Nginx — Reverse Proxy, Static Files and SSL Termination

Nginx sits in front of everything — it receives all incoming HTTP/HTTPS traffic and routes it appropriately: static files (React build) are served directly from disk, /api/* requests are proxied to the FastAPI container, and /ws/* WebSocket requests are proxied with the HTTP upgrade headers that WebSockets require. Nginx also handles gzip compression, security headers, SSL termination, and the SPA routing requirement (serve index.html for any non-API path).

Nginx Configuration

# nginx/nginx.conf
server {
    listen 80;
    server_name blog.example.com;

    # Redirect HTTP to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name blog.example.com;

    # SSL certificates (managed by Certbot)
    ssl_certificate     /etc/letsencrypt/live/blog.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    # ── Security headers ───────────────────────────────────────────────────────
    add_header X-Frame-Options          "DENY"              always;
    add_header X-Content-Type-Options   "nosniff"           always;
    add_header Referrer-Policy          "strict-origin"     always;
    add_header Permissions-Policy       "geolocation=()"    always;

    # ── Gzip compression ───────────────────────────────────────────────────────
    gzip on;
    gzip_types text/plain text/css application/json
               application/javascript text/javascript image/svg+xml;
    gzip_min_length 1024;

    # ── FastAPI API proxy ──────────────────────────────────────────────────────
    location /api/ {
        proxy_pass         http://backend:8000;
        proxy_http_version 1.1;
        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_read_timeout 60s;
    }

    # ── WebSocket proxy (requires Upgrade headers) ────────────────────────────
    location /ws/ {
        proxy_pass         http://backend:8000;
        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;   # 1 hour — keep WS connections alive
    }

    # ── Uploaded files ────────────────────────────────────────────────────────
    location /uploads/ {
        proxy_pass http://backend:8000;
    }

    # ── React SPA — serve index.html for all non-API routes ──────────────────
    location / {
        root  /usr/share/nginx/html;
        index index.html;

        # Cache static assets (hashed filenames from Vite)
        location ~* \.(js|css|png|jpg|webp|svg|ico|woff2)$ {
            expires     1y;
            add_header  Cache-Control "public, immutable";
        }

        # SPA fallback: serve index.html for all paths
        try_files $uri $uri/ /index.html;
    }
}
Note: The try_files $uri $uri/ /index.html directive is what makes React Router’s client-side routing work in production. When a user directly visits blog.example.com/posts/42, Nginx looks for a file at /usr/share/nginx/html/posts/42 (does not exist), then tries /posts/42/ (does not exist), and finally falls back to /index.html. React loads, reads the URL, and renders the PostDetailPage. Without this fallback, direct URL access returns a 404 from Nginx.
Tip: Vite generates hashed filenames for JavaScript and CSS bundles (e.g., index-a3f2bc1d.js). The hash changes every time the content changes. This means you can safely cache these files forever (Cache-Control: public, immutable, max-age=31536000) — they will never be stale because a new deployment generates new filenames. Only index.html should have a short or no cache, as it is the entry point that references the latest hashed bundles.
Warning: WebSocket proxying requires the special Upgrade and Connection: "upgrade" headers. Without them, Nginx closes WebSocket connections immediately with a 400 Bad Request or silently drops the upgrade, causing all WebSocket connections to fail in production even though they work in development (where Vite’s proxy handles the upgrade correctly). Always test WebSocket functionality after deploying behind Nginx.

Let’s Encrypt SSL with Certbot

# On the server — obtain initial certificate
docker run --rm \
  -v ./certbot/conf:/etc/letsencrypt \
  -v ./certbot/www:/var/www/certbot \
  certbot/certbot certonly \
  --webroot \
  --webroot-path=/var/www/certbot \
  --email admin@blog.example.com \
  --agree-tos \
  --no-eff-email \
  -d blog.example.com

# Renew certificates (run via cron every 12 hours)
# 0 0,12 * * * docker run --rm -v ./certbot/conf:/etc/letsencrypt certbot/certbot renew --quiet

Common Mistakes

Mistake 1 — Missing WebSocket Upgrade headers (WS fails in production)

❌ Wrong — no Upgrade headers in the /ws/ location block.

✅ Correct — always include Upgrade $http_upgrade and Connection "upgrade" for WebSocket proxy locations.

Mistake 2 — Caching index.html (users see old app after deploy)

❌ Wrong — index.html cached for 1 year:

expires 1y;   # for ALL files including index.html!

✅ Correct — exclude index.html from long-term caching; only cache hashed assets.

🧠 Test Yourself

A user visits blog.example.com/posts/42 directly. Nginx has no file at that path. What does try_files $uri $uri/ /index.html return and why does it work?