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

code
npx create-strapi-app@latest my-vod-cms --quickstart
code

Configure 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.

code
module.exports = ({ env }) => ({
code
connection: {
code
client: 'postgres',
code
connection: {
code
host: env('DATABASE_HOST', '127.0.0.1'),
code
port: env.int('DATABASE_PORT', 5432),
code
database: env('DATABASE_NAME', 'vodcms'),
code
user: env('DATABASE_USERNAME', 'voduser'),
code
password: env('DATABASE_PASSWORD', 'securepassword'),
code
ssl: env.bool('DATABASE_SSL', false),
code
},
code
},
code
});

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.

code
# .env
code
DATABASE_HOST=localhost
code
DATABASE_PORT=5432
code
DATABASE_NAME=vodcms
code
DATABASE_USERNAME=postgres
code
DATABASE_PASSWORD=your_secure_password_here
code

Build & 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.

code
npm run build
code
npm run develop

Content 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.

code
{
code
"kind": "collectionType",
code
"collectionName": "videos",
code
"info": {
code
"singularName": "video",
code
"pluralName": "videos",
code
"displayName": "Video"
code
},
code
"options": { "draftAndPublish": true },
code
"attributes": {
code
"title": { "type": "string", "required": true },
code
"description": { "type": "text" },
code
"videoFile": { "type": "media", "required": true, "allowedTypes": ["videos"] },
code
"thumbnail": { "type": "media", "allowedTypes": ["images"] },
code
"category": {
code
"type": "relation",
code
"relation": "manyToOne",
code
"target": "api::category.category",
code
"inversedBy": "videos"
code
},
code
"tags": {
code
"type": "relation",
code
"relation": "manyToMany",
code
"target": "api::tag.tag",
code
"mappedBy": "videos"
code
}
code
}
code
}
code

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:

code
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.m3u8

This 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 SettingsRoles under the Users & Permissions plugin.

  1. Select the Authenticated role (applies to logged-in users).
  2. Under Video permissions
  1. Enable find and findOne (read access).
  2. Do not enable create, update, or delete.
  1. 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.

code
mkdir -p src/policies
code
touch src/policies/is-premium.js
code
code
// path: ./src/policies/is-premium.js
code

code
module.exports = (policyContext, config, { strapi }) => {
code
const { user } = policyContext.state; // get the user from the request context
code
const video = policyContext.params; // get the request parameters
code

code
// 1. If the user is an admin, always allow.
code
if (user && user.role.name === 'Admin') {
code
return true;
code
}
code

code
// 2. Find the video being requested
code
return strapi.entityService.findOne('api::video.video', video.id, { populate: ['isPremium'] })
code
.then((video) => {
code
if (!video) {
code
return false; // Video doesn't exist
code
}
code

code
// 3. If the video is NOT premium, anyone can watch it.
code
if (!video.isPremium) {
code
return true;
code
}
code

code
// 4. If the video IS premium, check if the user is authenticated and has premium access.
code
// (This assumes you add a `isPremium: Boolean` field to the User collection type)
code
if (user && user.isPremium) {
code
return true;
code
}
code

code
// 5. If none of the above, block access.
code
return false;
code
});
code
};

Apply the policy to the video controller

Next, extend the default controller for the video API to use our custom policy before serving content.

code
mkdir -p src/api/video/controllers
code
touch src/api/video/controllers/video.js
code
code
// path: ./src/api/video/controllers/video.js
code

code
'use strict';
code

code
const { createCoreController } = require('@strapi/strapi').factories;
code

code
module.exports = createCoreController('api::video.video', ({ strapi }) => ({
code
async findOne(ctx) {
code
// Check our custom policy first
code
const isAllowed = await strapi.policy('is-premium').handler(ctx);
code

code
if (!isAllowed) {
code
return ctx.unauthorized('You are not authorized to view this premium content.');
code
}
code

code
// If allowed, proceed with the default Strapi behavior
code
const { data, meta } = await super.findOne(ctx);
code
return { data, meta };
code
}
code
}));

Rebuild and Restart Strapi

Rebuild the project to apply changes:

code
npm run build
code
npm run develop
code

Media 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.

code
// src/api/webhooks/controllers/video.js
code
module.exports = {
code
async handle(ctx) {
code
const { type, data } = ctx.request.body;
code

code
if (type === 'video.processed') {
code
await strapi.db.query('api::video.video').update({
code
where: { sourceId: data.id },
code
data: { playbackUrl: data.playbackUrl },
code
});
code
}
code

code
ctx.send({ received: true });
code
},
code
};

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.