How to Set Up Nginx for a Ruby Application

How to Set Up Nginx for a Ruby Application

If you’ve built a Ruby application — whether it’s a Rails app, a Sinatra microservice, or something running on Rack — you already know that Ruby’s built-in app servers were never designed to face the public internet directly. That’s where Nginx comes in. I’ve deployed dozens of Ruby apps over the years, and in almost every single case, the production stack looks the same: Nginx sitting in front, quietly handling the things it’s good at, while an application server like Puma handles the Ruby code behind it.

In this guide, I’m going to walk you through exactly how to set this up from scratch, on a fresh Ubuntu/Debian-based server, using Puma as the application server (it’s the default for modern Rails apps and plays nicely with threads and processes). I’ll also point out where things differ if you’re using Unicorn or Passenger instead.

Why Put Nginx in Front of a Ruby App Server?

Puma, Unicorn, and similar servers are perfectly capable of accepting HTTP connections. So why bother with Nginx at all?

A few reasons, based on real production pain I’ve run into:

  • Static file serving. Puma is not efficient at serving CSS, JS, images, or fonts. Nginx is built for exactly this and will outperform any Ruby process by an order of magnitude.
  • Slow client protection. A malicious or just slow client can hold open a connection to your Ruby workers, tying up threads that should be handling real requests. Nginx buffers requests and responses, insulating your app from this.
  • SSL termination. Handling TLS in Nginx is simpler and more battle-tested than trying to configure it inside a Ruby process.
  • Load balancing. If you’re running multiple Puma workers or multiple app servers, Nginx can distribute traffic across them.
  • Zero-downtime deploys. Nginx can hold requests briefly while you restart your app server, so users never see a dropped connection.

Requirements

Before starting, make sure you have:

  • A server running Ubuntu 22.04 or 24.04 (the steps are nearly identical on Debian)
  • Root or sudo access
  • A Ruby application that’s ready to run (I’ll assume a Rails app for the examples, but Sinatra/Rack apps work the same way)
  • Ruby installed (via rbenv, rvm, or asdf — I prefer rbenv for production servers)
  • Bundler and your app’s gems installed (bundle install)
  • A domain name pointed at your server (optional but recommended for the SSL section)

I’ll assume your app lives at /var/www/myapp and runs as a system user called deploy.

Step 1: Install Nginx

sudo apt update
sudo apt install nginx -y

Confirm it’s running:

sudo systemctl status nginx

You should see active (running). If you visit your server’s IP in a browser now, you’ll see the default Nginx welcome page — that confirms the web server itself is working before we touch any Ruby-specific configuration.

Also open port 80 (and 443 once you add SSL) in your firewall:

sudo ufw allow 'Nginx Full'

Step 2: Install Ruby and Puma

If Ruby isn’t already installed, here’s the rbenv route I usually take:

sudo apt install -y git curl libssl-dev libreadline-dev zlib1g-dev autoconf bison build-essential libyaml-dev libreadline-dev libncurses5-dev libffi-dev libgdbm-dev
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer | bash

Add rbenv to your shell profile, then install a Ruby version:

rbenv install 3.3.0
rbenv global 3.3.0
gem install bundler

Inside your application directory, make sure Puma is in your Gemfile:

gem "puma", "~> 6.0"

Then run:

cd /var/www/myapp
bundle install

Step 3: Configure Puma

Create (or edit) config/puma.rb in your Rails app:

# config/puma.rb
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 }

preload_app!

plugin :tmp_restart

# Bind to a Unix socket instead of TCP - this is faster and more secure
# for communication between Nginx and Puma on the same machine.
bind "unix:///var/www/myapp/shared/sockets/puma.sock"

Using a Unix socket instead of a TCP port (like 127.0.0.1:3000) is a small but meaningful performance win, and it also means Puma isn’t listening on any network-accessible port at all — only Nginx (and root) can talk to it.

Create the socket directory:

mkdir -p /var/www/myapp/shared/sockets
mkdir -p /var/www/myapp/tmp/pids

Step 4: Run Puma as a systemd Service

You don’t want to start Puma by hand every time the server reboots. Create a systemd unit:

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

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp
Environment=RAILS_ENV=production
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable puma_myapp
sudo systemctl start puma_myapp
sudo systemctl status puma_myapp

If this fails, check journalctl -u puma_myapp -n 50 — most issues at this stage are missing gems, wrong Ruby paths, or database connection errors.

Step 5: Configure Nginx as a Reverse Proxy

Now the part you came here for. Create a new server block:

sudo nano /etc/nginx/sites-available/myapp

Here’s a complete, production-ready configuration:

upstream puma_myapp {
    server unix:///var/www/myapp/shared/sockets/puma.sock fail_timeout=0;
}

server {
    listen 80;
    server_name example.com www.example.com;

    root /var/www/myapp/public;

    access_log /var/log/nginx/myapp_access.log;
    error_log /var/log/nginx/myapp_error.log;

    client_max_body_size 20M;
    keepalive_timeout 10;

    # Serve static assets directly - never hit Puma for these
    location ~ ^/(assets|packs)/ {
        gzip_static on;
        expires max;
        add_header Cache-Control public;
        add_header ETag "";
    }

    try_files $uri/index.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 $http_host;
        proxy_redirect off;
        proxy_pass http://puma_myapp;
    }

    error_page 500 502 503 504 /500.html;
    location = /500.html {
        root /var/www/myapp/public;
    }
}

A few things worth explaining here since they trip people up:

  • upstream defines the pool Nginx proxies to. Even with a single Puma socket, wrapping it in an upstream block makes it trivial to add more workers or servers later.
  • try_files $uri/index.html $uri @puma checks if a static file exists before falling through to Puma. This lets Rails’ precompiled assets and any static HTML pages get served without touching Ruby at all.
  • X-Forwarded-For and X-Forwarded-Proto headers matter a lot — without them, your Rails app won’t know the real client IP or whether the original request was HTTPS, which breaks things like request.ssl? and IP-based rate limiting.

Enable the site and remove the default:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

nginx -t tests your configuration syntax before you reload — always run this first. A syntax error in a reload can take your whole site down.

Step 6: Add HTTPS with Let’s Encrypt

There’s really no excuse for running a production app over plain HTTP in 2026. Certbot makes this nearly painless:

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

Certbot will automatically edit your Nginx config to add the listen 443 ssl block, install the certificate, and set up a redirect from HTTP to HTTPS. Test the renewal process:

sudo certbot renew --dry-run

Testing Your Setup

With everything running, hit your domain:

curl -I https://example.com

You should see a 200 OK (or a redirect if you’re not logged in, depending on your app). Check that static assets load quickly and that a dynamic page — something that actually touches your database — also returns correctly.

I also like to specifically test the socket connection is alive:

sudo -u deploy curl --unix-socket /var/www/myapp/shared/sockets/puma.sock http://localhost/

If that returns your app’s HTML, Puma itself is healthy and the problem, if any, is on the Nginx side.

Troubleshooting Common Issues

502 Bad Gateway — This almost always means Nginx can’t reach Puma. Check:

  • Is Puma actually running? systemctl status puma_myapp
  • Does the socket file exist and have the right permissions? ls -la /var/www/myapp/shared/sockets/
  • Does the deploy user (running Puma) match the user Nginx expects to read the socket from? Nginx typically runs as www-data, and it needs read/write access to the socket.

A quick fix for permission mismatches is adding www-data to the deploy group, or setting explicit socket permissions in puma.rb:

bind "unix:///var/www/myapp/shared/sockets/puma.sock"

Puma sets socket permissions to 0666 by default, which usually avoids this, but it’s worth checking with ls -la.

413 Request Entity Too Large — Increase client_max_body_size in the server block, especially if your app accepts file uploads.

Static assets 404ing — Double check RAILS_ENV=production and that you ran bundle exec rails assets:precompile — Rails won’t compile assets on the fly in production mode by default.

Slow first request after deploy — This is usually Puma’s preload_app! combined with a cold Rails boot. It’s expected; subsequent requests will be fast.

Security Considerations

A few things I always double-check on Ruby deployments:

  • Never expose Puma directly to the internet. Bind it to a Unix socket or 127.0.0.1 only — never 0.0.0.0 in production.
  • Hide the Nginx version. Add server_tokens off; in your http block in /etc/nginx/nginx.conf so error pages and headers don’t leak your exact Nginx version to attackers.
  • Set security headers. Add these to your server block:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  • Rate limit login and API endpoints to prevent brute-force attacks:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /users/sign_in {
    limit_req zone=login burst=5 nodelay;
    proxy_pass http://puma_myapp;
}
  • Keep secrets out of version control. Use Rails credentials or environment variables loaded via systemd’s EnvironmentFile= directive rather than hardcoding anything in puma.rb.

Performance Tips

  • Tune worker and thread counts to your CPU. A common starting point is workers = number_of_cores, threads = 5. Watch memory usage — each Puma worker is a full copy of your Rails app in memory (mitigated somewhat by preload_app! and copy-on-write).
  • Enable gzip compression in Nginx for text-based responses:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1024;
  • Cache aggressively for static assets. Rails fingerprints compiled assets (e.g., application-a1b2c3.css), so it’s safe to set expires max; since a new deploy changes the filename.
  • Use HTTP/2 by adding http2 to your listen directive once SSL is configured — it multiplexes requests over a single connection and noticeably speeds up asset-heavy pages.
  • Consider a CDN in front of Nginx for globally distributed traffic; it takes load off your origin server entirely for static content.

Real-World Use Cases

I’ve used this exact pattern for:

  • A Rails e-commerce platform handling flash-sale traffic spikes, where Nginx’s static asset serving and rate limiting kept Puma workers free for checkout logic.
  • A Sinatra-based internal API where Nginx handled TLS termination and IP allowlisting before requests ever reached the lightweight Ruby app.
  • A multi-tenant SaaS app where Nginx routed subdomains (tenant1.example.com, tenant2.example.com) to the same Puma cluster, with the tenant resolved inside Rails based on the Host header.

Best Practices Recap

  • Always bind your Ruby app server to a Unix socket, not a public TCP port.
  • Let Nginx serve static files directly — never route them through Puma.
  • Use systemd to manage your app server so it survives reboots and crashes.
  • Test configuration changes with nginx -t before reloading.
  • Terminate SSL at Nginx and force HTTPS everywhere.
  • Set sane timeouts and body size limits to protect your app from abuse.
  • Monitor both Nginx and Puma logs — problems often show symptoms in one and root causes in the other.

Once this is wired up, deploys become routine: update your code, run migrations, restart the puma_myapp service, and Nginx keeps serving traffic the whole time with barely a blip. It’s a boring, reliable setup — and boring is exactly what you want in production.

Total
1
Shares

Leave a Reply

Previous Post
How to Use Nginx as a WebSocket Load Balancer

How to Use Nginx as a WebSocket Load Balancer

Next Post
How to Set Up Nginx for a Python Application

How to Set Up Nginx for a Python Application

Related Posts