How to Set Up Nginx with Ruby on Rails

How to Set Up Nginx with Ruby on Rails

I’ve deployed Rails apps a handful of different ways over the years — Passenger, Unicorn, and these days almost exclusively Puma, which has been Rails’ default application server since Rails 5. If you’re setting up Nginx with a Rails app today and you’re not sure which app server to use, use Puma — it’s what rails new sets up for you out of the box, and it plays nicely with Nginx as a reverse proxy. This guide walks through that exact combination.

Why Nginx and Puma Together?

Like most Ruby web frameworks, Rails speaks Rack, not raw HTTP. Puma is a Rack-compatible application server that can handle HTTP directly, but in production I still put Nginx in front of it for the same reasons I’d put Nginx in front of any application server:

  • TLS termination in one consistent place
  • Serving static assets (compiled CSS/JS from the Rails asset pipeline, or Webpacker/jsbundling output) without going through Ruby at all
  • Buffering slow clients so they don’t tie up Puma’s limited thread/worker pool
  • A clean point to add rate limiting, security headers, and caching rules
Client → Nginx (80/443) → Puma (via Unix socket) → Rails app

Requirements

  • A Linux server with Ruby, Bundler, and your Rails app’s dependencies installed
  • Your Rails app deployed to the server (I’ll use /var/www/myrailsapp)
  • Nginx installed
  • Puma configured (it ships with Rails by default, listed in your Gemfile)
  • Assets precompiled: RAILS_ENV=production bin/rails assets:precompile

Confirm Puma can start and serve your app before touching Nginx:

cd /var/www/myrailsapp
RAILS_ENV=production bundle exec puma -C config/puma.rb

Step 1: Configure Puma

Open config/puma.rb and make sure it’s set up to bind to a Unix socket, which is what I recommend for local communication with Nginx:

max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"

port ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "production" }

pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }

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

bind "unix:///var/www/myrailsapp/tmp/sockets/puma.sock"

preload_app!

plugin :tmp_restart

workers enables Puma’s cluster mode, which forks multiple OS processes to take advantage of multiple CPU cores — important since MRI Ruby has a Global Interpreter Lock that limits true parallelism within a single process.

Step 2: Run Puma as a Systemd Service

sudo nano /etc/systemd/system/myrailsapp.service
[Unit]
Description=Puma HTTP Server for myrailsapp
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myrailsapp
Environment=RAILS_ENV=production
Environment=PATH=/var/www/myrailsapp/vendor/bundle/ruby/3.2.0/bin:/usr/bin
ExecStart=/usr/local/bin/bundle exec puma -C config/puma.rb
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start myrailsapp
sudo systemctl enable myrailsapp
sudo systemctl status myrailsapp

Step 3: Create the Nginx Server Block

sudo nano /etc/nginx/sites-available/myrailsapp
upstream puma_myrailsapp {
    server unix:///var/www/myrailsapp/tmp/sockets/puma.sock fail_timeout=0;
}

server {
    listen 80;
    server_name myrailsapp.example.com;
    root /var/www/myrailsapp/public;

    access_log /var/log/nginx/myrailsapp.access.log;
    error_log /var/log/nginx/myrailsapp.error.log;

    location ^~ /assets/ {
        gzip_static on;
        expires max;
        add_header Cache-Control public;
    }

    try_files $uri/index.html $uri.html $uri @puma;

    location @puma {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $host;
        proxy_redirect off;
        proxy_pass http://puma_myrailsapp;
    }

    client_max_body_size 20M;
    keepalive_timeout 10;
}

A few notes on why this config looks slightly different from a typical proxy setup:

  • location ^~ /assets/ — Rails’ asset pipeline precompiles CSS/JS into public/assets/ with content-hashed filenames, so I tell Nginx to serve those directly with long-lived cache headers, since a hashed filename only ever refers to one immutable version of the file.
  • try_files $uri/index.html $uri.html $uri @puma; — this checks for a static file first (useful if you’re using page caching) before falling back to Puma via the named location.
  • root /var/www/myrailsapp/public; — same reasoning as other frameworks: public/ is the safe, publicly servable directory, distinct from the rest of the Rails app.

Step 4: Enable the Site

sudo ln -s /etc/nginx/sites-available/myrailsapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Adding HTTPS

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d myrailsapp.example.com

After enabling HTTPS, make sure config/environments/production.rb has:

config.force_ssl = true

This tells Rails to redirect any HTTP request to HTTPS and to mark cookies as secure — but only enable this after you’ve confirmed HTTPS actually works, or you can lock yourself out with a redirect loop if Nginx and Rails disagree about the protocol.

Testing Your Setup

  1. sudo nginx -t for config syntax
  2. sudo systemctl status myrailsapp to confirm Puma is running
  3. curl -I http://myrailsapp.example.com
  4. Check the Rails log (log/production.log) alongside Nginx’s error log while testing a few pages
  5. Confirm assets load with a hashed filename and a Cache-Control: public header in the response
  6. If using Action Cable (Rails’ WebSocket framework), test that connections upgrade properly

Adding Action Cable Support

If your Rails app uses Action Cable, add a dedicated location block with WebSocket upgrade headers:

location /cable {
    proxy_pass http://puma_myrailsapp;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

Troubleshooting Common Issues

502 Bad Gateway — Puma isn’t running, or the socket path in the Nginx config doesn’t match config/puma.rb. Check sudo systemctl status myrailsapp and confirm the socket file exists at the expected path.

Assets 404ing in production — You likely forgot to run RAILS_ENV=production bin/rails assets:precompile after your last deploy, or public/assets wasn’t included in your deployment.

“We’re sorry, but something went wrong” generic error page — This is Rails’ production error page. Check log/production.log for the actual exception; Rails intentionally hides details from users in production.

CSRF or session issues after adding HTTPS — Usually caused by config.force_ssl = true being set before Nginx is actually correctly forwarding X-Forwarded-Proto. Double check that header is present.

Slow response times under load — Check WEB_CONCURRENCY (Puma worker count) and RAILS_MAX_THREADS; both need to be tuned to your server’s CPU/RAM, and I always benchmark with a tool like wrk rather than guessing.

Security Considerations

  • Set config.force_ssl = true once HTTPS is confirmed working
  • Keep config/master.key (or RAILS_MASTER_KEY) out of source control and off the web root
  • Restrict Puma’s socket permissions so only Nginx’s user can access it
  • Run bundle audit periodically to catch vulnerable gem versions
  • Add security headers via secure_headers gem or directly in Nginx
  • Never disable CSRF protection globally just to make an API endpoint “work” — scope exceptions narrowly

Performance Tips

  • Let Nginx serve /assets/ directly with long-cache headers — never route these through Puma
  • Tune Puma workers and threads to match your server’s CPU and expected concurrency, then load test to confirm
  • Use preload_app! in puma.rb to reduce memory usage across forked workers via copy-on-write
  • Enable gzip in Nginx, or serve pre-gzipped assets with gzip_static on; as shown above
  • Use a CDN in front of Nginx for asset delivery if you have significant geographic traffic spread
  • Cache database queries with Rails’ built-in fragment/low-level caching backed by Redis or Memcached for read-heavy pages

Real-World Use Case

I ran an internal Rails admin dashboard behind this exact stack — Puma in cluster mode with two workers and five threads each, Nginx serving precompiled assets directly, and Action Cable powering a live activity feed. The WebSocket location block was the piece that took the longest to get right; once the Upgrade/Connection headers were in place on the /cable path specifically (not just the main app path), real-time updates started working reliably.

Best Practices Recap

  • Use Puma in cluster mode, bound to a Unix socket
  • Manage Puma with systemd for reliability
  • Let Nginx serve public/assets/ directly with aggressive caching
  • Enable force_ssl only after confirming HTTPS and forwarded headers work
  • Add a dedicated location block for Action Cable if you use it
  • Precompile assets on every deploy, before restarting Puma

Rails and Nginx is a genuinely mature, well-trodden combination — most of what trips people up is either the asset pipeline (forgetting to precompile) or the socket path mismatch between Puma and Nginx. Get those two things right and the rest of the stack tends to just work.

Total
1
Shares

Leave a Reply

Previous Post
How to Configure Nginx for Django

How to Configure Nginx for Django

Next Post
How to Set Up Nginx for a Node.js Application

How to Set Up Nginx for a Node.js Application

Related Posts