Video-on-demand platforms require efficient management of large media files, metadata, and secure content delivery. Strapi provides a customizable API and media library. But it doesn’t handle storage, streaming, or frontend rendering directly.
Integrating Strapi with external providers (AWS S3, transcoding services, or CDNs) enables scalable storage, adaptive streaming, role-based access, and secure playback, forming a production-ready VOD backend.
Prerequisites
- Install Node.js v18+ using this command to upgrade the Node.js version:
- Use a package manager: npm, yarn, or pnpm.
- For production, use PostgreSQL.
Setup Project with PostgreSQL
PostgreSQL scales better with relational data like users, roles, and video metadata.
Create a Strapi Project with PostgreSQL
npx create-strapi-app@latest my-vod-cms --quickstartConfigure the Database Connection
Update ./config/database.js with your PostgreSQL credentials. It ensures reliable handling of metadata, supports concurrency at scale, and keeps sensitive credentials portable across dev, staging, and production via environment variables.
module.exports = ({ env }) => ({connection: {client: 'postgres',connection: {host: env('DATABASE_HOST', '127.0.0.1'),port: env.int('DATABASE_PORT', 5432),database: env('DATABASE_NAME', 'vodcms'),user: env('DATABASE_USERNAME', 'voduser'),password: env('DATABASE_PASSWORD', 'securepassword'),ssl: env.bool('DATABASE_SSL', false),},},});Explanation:
- host: env('DATABASE_HOST', '127.0.0.1') : Reads the DATABASE_HOST environment variable to determine the database server’s hostname. Defaults to '127.0.0.1' (localhost) if not set.
- port: env.int('DATABASE_PORT', 5432) : Reads the DATABASE_PORT environment variable as an integer to set the database port. Defaults to 5432, the standard PostgreSQL port.
- database: env('DATABASE_NAME', 'strapi') : Reads DATABASE_NAME to determine which database Strapi should use. Defaults to 'strapi'.
Add Environment Variables
Setting environment variables ensures Strapi connects to the correct database without hardcoding sensitive values. Using a .env file keeps credentials secure, configurable across environments, and consistent with deployment best practices.
# .envDATABASE_HOST=localhostDATABASE_PORT=5432DATABASE_NAME=vodcmsDATABASE_USERNAME=postgresDATABASE_PASSWORD=your_secure_password_hereBuild & Start the Project
Run the build command to generate the admin panel and then start the server. Strapi will automatically create the tables in your PostgreSQL database.
npm run buildnpm run developContent Models for VOD
Define a Video collection with fields like Title, Description, File/URL, Thumbnail, Duration, Tags, Category, and Access Level (Free/Premium). Metadata implements search, filtering, and personalized recommendations, while supporting scalability when managing large video libraries.
{"kind": "collectionType","collectionName": "videos","info": {"singularName": "video","pluralName": "videos","displayName": "Video"},"options": { "draftAndPublish": true },"attributes": {"title": { "type": "string", "required": true },"description": { "type": "text" },"videoFile": { "type": "media", "required": true, "allowedTypes": ["videos"] },"thumbnail": { "type": "media", "allowedTypes": ["images"] },"category": {"type": "relation","relation": "manyToOne","target": "api::category.category","inversedBy": "videos"},"tags": {"type": "relation","relation": "manyToMany","target": "api::tag.tag","mappedBy": "videos"}}}Explanation:
- kind: "collectionType": Defines this content type as a collection, meaning it can have multiple entries (videos).
- info: { singularName, pluralName, displayName }: Metadata for Strapi admin panel.
- pluginOptions: {}: Placeholder for any plugin-specific configurations (empty here).
FFmpeg Integration for Video Uploads
When managing a VOD platform, raw video uploads (like MP4s) are too large and inefficient to stream at scale. To deliver smooth playback across devices and network conditions, videos need to be transcoded into adaptive streaming formats (like HLS).
Instead of relying on an external platform for upload and transcoding, configure Strapi’s upload system to accept raw video files and then trigger a local or server-side FFmpeg process for transcoding.
FFmpeg-Based Transcoding Workflow
Step 1: Configure Strapi to store uploaded videos temporarily, either in the file system or in cloud object storage that you control.
Step 2: Use FFmpeg to convert uploaded MP4s into adaptive streaming formats (such as HLS). This can be done automatically by triggering a script whenever a new video is uploaded to Strapi’s media library.
Step 3: The transcoded HLS files (typically .m3u8 playlists and .ts segments) should be stored in a publicly accessible location (e.g., in an S3 bucket behind a CDN).
Step 4: After transcoding the files, update the video’s entry in Strapi with the new streaming URL and any additional format/resolution details using Strapi’s REST API or by using webhooks.
Example FFmpeg Command for HLS
To convert an MP4 into HLS format:
ffmpeg -i input.mp4 -preset veryfast -g 48 -sc_threshold 0 -map 0:v:0 -map 0:a:0 -c:v libx264 -c:a aac -b:v:0 3000k -b:v:1 1500k -s:v:0 1920x1080 -s:v:1 1280x720 -f hls -hls_time 4 -hls_playlist_type vod -hls_segment_filename 'output_%03d.ts' output.m3u8This command generates an HLS playlist (output.m3u8) and video segments ready for adaptive streaming.
Automated Transcoding with Strapi
Use Strapi lifecycle hooks (afterCreate or afterUpdate) in your Video collection to programmatically invoke the FFmpeg transcoding process each time a new video is uploaded.
Optionally, you can also set up a background job queue (e.g., using Bull or Kue) for offloading intensive transcoding tasks.
Updating Playback URL Post-Transcode
After FFmpeg completes, update the Strapi video entry’s videoFile or playbackUrl field with the path to the new .m3u8 file for secure and adaptive streaming.
Secure & Scalable Playback
Use a CDN or signed URLs for access control, ensuring only authorized viewers can stream premium content. For role-based access and premium logic, follow the previously described Strapi RBAC and custom policies.
Configure Roles in Strapi Admin
Navigate to Settings → Roles under the Users & Permissions plugin.
- Select the Authenticated role (applies to logged-in users).
- Under Video permissions
- Enable find and findOne (read access).
- Do not enable create, update, or delete.
- Save your changes.
This sets baseline permissions, but doesn’t yet distinguish between free and premium videos , which is why we need custom logic.
Create a Custom Policy
Policies in Strapi act as middleware for authorization logic. We’ll create a custom one to restrict premium content.
mkdir -p src/policiestouch src/policies/is-premium.js// path: ./src/policies/is-premium.js
module.exports = (policyContext, config, { strapi }) => {const { user } = policyContext.state; // get the user from the request contextconst video = policyContext.params; // get the request parameters
// 1. If the user is an admin, always allow.if (user && user.role.name === 'Admin') {return true;}
// 2. Find the video being requestedreturn strapi.entityService.findOne('api::video.video', video.id, { populate: ['isPremium'] }).then((video) => {if (!video) {return false; // Video doesn't exist}
// 3. If the video is NOT premium, anyone can watch it.if (!video.isPremium) {return true;}
// 4. If the video IS premium, check if the user is authenticated and has premium access.// (This assumes you add a `isPremium: Boolean` field to the User collection type)if (user && user.isPremium) {return true;}
// 5. If none of the above, block access.return false;});};Apply the policy to the video controller
Next, extend the default controller for the video API to use our custom policy before serving content.
mkdir -p src/api/video/controllerstouch src/api/video/controllers/video.js// path: ./src/api/video/controllers/video.js
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::video.video', ({ strapi }) => ({async findOne(ctx) {// Check our custom policy firstconst isAllowed = await strapi.policy('is-premium').handler(ctx);
if (!isAllowed) {return ctx.unauthorized('You are not authorized to view this premium content.');}
// If allowed, proceed with the default Strapi behaviorconst { data, meta } = await super.findOne(ctx);return { data, meta };}}));Rebuild and Restart Strapi
Rebuild the project to apply changes:
npm run buildnpm run developMedia Handling & Transcoding Workflow
Raw MP4 uploads are inefficient for large-scale delivery. A production-ready VOD platform needs adaptive streaming formats like HLS or DASH, which adjust quality based on the viewer’s device and bandwidth.
To achieve this, combine cloud storage with a transcoding service that converts uploaded MP4s into multiple resolutions, generates adaptive bitrate streams for smooth playback, and outputs secure playback URLs.
Strapi stays in sync via webhooks. When a video is transcoded, the external service sends a callback to update Strapi automatically, ensuring your backend always reflects the correct playback status.
// src/api/webhooks/controllers/video.jsmodule.exports = {async handle(ctx) {const { type, data } = ctx.request.body;
if (type === 'video.processed') {await strapi.db.query('api::video.video').update({where: { sourceId: data.id },data: { playbackUrl: data.playbackUrl },});}
ctx.send({ received: true });},};Explanation:
- module.exports = ({ env }) => ({ ... }) : Exports a function that reads environment variables and sets plugin configurations for Strapi.
- upload: { config: { ... } } : Configures the upload plugin, which handles media (images, videos, files) in Strapi.
- providerOptions: { cloud_name, api_key, api_secret } : Reads environment variables to securely provide Cloudinary credentials.
