How to Set Up Nginx for a Ruby Application

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:

Requirements

Before starting, make sure you have:

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:

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:

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:

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;
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;
}

Performance Tips

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;

Real-World Use Cases

I’ve used this exact pattern for:

Best Practices Recap

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.

Exit mobile version