Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 053 / 100

Rails on Heroku, Render and Fly: lessons from production

Managed hosting options.

Expona Admin7 min read

Rails on Heroku, Render and Fly: lessons from production

After shipping Rails applications across Heroku, Render, and Fly.io, we've learned that the platform you choose shapes how you architect deploys, databases, and worker processes. Each service excels in different scenarios, but they share common patterns that senior teams exploit to move faster and operate more reliably.

Push to deploy and what actually happens

The magic of managed hosting is a git push that triggers a deploy. Heroku pioneered this with the Procfile, Render adopted it wholesale, and Fly.io built it into their CLI and GitHub Actions integration. But understanding the mechanics underneath prevents surprises in production.

When you push to main on Heroku, the platform detects your Gemfile, runs bundle install, executes rake assets:precompile if needed, and boots dynos running the processes you defined. Render does the same, though it rebuilds from scratch each deploy unless you use caching. Fly.io uses Docker: it reads your Dockerfile (or generates one) and deploys immutable images to their infrastructure.

The practical difference: Heroku and Render use buildpacks that hide the build environment; Fly.io's Docker approach is more explicit and portable. We've favored Fly.io for teams that need to run the same container locally via Docker Desktop, because debugging deploy issues becomes "run the same Dockerfile on your laptop."

Here's a minimal Procfile that works on Heroku and Render:

ruby
web: bundle exec puma -t 5:5 -w 4 -p $PORT
worker: bundle exec sidekiq -e production -c 10
release: bundle exec rake db:migrate

The release process runs once per deploy, blocking until migrations complete. This ensures you never boot application code that expects a schema the database doesn't have yet. Fly.io runs release commands similarly; both Render and Fly also support rollbacks if the release fails.

One gotcha: if your release process is slow (a migration touches millions of rows), your deploy hangs. We've seen teams timeout after 30 minutes waiting for a migration during a Saturday deploy. The solution is splitting long migrations into separate steps across multiple deploys, or running them offline before the code deploy.

Managed Postgres and the constraints that matter

All three platforms offer managed Postgres, but the trade-offs differ. Heroku's Postgres is battle-tested and expensive; Render and Fly.io undercut them significantly. However, cheaper doesn't always mean sufficient.

With managed Postgres, you gain automated backups, failover, and vendor responsibility for security patches. You lose direct SSH access to the database server and shell-level debugging. If your Rails app needs to diagnose a slow query at 3 AM, you're limited to query logs exposed via the provider's dashboard or metrics API.

Render provides read replicas and automated backups at a reasonable price point. Fly.io recently launched Postgres as a service; it's newer but more flexible for custom configurations. Heroku's offering is the most expensive but the most mature for large teams already committed to their ecosystem.

A common production pattern: connect via an encrypted tunnel to keep the database off the public internet. Heroku and Render do this automatically when you provision a database. Fly.io requires you to use private networks or Wireguard, which adds complexity but gives you more control.

Here's how we configure the database connection in config/database.yml to respect environment variables safely:

ruby
production:
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("DB_POOL", 5) %>
  timeout: 5000
  database: <%= ENV.fetch("DATABASE_NAME") %>
  username: <%= ENV.fetch("DATABASE_USER") %>
  password: <%= ENV.fetch("DATABASE_PASSWORD") %>
  host: <%= ENV.fetch("DATABASE_HOST") %>
  port: <%= ENV.fetch("DATABASE_PORT", 5432) %>
  ssl_mode: require

The ssl_mode: require line forces encryption on the wire. It's a small detail that every production Rails app needs. Never rely on the default, which allows unencrypted connections.

Connection pooling becomes critical when you scale horizontally. Heroku dynos share a database connection pool via PgBouncer, included by default. Render and Fly.io don't; if you run 10 application instances, each with a pool of 5, you've created 50 connections to Postgres. Most managed databases allow 500–1000. Scale to 100 instances and you'll exhaust the pool and start seeing connection refused errors.

The fix: configure PgBouncer on Render and Fly.io explicitly, or use your managed Postgres's built-in connection pooling (many support it now). Heroku handles this for you by default, which is one reason we still use them for teams that haven't invested in infrastructure knowledge.

Background workers as separate processes

Email, image resizing, and batch reports don't belong in the web request cycle. The Procfile pattern makes it trivial to run separate processes for background jobs. On Heroku, you add a dyno for worker; on Render and Fly.io, you add a new service.

We typically use Sidekiq for background jobs. Here's a minimal Sidekiq configuration in config/sidekiq.yml:

ruby
:verbose: false
:concurrency: 10
:timeout: 30
:max_retries: 5
:dead: true

production:
  :concurrency: 10
  :timeout: 30

The Procfile worker line then becomes:

ruby
worker: bundle exec sidekiq -e production -c 10 -r ./config/application.rb

On Heroku, you scale this by adding more worker dynos in the dashboard or CLI: heroku ps:scale worker=3. On Render, you edit the service definition to scale the worker count. On Fly.io, you declare multiple instances or add a separate machine group.

The benefit is clear: your web dynos stay responsive to HTTP requests; background jobs run in isolation. If a job crashes, it doesn't take down your web server. If you need to scale workers independently (e.g., during a batch email campaign), you adjust their count without touching your web tier.

One production reality: long-running jobs fail. Network interruptions, database deadlocks, and third-party API timeouts happen. Sidekiq retries failed jobs with exponential backoff by default. For critical jobs (payment processing, compliance audits), we explicitly configure retry strategies:

ruby
class PaymentWorker
  include Sidekiq::Worker
  sidekiq_options retry: 10, dead: true, lock: :until_executed

  def perform(order_id)
    order = Order.find(order_id)
    PaymentGateway.charge(order)
  rescue PaymentGateway::TransientError => e
    raise e  # Sidekiq will retry
  rescue PaymentGateway::PermanentError => e
    order.update(payment_failed: true)
    nil  # Don't retry permanent failures
  end
end

The lock: :until_executed option prevents duplicate job execution during retries, critical when idempotency isn't guaranteed by the API.

Scale dynos or machines

Vertical scaling (bigger machines) and horizontal scaling (more machines) are both available on all three platforms. The trade-offs are operational rather than technical.

Heroku dynos come in predefined sizes: Standard-1x ($50/month, 512 MB RAM), Standard-2x ($250/month, 1 GB RAM), and Performance ($500+). Render uses similar tiers. Fly.io gives you granular control: define CPU and RAM in your fly.toml, paying per unit per month.

Heroku's simplicity appeals to small teams. You scale by clicking a button or running heroku ps:resize. The platform handles zero-downtime deploys (rolling restarts of dynos). Render works similarly. Fly.io requires you to understand their machine types and resource allocation; it's more powerful but steeper to learn.

Most Rails apps scale horizontally (more smaller instances) rather than vertically. A single Standard-2x dyno running Puma with 4 workers handles ~50 requests/sec; two Standard-1x dynos do the same, often more reliably because instance failure doesn't take down the entire service. Load balancing is automatic on all three platforms.

Here's a typical Puma configuration for a single dyno:

ruby
# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count

workers ENV.fetch("WEB_CONCURRENCY") { 4 }

preload_app!

on_worker_boot do
  ActiveRecord::Base.establish_connection
end

On Heroku, set WEB_CONCURRENCY=4 via heroku config:set. The dyno reboots, and Puma now uses 4 processes. Monitor dyno metrics (memory, CPU) via the Heroku CLI or dashboard to know when to scale.

Autoscaling is available on all platforms now, though Heroku requires an add-on. We've found autoscaling risky for Rails apps: it responds slowly to traffic spikes. A sudden surge in traffic queues requests; autoscaling detects high load and adds dynos, but Puma has already started queuing. We prefer manual scaling based on predictable traffic patterns (scale up before peak hours, down after).

Common mistakes

  • Forgetting release migrations: Deploying code that depends on database schema before the migration runs causes immediate 500 errors. Always use the release process.
  • Database connection pool exhaustion: Scaling web instances without adjusting the pool or adding PgBouncer starves the database of connections. Monitor connection count as you scale.
  • Long-running requests on web dynos: A single slow endpoint ties up a Puma worker, blocking other requests. Move slowness to background jobs or optimize queries.
  • Stateful sessions without Redis: If you run multiple web dynos, in-memory session storage causes login issues when users hit different instances. Use Redis for sessions.
  • Skipping staging deploys: Push to a staging environment first on the same platform. A config difference between dev and prod is easier to catch there.
  • Ignoring log retention: Managed platforms log to disk; logs roll over quickly. Stream logs to a centralized service (Papertrail, DataDog) for production debugging.

Key takeaways

  • Push-to-deploy abstracts away infrastructure, but knowing the build process (buildpacks vs. Docker) helps debug deploy failures.
  • Managed Postgres is convenient, but connection pooling and encryption are non-negotiable for production Rails apps.
  • Background workers as separate processes let web and job tiers scale independently; Sidekiq with explicit retry logic handles real-world failure modes.
  • Horizontal scaling (more instances) is more reliable than vertical scaling for Rails; monitor metrics and scale before traffic spikes, not during.
  • Each platform (Heroku, Render, Fly.io) makes different trade-offs between ease of use and control. Choose based on team expertise and willingness to learn the abstraction.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles