The first production Rails app I deployed without a platform-as-a-service safety net taught me quickly that “rails server” is not a production deployment strategy. Rails’ built-in server is fine for development, but for anything real, you need an actual application server — Puma is the standard choice these days — sitting behind a reverse proxy that handles TLS, static assets, and the messy realities of the internet. That’s exactly the stack I’ll walk through here: Nginx in front, Puma behind, serving a Rails application.
This combination has become something of a default in the Rails world, replacing older Passenger or Unicorn-based setups for a lot of teams, mostly because Puma’s threaded/multi-process model plays nicely with Rails’ modern concurrency support and it’s dead simple to configure.
Why This Architecture
Puma is a capable HTTP server on its own, but running it directly exposed to the internet isn’t a great idea for a few reasons: it’s not optimized for serving static files efficiently, it doesn’t handle TLS termination as gracefully as a dedicated web server, and it lacks the fine-grained request buffering, rate limiting, and caching capabilities Nginx offers. So the standard pattern is:
Client → Nginx (TLS termination, static files, reverse proxy) → Puma (application logic) → Rails app
Nginx handles everything up to the point where actual application logic is needed, then hands off to Puma over a Unix socket or TCP connection. This division of labor keeps each piece doing what it’s good at.
Requirements
- A Linux server (Ubuntu 22.04/24.04 or similar) with Ruby, Bundler, and your Rails application already set up
- Puma included in your Rails app’s Gemfile (it’s the default in Rails 5+)
- Nginx installed
- A non-root user for running the application (never run Puma as root)
- Optionally, a process manager like systemd to keep Puma running and restart it on failure
Verify Puma is in your Gemfile:
gem "puma", "~> 6.0"
Run bundle install if you just added it.
Step 1: Configure Puma
Create or edit config/puma.rb in your Rails application:
# 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 "unix:///home/deploy/myapp/shared/tmp/sockets/puma.sock"
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end
The key line here is bind "unix:///home/deploy/myapp/shared/tmp/sockets/puma.sock". I strongly prefer Unix sockets over TCP for the connection between Nginx and Puma when they’re on the same host — it’s slightly faster and avoids consuming a TCP port unnecessarily. Adjust the path to match your actual deployment directory structure. Make sure the socket directory exists:
mkdir -p /home/deploy/myapp/shared/tmp/sockets
mkdir -p /home/deploy/myapp/shared/tmp/pids
Step 2: Run Puma as a systemd Service
Rather than relying on rails server or a screen/tmux session (please don’t do this in production), set up a proper systemd unit so Puma starts on boot and restarts automatically on failure.
Create /etc/systemd/system/puma_myapp.service:
[Unit]
Description=Puma HTTP Server for myapp
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/myapp/current
Environment=RAILS_ENV=production
Environment=PATH=/home/deploy/.rbenv/shims:/home/deploy/.rbenv/bin:/usr/bin:/bin
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C config/puma.rb
Restart=always
RestartSec=3
StandardOutput=append:/home/deploy/myapp/shared/log/puma.stdout.log
StandardError=append:/home/deploy/myapp/shared/log/puma.stderr.log
[Install]
WantedBy=multi-user.target
Adjust WorkingDirectory, User, and the Ruby path (rbenv, rvm, or system Ruby, depending on your setup) to match your environment. Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now puma_myapp
sudo systemctl status puma_myapp
Step 3: Precompile Assets
Rails apps in production typically serve precompiled, fingerprinted assets rather than compiling on the fly:
RAILS_ENV=production bundle exec rails assets:precompile
This generates files under public/assets, which Nginx will serve directly, bypassing Puma and Rails entirely for these requests — a significant performance win.
Step 4: Write the Nginx Server Block
Here’s a complete, production-ready configuration:
upstream puma_myapp {
server unix:///home/deploy/myapp/shared/tmp/sockets/puma.sock fail_timeout=0;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /home/deploy/myapp/current/public;
client_max_body_size 20M;
keepalive_timeout 10;
location ~ ^/(assets|packs)/ {
expires 1y;
add_header Cache-Control "public, immutable";
gzip_static on;
access_log off;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
try_files $uri/index.html $uri @puma;
location @puma {
proxy_pass http://puma_myapp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_redirect off;
}
error_page 500 502 503 504 /500.html;
location = /500.html {
root /home/deploy/myapp/current/public;
}
}
A few important details:
root /home/deploy/myapp/current/public;— this points at Rails’ public directory, allowing Nginx to serve static files (assets, uploaded files if stored locally, error pages) without hitting Puma at all.try_files $uri/index.html $uri @puma;— this checks if a static file exists first (useful for cached full-page HTML if you’re using something like Rails’ page caching); if not, it falls through to the@pumanamed location.- The
assetsandpackslocation block sets long cache expiration since Rails fingerprints these filenames with a content hash — if the file changes, the filename changes too, so aggressive caching is safe. proxy_set_header X-Forwarded-Proto $scheme;— this is critical for Rails to correctly detect that the original request was HTTPS, even though the connection between Nginx and Puma is plain HTTP over the socket. Without this, you’ll get mixed content warnings or brokenurl_forhelpers generatinghttp://links on an HTTPS site.
Step 5: Configure Rails to Trust the Proxy
In config/environments/production.rb, make sure Rails knows it’s behind a reverse proxy:
config.force_ssl = true
config.assume_ssl = true
config.force_ssl = true ensures Rails redirects any stray HTTP traffic to HTTPS (as a backup to Nginx’s redirect) and sets secure cookie flags. In recent Rails versions, config.assume_ssl = true tells Rails to treat all requests as SSL, which pairs well with a TLS-terminating proxy in front.
Step 6: Test and Reload
sudo nginx -t
sudo systemctl reload nginx
Then hit your domain:
curl -I https://example.com
You should get a 200 OK (or an appropriate redirect if you’re not logged in, etc.) along with headers confirming Nginx is serving the response.
Zero-Downtime Restarts
One thing that trips people up is deploying new code without dropping active connections. Puma supports phased restarts via pumactl or by sending it a USR1 signal (with preload_app! and cluster mode configured), which restarts workers one at a time rather than all at once:
sudo systemctl kill -s USR1 puma_myapp
Alternatively, tools like Capistrano with the capistrano3-puma gem handle this automatically as part of the deploy process, which is what I’d recommend for anything beyond a solo hobby project.
Troubleshooting Common Issues
Problem: 502 Bad Gateway.
This means Nginx can’t reach Puma. Check:
sudo systemctl status puma_myapp
ls -la /home/deploy/myapp/shared/tmp/sockets/
Confirm the socket file exists and that its permissions allow the Nginx worker process (usually running as www-data or nginx) to connect to it. If the socket directory is inside the deploy user’s home directory, make sure the home directory itself has execute permission for others (chmod o+x /home/deploy), or Nginx won’t be able to traverse into it.
Problem: Static assets return 404 despite existing in public/assets.
Double-check the root directive points at the correct current symlink (common in Capistrano-style deployments) and that assets:precompile actually ran for the currently deployed release.
Problem: Mixed content warnings or infinite redirect loops on HTTPS.
This is almost always the X-Forwarded-Proto header being missing or Rails not being configured to trust it. Double-check both the Nginx proxy_set_header X-Forwarded-Proto $scheme; line and Rails’ assume_ssl/force_ssl settings.
Problem: Requests hang or time out under load.
Check your Puma worker/thread counts against your server’s CPU and memory. Too few workers means requests queue up; too many can exhaust memory, especially with a memory-hungry Rails app. Monitor with:
sudo systemctl status puma_myapp
htop
Problem: File uploads fail with a 413 error.
Increase client_max_body_size in the Nginx server block to accommodate your largest expected upload.
Security Considerations
- Never run Puma as root; use a dedicated deploy user with minimal privileges.
- Keep the Puma socket or port unreachable from outside the host — if using TCP instead of a Unix socket, bind Puma to
127.0.0.1only, never0.0.0.0. - Set
client_max_body_sizedeliberately rather than leaving it unbounded, to prevent abuse via oversized upload attempts. - Use
secureandhttponlycookie flags, which Rails sets automatically whenforce_sslis enabled. - Keep both Nginx and your Ruby/Rails/Puma versions patched — Rails security advisories come out periodically, and staying current matters.
- Consider adding rate limiting at the Nginx layer for login endpoints or other abuse-prone routes:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;location /login { limit_req zone=login burst=3; proxy_pass http://puma_myapp;}
Performance Tips
- Serve as much as possible directly from Nginx — static assets, uploaded files (if not using cloud storage), and error pages — to keep load off Puma and Rails entirely.
- Tune
WEB_CONCURRENCY(Puma worker count) and thread counts based on actual CPU core count and memory headroom; a common starting point is workers equal to CPU cores, with 5 threads per worker, then adjust based on observed behavior. - Enable
preload_app!in Puma’s config to reduce memory usage via copy-on-write when running in cluster mode with multiple workers. - Use
gzipor pre-compressed assets (gzip_static on;) for text-based static files to reduce bandwidth. - Consider a CDN in front of Nginx for asset delivery on higher-traffic sites, further reducing load on your origin server.
- Watch your database connection pool size relative to
threads * workersin Puma — a mismatch here is a very common source of “waiting for a connection” errors under load.
Real-World Use Cases
- Standard production Rails deployments on a single VPS or a small cluster of application servers behind a load balancer.
- Multi-app hosting where several Rails apps run on the same server, each with its own Puma instance and socket, all fronted by a single Nginx installation using different
server_nameblocks. - Blue-green deployments where Nginx’s upstream configuration switches between two Puma socket paths during a release, enabling near-zero-downtime deploys without relying solely on Puma’s phased restart.
- API-only Rails backends serving a separate frontend (React, Vue, etc.), where Nginx also handles CORS headers and API-specific rate limiting.
Best Practices Summary
- Use Unix sockets between Nginx and Puma when they’re co-located for a small performance and security benefit.
- Manage Puma with systemd (or an equivalent process supervisor) rather than running it manually.
- Let Nginx handle all static asset serving and TLS termination.
- Always pass and trust
X-Forwarded-Protocorrectly to avoid HTTPS detection issues in Rails. - Set sensible timeouts and body size limits matched to your application’s actual needs.
- Tune Puma’s worker/thread configuration based on real server resources, not defaults or guesses.
- Automate deploys and restarts to avoid downtime and human error creeping into the process.
Handling WebSockets (ActionCable)
If your Rails app uses ActionCable for WebSocket connections, you need a bit of extra configuration since standard HTTP proxying doesn’t handle the upgrade handshake automatically:
location /cable {
proxy_pass http://puma_myapp;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
The Upgrade and Connection headers here are what tell both Nginx and Puma to treat this as a persistent WebSocket connection rather than a normal HTTP request-response cycle. The extended proxy_read_timeout matters because WebSocket connections are meant to stay open for a long time, and the default 60-second timeout would kill idle connections prematurely.
Log Management
Rails, Puma, and Nginx all generate their own logs, and in a production setup, I like to keep them clearly separated and appropriately rotated:
access_log /var/log/nginx/myapp_access.log;
error_log /var/log/nginx/myapp_error.log warn;
Puma’s logs, as configured in the systemd unit earlier, go to puma.stdout.log and puma.stderr.log. Set up logrotate for all of these to avoid filling up disk space over time:
/home/deploy/myapp/shared/log/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
The copytruncate option matters here specifically for Puma’s logs since it avoids needing to signal Puma to reopen file handles after rotation, which can otherwise be a source of subtle bugs if not handled carefully.
Frequently Asked Questions
Should I use TCP or Unix sockets between Nginx and Puma?
Unix sockets when both are on the same host — slightly faster, and they don’t tie up a TCP port. Use TCP (bind tcp://127.0.0.1:3000) only when Nginx and Puma run on different hosts, in which case bind to a private network interface, never a public one.
Do I need Passenger or Unicorn instead of Puma?
Not for a typical modern Rails app. Puma has become the de facto default since Rails 5 and handles both threaded and multi-process concurrency well. Passenger and Unicorn are still viable, particularly Passenger for teams wanting deeper Nginx integration (it has its own Nginx module), but Puma plus a standard reverse proxy setup is simpler to reason about and is what a new Rails project generates by default.
How many Puma workers and threads should I actually use?
There’s no universal number — it depends on your CPU core count, available memory, and whether your workload is more CPU-bound or I/O-bound (waiting on database/API calls). A common starting point is workers roughly equal to CPU core count, 5 threads per worker, then adjust based on observed memory usage and response times under real load. Watch for memory pressure specifically, since each worker is a full copy of your Rails app’s memory footprint (mitigated somewhat by preload_app! and copy-on-write).
What if I’m deploying with Docker instead of directly on a VM?
The same Nginx configuration principles apply, but you’d typically run Nginx and Puma in separate containers, communicating over a Docker network via TCP rather than a Unix socket (since sharing a socket file across containers requires a shared volume, which is possible but less common than just using the container network). Adjust proxy_pass to reference the Puma container’s service name if using Docker Compose or a similar orchestration tool.
My Rails app works locally but returns 500 errors only in production behind Nginx — what should I check first?
Check RAILS_ENV=production Rails logs first (not Nginx’s error log) for the actual application-level exception, since a 500 from Puma/Rails will show up there with a full backtrace. Common culprits specific to the reverse-proxy setup include missing SECRET_KEY_BASE, asset precompilation not having run, or database credentials differing between environments.
Wrapping Up
Nginx and Puma is a proven, well-documented combination for running Rails in production, and once it’s set up correctly, it’s genuinely low-maintenance. The details that matter most are the ones that are easy to overlook: getting the socket permissions right, correctly forwarding the protocol header so Rails knows it’s behind TLS, and tuning Puma’s concurrency settings to match your actual server resources rather than copying defaults from a tutorial. Get those right, and this stack will serve you reliably for a long time.