There's a moment in almost every project that deals with media where you decide to run FFmpeg. Maybe you're building an app that lets users upload videos. Maybe you're automating a podcast pipeline. Maybe a client asked for "just a simple watermark" and you know from experience that nothing about video is simple.
So you install FFmpeg. It works. You run a command in your terminal, the file comes out the other side, and you feel good about it.
Then you try to run that same command on a server.
That's where things get interesting. The CPU pegs at 100%. Your web workers get stuck. Some request times out after 30 seconds because your reverse proxy says so. A 4K file eats all the RAM and the container gets killed with no explanation. You start writing a queue, then a worker pool, then a way to autoscale that worker pool, and suddenly you're not building a video product anymore — you're running a small infrastructure company as a side project.
This article is about a different path. We'll look at how to execute arbitrary FFmpeg commands without owning any of that infrastructure: what "arbitrary" really means, where the pain points hide, how a serverless FFmpeg API actually behaves, and how to design pipelines that hold up under real traffic. Along the way I'll point out where a service like FFmpeGo fits in, since it was built for exactly this problem.
Let's start with the word that does most of the work here.
What "Arbitrary FFmpeg Commands" Actually Means
Most video APIs online offer a menu. You pick from options like:
- Convert to MP4
- Compress video
- Extract audio
- Generate a thumbnail
- Add a watermark
These are useful. They're also a ceiling. The moment you need something that isn't on the menu, you're back to running FFmpeg yourself.
FFmpeg has hundreds of codecs, dozens of filters, and an argument parser that can express things you'd never find in a dropdown. A few examples of "arbitrary" in practice:
Concatenating clips with different resolutions and frame rates. You can't just cat files together. You need concat demuxer or filter_complex with scale and fps normalization, plus padding to a common frame size.
Building a picture-in-picture layout. Two inputs, an overlay filter, precise positioning, maybe a subtle border, and timing offsets so the inset appears at the right moment.
Loudness normalization for broadcast. Two-pass loudnorm hitting -16 LUFS for podcasts or -23 LUFS for EBU R128 delivery.
Generating an HLS ladder. One input, multiple renditions, segmented output, a master playlist, and correct bandwidth values in the manifest.
Frame extraction on a schedule. Pulling a JPG every N seconds with scene detection so you're not extracting 4,000 nearly identical frames.
Two-pass encoding for quality targets. First pass to a null output to gather statistics, second pass to hit a bitrate accurately.
Chaining filters that don't exist as presets anywhere. Crop, rotate, deinterlace, denoise, sharpen, color-correct, then encode with a specific profile and level.
None of these are exotic. They're ordinary media engineering. And every single one of them requires the ability to pass real FFmpeg arguments, not choose from a list.
That's the distinction worth holding onto: a preset API constrains you to the vendor's imagination. An arbitrary-command API constrains you to FFmpeg's capabilities, which is a much larger space.
Why presets break down under real requirements
Here's the pattern I've seen repeatedly. A team picks a preset-based video API because it's fast to integrate. Six months later, a product requirement shows up:
- "Marketing wants a lower-third graphic burned into the export."
- "The client needs captions positioned at a specific x/y offset."
- "We need variable bitrate for the premium tier and constant for the free tier."
- "Legal says the watermark has to be top-right with 60% opacity, not centered."
Every one of those is a five-line FFmpeg change. Every one is a support ticket, a roadmap request, or a migration with a preset API.
The Hidden Cost of Running FFmpeg on Your Own Servers
Let's talk about the infrastructure tax honestly, because it's bigger than most estimates.
It's not one server, it's a shape that keeps changing
The naive plan is: put FFmpeg on a box, send jobs to it. That works until traffic isn't flat. Video uploads cluster — evenings, weekends, product launches, marketing campaigns. Your box is idle half the day and melting the other half.
So you add a queue. Now you need a worker process, a retry policy, dead-letter handling, and visibility into which jobs are stuck. Then you add a second box and need a load balancer. Then you realize a single 4K job can starve everything else on the machine, so you need concurrency limits per worker. Then you need to autoscale on queue depth, which means you need metrics, which means you need a monitoring stack.
None of that is video work. All of it is work you now own forever.
The resource profile of FFmpeg is genuinely awkward
FFmpeg is a CPU-bound process that also happens to be memory-hungry and I/O-hungry at the same time. Some specifics:
- A single 1080p x264 transcode will happily use every core you give it. Give it 16 cores and it'll take 16.
- High-resolution filters — anything with large kernels like heavy denoise or optical-flow-based interpolation — can balloon memory usage well past what you'd expect.
- Reads and writes are streaming and constant. Network storage latency shows up directly in wall-clock time.
- Two-pass jobs need intermediate storage, and that intermediate file can be large.
On a shared server, one of these jobs ruins everyone else's latency. On a dedicated server, you're paying for idle capacity most of the day.
Timeouts are the silent killer
This one bites people late in development. Your app framework has a request timeout. Your reverse proxy has one. Your serverless platform of choice has one. The browser has one.
A 30-minute 4K transcode takes minutes even on fast hardware. It doesn't fit into a request lifecycle, which means you need asynchronous job handling — queue in, poll or webhook out. That's another chunk of code you have to write and maintain, plus storage for job state, plus a way to expire old jobs.
The scaling cliff
Everything is fine at ten videos a day. At a thousand a day, you're debugging queue backpressure. At ten thousand, you're writing runbooks. The work scales roughly linearly with volume while the engineering value stays flat. That's a bad trade.
How a Serverless FFmpeg API Changes the Picture
The idea behind a serverless FFmpeg API is straightforward: you keep the FFmpeg command, and you give up the machine.
You send a request containing your inputs and your arguments. Somewhere else, a container spins up with FFmpeg installed, runs your command, writes the output to storage, and sends you back a URL. Then it goes away.
From your side, the whole thing is one HTTP call. From the billing side, you pay for the execution time you consumed.
FFmpeGo works this way. You POST a JSON payload to a single endpoint — /v2/run — with the input URLs and the exact FFmpeg arguments you want to run. The response tells you what happened and where the output landed.
The mental model matters here. You're not configuring a service or learning a DSL. You're literally writing the FFmpeg command you'd write in your terminal, wrapping it in JSON, and shipping it to someone else's compute.
What the request looks like
The shape is roughly this — a list of inputs and an argv-style list of arguments:
{
"inputs": [
{ "url": "https://storage.example.com/raw/interview.mp4" }
],
"args": [
"-i", "interview.mp4",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "22",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"output.mp4"
]
}
The arguments are what you already know. If you've ever typed ffmpeg -i input.mp4 -c:v libx264 output.mp4, you've written 90% of this request.
What the response gives you
A 2xx response means the job completed. You get back a location for the output file — typically a signed URL you can download or pass downstream — along with timing information about how long the process ran.
A non-2xx response means the job failed, and here's the part that matters commercially: FFmpeGo only bills for successful encodes. Failed jobs aren't charged. For anyone who's burned money waiting on a worker pool that crashed halfway through a batch, that detail carries real weight.
A Step-by-Step Walkthrough: From Local Command to Cloud Job
Let's take a realistic scenario and walk through it. Suppose you're building a course platform and you need to prepare uploaded lecture videos for streaming.
Step 1: Write and test the command locally
Always start in your terminal. It's free, it's fast to iterate, and it's the only way to be sure your filter graph is correct. Something like:
ffmpeg -i lecture.mp4 \
-vf "scale=-2:720,fps=30" \
-c:v libx264 -preset medium -crf 21 -profile:v main -level 3.1 \
-c:a aac -b:a 128k -ac 2 \
-movflags +faststart \
lecture_720p.mp4
Run it. Watch the output. Check the file plays. Note anything weird in the logs.
Step 2: Handle the input path
Locally your input is a file path. In the cloud, it's a URL that the runner needs to fetch. So the first argument changes from a local filename to whatever identifier the service expects in place of the input — usually a basename or a placeholder that maps to the first entry in your inputs array. Check the docs for the exact convention; it's the one detail that differs from a pure local command.
Step 3: Send the job
Build the JSON payload with your arguments, POST it, and read the response. In practice this is a handful of lines in any language. Here's the shape in JavaScript:
const res = await fetch("https://ffmpego.com/v2/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.FFMPEGO_KEY}`
},
body: JSON.stringify({
inputs: [{ url: signedInputUrl }],
args: [
"-i", "lecture.mp4",
"-vf", "scale=-2:720,fps=30",
"-c:v", "libx264", "-preset", "medium", "-crf", "21",
"-profile:v", "main", "-level", "3.1",
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
"-movflags", "+faststart",
"output.mp4"
]
})
});
const job = await res.json();
Step 4: Store or forward the output
Take the URL from the response and either download it, copy it to your own bucket, or hand it straight to your CDN. Many teams skip the copy and just treat the returned location as the canonical source for the next step.
Step 5: Handle failure properly
Wrap the call, log the error body, and retry with backoff on transient failures. Since failed jobs aren't billed, a retry costs you nothing but time — which is the correct tradeoff when the alternative is dropping a customer's upload on the floor.
Step 6: Cap your spend
Set a monthly cap. Then decide what happens when you approach it: alert, throttle low-priority jobs, or queue them for the next cycle. Hard caps exist specifically so a runaway loop in your code can't turn into a five-figure invoice.
filter_complex and Multi-Input Jobs: Where Things Get Real
Single-input, single-output jobs are the easy case. The interesting work starts when you have more than one input or a filter graph with branches.
FFmpeg's -filter_complex lets you label streams, split them, merge them, and route them independently. It's the tool that turns FFmpeg from a converter into a small non-linear editor.
Example: picture-in-picture with a timed inset
ffmpeg -i main.mp4 -i webcam.mp4 \
-filter_complex "[1:v]scale=480:-2[pip];[0:v][pip]overlay=W-w-40:H-h-40:enable='between(t,10,120)'" \
-c:v libx264 -crf 21 -preset medium \
-c:a aac -b:a 128k \
output.mp4
Two inputs in, one stream labeled pip, overlaid at a position computed from the main video's dimensions, only active between the 10-second and 120-second marks. There is no preset API on earth that offers this.
Example: concat with normalization
Different sources, different resolutions. Normalize first, then concatenate:
ffmpeg -i a.mp4 -i b.mp4 \
-filter_complex "\
[0:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v0];\
[1:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v1];\
[v0][0:a][v1][1:a]concat=n=2:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" \
-c:v libx264 -crf 20 -preset medium -c:a aac -b:a 160k \
output.mp4
Note setsar=1. Forgetting it is one of the classic causes of a concat that produces stretched or squished video. Small detail, big consequence.
Example: HLS ladder from one pass
ffmpeg -i source.mp4 \
-filter_complex "\
[0:v]split=3[s0][s1][s2];\
[s0]scale=w=1920:h=1080[v0];\
[s1]scale=w=1280:h=720[v1];\
[s2]scale=w=854:h=480[v2]" \
-map "[v0]" -c:v:0 libx264 -b:v:0 5000k -preset medium \
-map "[v1]" -c:v:1 libx264 -b:v:1 2800k -preset medium \
-map "[v2]" -c:v:2 libx264 -b:v:2 1400k -preset medium \
-map a:0 -c:a aac -b:a 128k -ac 2 \
-f hls -hls_time 6 -hls_playlist_type vod \
-hls_segment_filename "seg_%v_%03d.ts" \
-master_pl_name master.m3u8 \
playlist_%v.m3u8
One job, three renditions, a master playlist. On your own hardware this is a heavy, long-running process that blocks everything. In a managed runner, it's just a request that takes a while.
Why this matters for architecture
Because the runner accepts arbitrary arguments, you don't have to choose between "use a video API" and "do everything yourself." You get to write the pipeline once, as FFmpeg arguments, and ship it. When requirements change, you edit the arguments — no vendor ticket, no SDK upgrade, no migration.
Compute Seconds: Why Usage-Based Pricing Fits Media Work
Media processing has a spiky cost profile. A quiet day costs almost nothing. A launch day costs a lot. Flat-rate server pricing doesn't match that shape, which is why so many teams over-provision and then feel bad about idle CPUs.
FFmpeGo bills on compute seconds — the actual wall-clock execution time of the FFmpeg process. That's a clean unit because it maps directly to what you're consuming. If a job runs for 90 seconds, you're billed for roughly 90 compute seconds. A short clip costs almost nothing. A long 4K job costs more.
Compare that to the alternatives:
| Approach | Upfront cost | Idle cost | Scaling effort | Failure cost |
|---|---|---|---|---|
| Dedicated media server | High (instance + ops) | You pay for idle hours | Manual or scripted | You pay for failed jobs |
| Container service you manage | Medium | Partial | Autoscaling config | You pay for failed jobs |
| Queue + worker pool | Medium to high | Low but nonzero | Significant engineering | You pay for failed jobs |
| Serverless FFmpeg API | None | None | None | Not billed on failure |
The row that surprises people is the last one. Failed encodes are common during development — bad filter graphs, missing inputs, unsupported codecs. Paying for those is a small tax that adds up when you're iterating quickly.
A quick mental math exercise
Say your average job is a 1080p clip that takes 40 compute seconds. A thousand uploads a month is 40,000 compute seconds. Compare that to the smallest dedicated instance that could handle the peak — which you'd rent for 730 hours whether you used it or not. The server is almost certainly more expensive, and it comes with a pager.
I'd rather not invent precise dollar figures here, because pricing changes and your mileage varies with job length and complexity. The point is the shape: pay for what you run, not for what you might need.
Common Mistakes When Running FFmpeg in the Cloud
This section is the one I'd bookmark. These are the mistakes I've made, seen made, or debugged for someone else.
1. Forgetting setsar=1 before concatenating
Covered above, but it deserves repeating. Mismatched sample aspect ratios produce output that looks wrong in ways that are hard to diagnose. Normalize SAR along with resolution and frame rate.
2. Using the wrong preset for the job
-preset slow buys you a small quality gain for a large time increase. On a per-second billing model, that tradeoff is visible. For drafts and previews, veryfast or ultrafast is fine. Save the slow presets for final exports where quality actually matters.
3. Not setting -movflags +faststart
Without it, the MP4's metadata sits at the end of the file, so players have to download the whole thing before playback starts. This is the single most common cause of "why does my video take forever to start streaming?"
4. Ignoring audio sample rates in concat
If one input is 44.1 kHz and another is 48 kHz, concat can fail or produce garbled audio. Add aresample=48000 to the audio chains.
5. Failing to specify -map when filters are involved
Once you use -filter_complex, FFmpeg can't always guess which stream you want. Be explicit with -map "[v]" -map "[a]". Guessing produces output with no audio, which is a fun bug to find in production.
6. Assuming a 4K job behaves like a 1080p one
It's roughly four times the pixels, and some filters scale worse than linearly. Test at the resolution you'll actually ship.
7. Wrapping the call in a synchronous user request
Even a 30-second transcode is too long to hold an HTTP request open in many stacks. Fire the job, store the job ID, and notify when it's done — webhook, poll, or a message on a queue.
8. No monthly cap
Set one. It's the difference between a bad afternoon and a bad quarter.
9. Not logging the exact arguments on failure
When a job fails, you want the full argv and the error output. Store both. Debugging a failed filter graph without the original command is guesswork.
10. Skipping the local test
If it doesn't work in your terminal, it won't work in the cloud. Test locally first. Every time.
Security and Operational Concerns Worth Thinking Through
Handing arbitrary command arguments to a remote service raises an obvious question: isn't that dangerous?
Mostly no, and here's why. The runner executes FFmpeg inside a sandboxed container. Your arguments map to FFmpeg's argument parser, not to a shell. FFmpeg has its own attack surface, but you're not getting shell access, and you're not reading the host filesystem. Still, a few habits keep things tidy:
Use signed, expiring URLs for inputs. Don't hand over permanent public links to private media. Generate short-lived URLs scoped to the job.
Lock down your API key. Server-side only. Never in a mobile app or a browser bundle. Rotate it if it leaks.
Validate user-supplied arguments. If your app builds FFmpeg arguments from user input, sanitize them. Allowing a user to inject -f or a protocol handler they shouldn't have is a bug on your side, not the platform's.
Set resource expectations. Long jobs cost more. If you accept user uploads, bound the length or resolution before queuing.
Cap spend at the account level. Then also cap it in your own code so a loop doesn't burn through the cap in an hour.
Who This Actually Helps
Let me be concrete about the profiles where this pattern pays off fastest.
Indie app developers. You're one person with a product idea. Standing up a media server means Kubernetes or a heap of Ansible you'll be babysitting at 2 a.m. A serverless FFmpeg API removes that entire category of work. You ship the feature and move on.
Automation engineers. You're gluing systems together — fetching assets from a CMS, transforming them, pushing them into a DAM. The transformations are the interesting part; the compute is not. Offloading it means the automation stays a script instead of becoming a platform.
SaaS companies. You've got customers, SLAs, and a finance team that asks questions about cloud spend. Usage-based billing on compute seconds is easy to explain and easy to attribute to a customer or a feature. Hard caps keep finance calm.
Agencies. Each client has different requirements, and preset APIs can't cover them all. Arbitrary arguments mean one integration pattern serves every client, with the FFmpeg command as the configuration.
Media libraries and archives. Batch jobs on thousands of files. You want to run the batch, walk away, and not have a server sitting idle for the three weeks between batches.
A Realistic Batch Scenario
Suppose you run a podcast network with 400 back-catalog episodes that need loudness normalization and a standardized 128k AAC audio track for a new distribution partner.
Locally, the command is:
ffmpeg -i episode.wav -af "loudnorm=I=-16:TP=-1.5:LRA=11" \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
episode_normalized.m4a
You need to run it 400 times. Options:
- Buy a big instance, run them in parallel with GNU Parallel, babysit it for two days, then shut the instance down.
- Write a loop that fires 400 jobs at the serverless API with some concurrency limit.
Option two is about 30 lines of code. The concurrency limit keeps you from hammering the service. Failed jobs are retried, and since they're not billed, the retries are free. When it finishes, you have 400 URLs and a log of which ones needed a second attempt.
The second approach also has a nice property: when the next batch of 40 episodes arrives, you run the same script. No infrastructure to wake up, no capacity to plan.
Comparing the Two Philosophies
There's a real philosophical split in media tooling.
Philosophy A: constrain the user. Offer a small set of well-tested operations. The vendor guarantees they work. The user trades flexibility for reliability.
Philosophy B: give the user the tool. Offer FFmpeg itself. The vendor guarantees the compute and the plumbing. The user owns the command.
Both are valid. Preset APIs are great when your needs are genuinely simple and unlikely to change. The trouble is that media needs rarely stay simple. The first time a client asks for something off-menu, Philosophy A becomes a blocker.
Philosophy B has the opposite failure mode: unlimited flexibility means you can write a bad command. That's a real cost, and it's why testing locally matters so much. But it's a cost you control, and it doesn't grow when you add features.
For anything beyond a one-off script, I'd take B every time. The flexibility is worth the accountability.
A Short Checklist Before You Ship
Run through this before your first production job:
- The exact FFmpeg command works in your local terminal on a representative file
- Input URLs are signed and expire quickly
- The API key lives server-side only
- Filters include
setsar=1where concatenation or resizing is involved -
-mapis explicit whenever-filter_complexis used -
-movflags +faststartis set for progressive MP4s - Audio sample rate and channel count are normalized for multi-input jobs
- The call is asynchronous — no user request is held open waiting for a transcode
- Failures are logged with the full argv and the error body
- Retries use exponential backoff
- A monthly spend cap is configured
- Low-priority jobs have a defined behavior when the cap is near
- There's a test that runs one job end to end on every deploy
Thirteen items. Most of them take five minutes. Together they prevent the majority of production incidents I've seen in this space.
FAQ
Do I need to know FFmpeg to use a serverless FFmpeg API? Yes, mostly. The advantage is that you get the full power of FFmpeg, but the flip side is that you write the arguments. If you've never touched FFmpeg, spend an hour with the official documentation and a sample file. It pays off immediately.
What happens if my job takes longer than expected? There's usually a maximum execution time per job and a maximum file size. For very long jobs — feature-length 4K, complex ladders — consider splitting the work into stages: preprocess, then encode, then package. Each stage is a separate job and each is easier to debug.
Can I run jobs in parallel? Yes, subject to whatever concurrency limits apply to your account. Fire them with a bounded concurrency pool rather than all at once. A limit of 10 to 20 concurrent jobs is usually a good starting point for batch work.
How do I know a job succeeded?
A 2xx status means the encode completed. Anything else means it failed, and you aren't billed for it. Log the response body either way — it usually contains FFmpeg's stderr, which tells you exactly what went wrong.
Is it cheaper than running my own server? Depends entirely on volume and utilization. If you'd run a server at 80% utilization, a server can win on raw cost. If your load is spiky — which media load almost always is — you'll come out ahead by paying only for compute seconds, and you'll save the engineering time on top.
What about GPU-accelerated encoding? FFmpeg supports NVENC and similar hardware encoders, and a managed runner handles the hardware side. Whether it's worth it depends on your volume. For small batches, CPU encoding at a fast preset is usually simpler and good enough. For large volumes, hardware encoding changes the math considerably.
Can I use it for audio-only work? Absolutely. Audio normalization, format conversion, silence detection, splitting a long recording into chapters — all of it works the same way. Audio jobs are cheap because they run fast.
What if I need to chain several FFmpeg commands?
Run them as separate jobs and pass outputs forward. Chaining in a single command is possible with -filter_complex for many cases, but separate jobs are easier to reason about, easier to retry, and easier to debug when one stage fails.
Where to Go From Here
If you've read this far, you probably have a specific job in mind. Here's my suggestion for the next hour.
Write the FFmpeg command you need. Test it locally until the output is right. Then look at what it would take to run it in the cloud — the inputs, the arguments, the output handling. If the infrastructure part looks like more work than the media part, that's your signal.
FFmpeGo was built for that exact moment: you keep the command, you drop the servers. Sending a job is a single POST to /v2/run, you pay for the compute seconds you actually use, failed encodes aren't billed, and a monthly cap keeps surprises off the table. There's a free tier to try it on real files before you commit to anything.
Start with the simplest version of your pipeline. One input, one output, one command. Get it working end to end. Then add the filter_complex, the multi-input cases, the batch scripts. Each addition is a few lines of arguments rather than a new piece of infrastructure.
That's the whole appeal. You spend your time on the media, not the machines.