I built a video generation workflow by combining n8n (a business automation tool) and Remotion (a React-based video framework).
In this post—the "Server Setup Edition"—I will share how to build an HTTP API server (Express) on a VPS to render Remotion videos, along with the specific pitfalls I encountered (such as memory shortages and bundling times).
Architecture
The setup involves sending a POST request from n8n's "HTTP Request" node. The server receives parameters (like text and colors), renders the video on the server side, and returns the file path.
- n8n: Sends text and parameters via POST
- Express Server (VPS): Receives the request and executes Remotion
- Remotion: Renders React components into an MP4 file
- Response: Returns the path of the generated video
Environment
Since Remotion rendering uses a headless browser, it consumes significant resources, especially memory.
- VPS OS: Ubuntu 22.04
- Node.js: 20.x
- Remotion: 4.0.x
- Framework: Express 4.x
- Memory: 4GB or more recommended (2GB will likely cause crashes)
Project Structure
The structure is simple. The api directory contains the server logic, and src contains the React components for the video.
remotion-server/
├── src/
│ ├── Video.tsx # Video content (React component)
│ └── index.ts # Remotion entry point
├── api/
│ └── server.ts # Express server entry point
├── out/ # Destination for rendered videos
├── package.json
└── tsconfig.json
Implementation
1. package.json
The key point here is the start script. Since the Remotion rendering process is heavy, I relaxed the Node.js heap memory limit.
{
"name": "remotion-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node --max-old-space-size=3072 api/server.js"
},
"dependencies": {
"@remotion/bundler": "^4.0.0",
"@remotion/renderer": "^4.0.0",
"express": "^4.18.2",
"react": "^18.2.0",
"remotion": "^4.0.0"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}
2. Video Component (src/Video.tsx)
This is a simple video component that changes text and background color based on the received props.
import { AbsoluteFill, useCurrentFrame, interpolate } from 'remotion';
import React from 'react';
interface VideoProps {
text: string;
color: string;
}
export const MyVideo: React.FC<VideoProps> = ({ text, color }) => {
const frame = useCurrentFrame();
// Fade in between frames 0 and 30
const opacity = interpolate(frame, ,, {[1]
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill
style={{
backgroundColor: color,
justifyContent: 'center',
alignItems: 'center',
}}
>
<h1 style={{ fontSize: 100, color: 'white', opacity }}>
{text}
</h1>
</AbsoluteFill>
);
};
3. API Server (api/server.ts)
This is the core implementation. To improve performance, I designed it to execute the Webpack bundle only once at server startup and cache the result for reuse.
import express from 'express';
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const app = express();
app.use(express.json());
// Path resolution for ES Modules environment
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Keep bundle result in memory
let cachedBundleLocation: string | null = null;
// Create bundle at server startup
async function initializeBundle() {
console.log('Creating Webpack bundle...');
const entryPoint = path.resolve(__dirname, '../src/index.ts');
cachedBundleLocation = await bundle({
entryPoint,
webpackOverride: (config) => ({
...config,
cache: { type: 'filesystem' }, // Speed up build
}),
});
console.log('Bundle created:', cachedBundleLocation);
}
// Video generation endpoint
app.post('/render', async (req, res) => {
try {
const { text = 'Hello', color = '#000000' } = req.body;
if (!cachedBundleLocation) {
return res.status(500).json({ error: 'Bundle not initialized' });
}
// Select composition (video settings)
const composition = await selectComposition({
serveUrl: cachedBundleLocation,
id: 'MyVideo',
inputProps: { text, color },
});
// Generate output file path
const timestamp = Date.now();
const outputLocation = path.resolve(__dirname, `../out/video-${timestamp}.mp4`);
// Create directory
const outputDir = path.dirname(outputLocation);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log(`Rendering started: ${outputLocation}`);
// Execute rendering
await renderMedia({
composition,
serveUrl: cachedBundleLocation,
codec: 'h264',
outputLocation,
inputProps: { text, color },
});
console.log('Rendering completed.');
res.json({
success: true,
path: outputLocation,
filename: path.basename(outputLocation),
});
} catch (error) {
console.error('Render error:', error);
res.status(500).json({
error: 'Rendering failed',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// Startup process
const PORT = process.env.PORT || 3000;
initializeBundle()
.then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((e) => {
console.error('Failed to start server:', e);
process.exit(1);
});
Usage
Startup
# Install dependencies
npm install
# TypeScript compilation
npx tsc
# Start server (with heap memory expansion)
npm start
Request Example
Send a request from n8n or Curl as follows:
curl -X POST http://localhost:3000/render \
-H "Content-Type: application/json" \
-d '{"text":"Hello Zenn","color":"#3ea8ff"}'
Response:
{
"success": true,
"path": "/path/to/remotion-server/out/video-1738465200000.mp4",
"filename": "video-1738465200000.mp4"
}
Technical Challenges & Gotchas
Here are three major issues I faced during implementation and their solutions.
1. Bundling was too heavy and caused timeouts
Initially, I executed the bundle() function for every request. This meant Webpack ran every time a video was generated, resulting in a wait time of over 5 minutes before rendering even started.
Solution:
I changed the logic to run bundling only once at server startup (initializeBundle) and cache the generated bundleLocation in a variable. This allows rendering to start immediately for all subsequent requests.
// NG: Executed inside every request
// app.post('/render', async (req, res) => {
// const bundleLocation = await bundle({ ... }); // Too heavy
// });
// OK: Executed once at startup and cached
let cachedBundleLocation: string | null = null;
await initializeBundle();
2. Process crashed due to memory shortage
When running on a VPS (2GB memory plan), the process was frequently killed (Killed) during rendering. Remotion uses Chromium in headless mode, which consumes a lot of memory.
Solution:
I added the --max-old-space-size option to increase the Node.js heap size limit. I also highly recommend using a VPS with at least 4GB of RAM.
"scripts": {
"start": "node --max-old-space-size=3072 api/server.js"
}
3. Path resolution differences (__dirname vs process.cwd)
I encountered issues where file paths drifted between the development environment and the production environment (when started with PM2, etc.).
-
process.cwd(): The directory where the command was executed (depends on launch location) -
__dirname: The directory where the script file is located (depends on file location)
Solution:
I unified the path resolution by using __dirname (generated from import.meta.url since this is ESM) for specifying the entry point, resolving relative paths from there. This ensures stability regardless of where the command is run.
// NG: Path changes depending on where you launch
// const entryPoint = path.resolve(process.cwd(), 'src/index.ts');
// OK: Reliable as it's based on server.ts location
const entryPoint = path.resolve(__dirname, '../src/index.ts');
Conclusion
I have now completed an API server that can be called from n8n to generate videos at any time.
In this example, I simply saved the MP4 locally and returned the path. However, in a production environment, adding a process to upload the file to AWS S3 and return a signed URL would make it more practical.
Automated video generation can be applied to various use cases, such as automating SNS posts or creating personalized videos.
Top comments (0)