Skip to main content
โšก Calmops

Nginx as a Reverse Proxy for Rails: Local Dev and Production Config

Introduction

Running Nginx in front of a Rails application is standard practice in production โ€” Nginx handles static files, SSL termination, and load balancing, while Rails handles dynamic requests. This guide covers the essential Nginx configuration for Rails, including a common Turbolinks bug caused by missing proxy headers.

Basic Nginx Reverse Proxy for Rails

Start Rails on a specific port:

bundle exec rails s -p 3011 -b 0.0.0.0

Nginx configuration to proxy requests to Rails:

server {
    listen 80;
    server_name example.com;

    root /data/myapp/public;

    # Serve static assets directly (no Rails involvement)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Serve Rails public directory
    location /assets {
        root /data/myapp/public;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Proxy everything else to Rails
    location / {
        proxy_pass http://localhost:3011;
        proxy_set_header Host $http_host;           # critical โ€” see below
        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_redirect off;
    }
}

The Critical Header: proxy_set_header Host

The most important line in the proxy configuration is:

proxy_set_header Host $http_host;

Without this, Nginx sends the backend’s address (localhost:3011) as the Host header instead of the actual domain. This causes several problems:

When Rails redirects (e.g., after form submission), it sets a Turbolinks-Location response header to tell Turbolinks what URL to show in the address bar. If the Host header is wrong, Rails generates an incorrect Turbolinks-Location:

# Without proxy_set_header Host:
Turbolinks-Location: http://localhost:3011/addresses/new  โ† wrong!

# With proxy_set_header Host $http_host:
Turbolinks-Location: http://localhost:8080/addresses/new  โ† correct

This causes a browser error:

Uncaught DOMException: Failed to execute 'replaceState' on 'History':
A history state object with URL 'http://localhost:3011/addresses/new'
cannot be created in a document with origin 'http://localhost:8080'

The browser refuses to update the URL because the redirect target has a different origin than the current page.

Why This Happens

Turbolinks makes requests via XMLHttpRequest, which follows redirects transparently. Turbolinks can’t detect a redirect happened without the Turbolinks-Location header. Rails sets this header automatically โ€” but uses the Host header to determine the correct URL. If Host is wrong, the generated URL is wrong.

From the Turbolinks documentation:

When you visit location /one and the server redirects you to location /two, you expect the browser’s address bar to display the redirected URL. Turbolinks makes requests using XMLHttpRequest, which transparently follows redirects. Send the Turbolinks-Location header in response to a visit that was redirected, and Turbolinks will replace the browser’s topmost history entry with the value you provide.

Local Development Configuration

For local development with Nginx proxying to Rails:

# /etc/nginx/sites-available/myapp-dev
server {
    listen 8080;
    server_name localhost;

    root /home/user/myapp/public;

    # Static files
    location /assets {
        root /home/user/myapp/public;
        try_files $uri =404;
    }

    # Uploaded files
    location /uploads {
        root /home/user/myapp/public;
    }

    # Proxy to Rails dev server
    location / {
        proxy_pass http://localhost:3011;
        proxy_set_header Host $http_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_read_timeout 300;
        proxy_connect_timeout 300;
    }
}

Enable and reload:

sudo ln -s /etc/nginx/sites-available/myapp-dev /etc/nginx/sites-enabled/
sudo nginx -t  # test config
sudo nginx -s reload

Production Configuration

A production-ready Nginx config for Rails with SSL:

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

# Main HTTPS server
server {
    listen 443 ssl http2;
    server_name example.com;

    # SSL certificates (Let's Encrypt)
    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;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    root /var/www/myapp/current/public;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;

    # Static assets with long cache
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Puma socket (Unix socket is faster than TCP)
    upstream rails_app {
        server unix:///var/www/myapp/shared/tmp/sockets/puma.sock fail_timeout=0;
    }

    location / {
        try_files $uri/index.html $uri @rails;
    }

    location @rails {
        proxy_pass http://rails_app;
        proxy_set_header Host $http_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 https;
        proxy_redirect off;
        proxy_read_timeout 300;
    }

    # Error pages
    error_page 500 502 503 504 /500.html;
    error_page 404 /404.html;

    # Logs
    access_log /var/log/nginx/myapp.access.log;
    error_log  /var/log/nginx/myapp.error.log;
}

Rails Configuration for Proxy

Tell Rails it’s behind a proxy:

# config/environments/production.rb
config.force_ssl = true

# Trust the proxy's X-Forwarded-For header
config.action_dispatch.trusted_proxies = [
  "127.0.0.1",
  "::1",
  # Add your load balancer IPs here
]
# config/application.rb
# If behind a load balancer that sets X-Forwarded-Proto
config.middleware.insert_before ActionDispatch::SSL, Rack::Sendfile

Debugging Proxy Issues

# Test Nginx config syntax
sudo nginx -t

# Reload without downtime
sudo nginx -s reload

# Check what headers Rails receives
# Add to ApplicationController temporarily:
def debug_headers
  render plain: request.headers.env.select { |k, _| k.start_with?('HTTP_') }.inspect
end

# Check Nginx error log
sudo tail -f /var/log/nginx/error.log

# Check Rails log for redirect issues
tail -f log/development.log | grep -E "Redirect|Location|Turbolinks"

Common Issues

Problem Cause Fix
Turbolinks redirect error Missing proxy_set_header Host Add proxy_set_header Host $http_host
502 Bad Gateway Rails not running or wrong port Check Rails is running, verify port
Static files not served Wrong root path Check root directive points to public/
SSL redirect loop X-Forwarded-Proto not set Add proxy_set_header X-Forwarded-Proto $scheme
Slow uploads No client_max_body_size Add client_max_body_size 50m

Resources

Comments