DEV Community

Javid Jamae
Javid Jamae

Posted on • Originally published at ffmpeg-micro.com

How to Use FFmpeg with Ruby on Rails (No Installation Required)

Originally published at ffmpeg-micro.com.

You need to process video in your Ruby on Rails app. Users upload clips, and you need to convert formats, resize for mobile, or extract thumbnails. You search "ruby ffmpeg" and find streamio-ffmpeg, a gem that wraps the FFmpeg binary.

It works great locally. Then you deploy to Heroku.

TL;DR: You can process video from Ruby without installing FFmpeg anywhere. Send an HTTP request to a cloud API, get results back. Works on Heroku, Render, Fly.io, or any platform that can make outbound HTTP calls.

The System Call Approach (And Why It Breaks)

The raw Ruby approach looks like this:

system("ffmpeg -i input.mp4 -c:v libx264 -crf 23 output.mp4")
Enter fullscreen mode Exit fullscreen mode

Or with backticks for capturing output:

result = `ffmpeg -i input.mp4 -vf scale=-2:720 output.mp4 2>&1`
raise "FFmpeg failed: #{result}" unless $?.success?
Enter fullscreen mode Exit fullscreen mode

On your dev machine, fine. In production:

  • Heroku doesn't include FFmpeg. You need a buildpack, which adds cold-start time and maintenance burden.
  • Render and Fly.io require custom Dockerfiles just to get the binary available.
  • System calls block your Puma worker. That thread is stuck until FFmpeg finishes.
  • You own codec updates, security patches, and temp file cleanup.

The streamio-ffmpeg Gem

streamio-ffmpeg is the most popular Ruby FFmpeg wrapper:

require "streamio-ffmpeg"

movie = FFMPEG::Movie.new("input.mp4")
movie.transcode("output.mp4", "-c:v libx264 -crf 23")
Enter fullscreen mode Exit fullscreen mode

Cleaner syntax, but it still shells out to the FFmpeg binary. No binary, no transcoding. The gem hasn't had a significant update in years, and it doesn't handle async processing or cloud storage.

Processing Video via API (No FFmpeg Needed)

FFmpeg Micro is a cloud API that runs FFmpeg for you. One HTTP request from Ruby, and you get a transcoded file back. No binary, no Dockerfile, no buildpack.

With Ruby's built-in Net::HTTP:

require "net/http"
require "json"

api_key = ENV.fetch("FFMPEG_MICRO_API_KEY")
uri = URI("https://api.ffmpeg-micro.com/v1/transcodes")

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{api_key}"
request["Content-Type"] = "application/json"
request.body = JSON.generate({
  inputs: [{ url: "https://example.com/video.mp4" }],
  outputFormat: "mp4",
  preset: { quality: "high", resolution: "1080p" }
})

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

job = JSON.parse(response.body)
puts "Job ID: #{job["id"]}"
Enter fullscreen mode Exit fullscreen mode

If you're using Faraday (common in Rails apps):

conn = Faraday.new(url: "https://api.ffmpeg-micro.com") do |f|
  f.request :json
  f.response :json
end

response = conn.post("/v1/transcodes") do |req|
  req.headers["Authorization"] = "Bearer #{ENV.fetch("FFMPEG_MICRO_API_KEY")}"
  req.body = {
    inputs: [{ url: "https://example.com/video.mp4" }],
    outputFormat: "mp4",
    preset: { quality: "high", resolution: "1080p" }
  }
end

job = response.body
puts "Job ID: #{job["id"]}"
Enter fullscreen mode Exit fullscreen mode

Both do the same thing. Pick whichever fits your project.

Checking Job Status and Downloading Results

Transcoding takes seconds to minutes depending on file size. Poll for completion:

job_id = job["id"]

loop do
  sleep 2
  uri = URI("https://api.ffmpeg-micro.com/v1/transcodes/#{job_id}")
  request = Net::HTTP::Get.new(uri)
  request["Authorization"] = "Bearer #{api_key}"

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end

  status = JSON.parse(response.body)
  break unless %w[queued processing].include?(status["status"])
end

if status["status"] == "completed"
  uri = URI("https://api.ffmpeg-micro.com/v1/transcodes/#{job_id}/download")
  request = Net::HTTP::Get.new(uri)
  request["Authorization"] = "Bearer #{api_key}"

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end

  download = JSON.parse(response.body)
  puts "Download URL: #{download["url"]}"
end
Enter fullscreen mode Exit fullscreen mode

The download endpoint returns a signed URL valid for 10 minutes. Save the file or redirect your user to it.

Advanced: Custom FFmpeg Options

Presets handle most use cases. For full control, pass FFmpeg options directly:

response = conn.post("/v1/transcodes") do |req|
  req.headers["Authorization"] = "Bearer #{ENV.fetch("FFMPEG_MICRO_API_KEY")}"
  req.body = {
    inputs: [{ url: "https://example.com/video.mp4" }],
    outputFormat: "webm",
    options: [
      { option: "-c:v", argument: "libvpx-vp9" },
      { option: "-crf", argument: "30" },
      { option: "-b:v", argument: "0" },
      { option: "-c:a", argument: "libopus" }
    ]
  }
end
Enter fullscreen mode Exit fullscreen mode

Same FFmpeg flags you'd use on the command line, passed as structured JSON. This converts MP4 to WebM using VP9 and Opus.

Uploading Local Files

If your video isn't at a public URL, upload it first. Three steps:

# Step 1: Get a presigned upload URL
presign = conn.post("/v1/upload/presigned-url") do |req|
  req.headers["Authorization"] = "Bearer #{api_key}"
  req.body = {
    filename: "my-video.mp4",
    contentType: "video/mp4",
    fileSize: File.size("/path/to/my-video.mp4")
  }
end

upload_url = presign.body.dig("result", "uploadUrl")
filename = presign.body.dig("result", "filename")

# Step 2: Upload directly to cloud storage
upload_conn = Faraday.new
upload_conn.put(upload_url) do |req|
  req.headers["Content-Type"] = "video/mp4"
  req.body = File.binread("/path/to/my-video.mp4")
end

# Step 3: Confirm the upload
confirm = conn.post("/v1/upload/confirm") do |req|
  req.headers["Authorization"] = "Bearer #{api_key}"
  req.body = {
    filename: filename,
    fileSize: File.size("/path/to/my-video.mp4")
  }
end

gcs_url = confirm.body.dig("result", "gcsUrl")
Enter fullscreen mode Exit fullscreen mode

After confirmation, pass the GCS URL as input in your transcode request.

Common Pitfalls

  • Blocking Puma workers with polling. Don't poll in a web request. Use ActiveJob or Sidekiq to check transcode status in a background job.
  • Forgetting use_ssl: true with Net::HTTP. The API is HTTPS-only. Without SSL, you get a connection refused error, not a helpful message.
  • Polling too fast. A 2-second interval is plenty. Don't hammer the status endpoint every 100ms.
  • Ignoring the failed status. Always check for failure in your polling loop. A failed job never transitions to completed, so an infinite loop wastes resources.
  • Hardcoding API keys. Use ENV.fetch("FFMPEG_MICRO_API_KEY") or Rails credentials (Rails.application.credentials.ffmpeg_micro_api_key).

Frequently Asked Questions

Does this work on Heroku?

Yes. The API approach only needs Ruby's Net::HTTP (included in stdlib) or any HTTP client gem. No FFmpeg binary, no buildpack, no special configuration.

What about ActiveStorage videos?

ActiveStorage handles upload and storage. For processing, grab the blob URL and pass it to the API. You don't need ActiveStorage variants or an FFmpeg preview processor.

How much does FFmpeg Micro cost?

FFmpeg Micro bills per minute of video processed. The free tier gives you enough minutes to build and test. A 5-minute video costs a few cents to transcode. See the pricing page for current rates.

Can I process video inside a Rails controller action?

You can kick off the job in the controller, but don't wait for it to finish. Return a 202 to the user and check the result with a background job. Sidekiq, GoodJob, or Solid Queue all work.

What Ruby version do I need?

Ruby 2.7 or later. The code examples use modern syntax like ENV.fetch and dig, but the API works with any Ruby version that supports HTTPS.

Last verified: May 2026. All API examples tested against FFmpeg Micro v1.

Top comments (0)