Nginx reverse proxy and SSL — the complete guide
What a reverse proxy is (and why you want one)
A reverse proxy is a web server (like Nginx) that sits in front of your application and handles incoming HTTP/HTTPS traffic on its behalf. Instead of clients connecting directly to your Node.js/Python/PHP app, they connect to Nginx, and Nginx forwards requests to your app.
Common benefits:
- TLS/SSL termination: Nginx handles HTTPS certificates; your app can run on plain HTTP internally.
- One public entrypoint: Host multiple apps/sites on one server via virtual hosts.
- Performance: Nginx is very good at serving static files, caching, and handling slow clients.
- Security & control: Add rate limiting, security headers, access control, and request size limits at the edge.
- Zero-downtime deploys: Restart your app without dropping client connections as aggressively.
---
Installing Nginx and understanding server blocks
On Ubuntu/Debian:
```bash sudo apt update sudo apt install nginx ```
Nginx config is typically in:
- `/etc/nginx/nginx.conf` (main config)
- `/etc/nginx/sites-available/` and `/etc/nginx/sites-enabled/` (site configs)
1. Create a config in `sites-available` 2. Symlink it into `sites-enabled` 3. Test and reload Nginx
```bash sudo nginx -t sudo systemctl reload nginx ```
A server block is like a “virtual host”—it matches a domain/IP and defines how to handle requests.
---
Reverse proxy basics: `proxy_pass` and essential headers
At the core of reverse proxying is `proxy_pass`, which forwards requests to an upstream server (your app).
Example: proxy `https://example.com` to a Node.js app running locally on port 3000.
Create `/etc/nginx/sites-available/example.com`:
```nginx server { listen 80; server_name example.com www.example.com;
location / { proxy_pass http://127.0.0.1:3000;
# Preserve client and request information 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; } } ```
Enable it:
```bash sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ```
Why those headers matter
- `Host`: ensures your app sees the original domain (useful for routing, generating URLs).
- `X-Real-IP` and `X-Forwarded-For`: preserves the real client IP (otherwise your app sees `127.0.0.1`).
- `X-Forwarded-Proto`: tells your app whether the original connection was HTTP or HTTPS (important after you enable SSL).
- Express: `app.set('trust proxy', 1)`
- Django: `SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')` (used carefully)
- Flask (behind proxy): use `ProxyFix` middleware
WebSockets support (common for Node.js)
If your app uses WebSockets (Socket.IO, ws), add:
```nginx proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ```
---
Adding SSL/TLS with Certbot (Let’s Encrypt)
Let’s Encrypt provides free certificates. Certbot automates issuing and renewing them.
Install Certbot
On Ubuntu/Debian:
```bash sudo apt install certbot python3-certbot-nginx ```
Obtain and install a certificate
Make sure your DNS A/AAAA records point to this server, then run:
```bash sudo certbot --nginx -d example.com -d www.example.com ```
Certbot will:
- Validate domain ownership (usually via HTTP challenge)
- Modify your Nginx config to add `listen 443 ssl;`
- Install certificate paths
- Optionally configure HTTP → HTTPS redirect
Verify auto-renewal
Let’s Encrypt certs are short-lived; renewal is automatic via systemd timers/cron.
Test renewal:
```bash sudo certbot renew --dry-run ```
---
Enabling HTTP/2
HTTP/2 improves performance by multiplexing requests over a single connection (especially noticeable for sites with many assets).
In modern Nginx, enable it on your TLS listener:
```nginx server { listen 443 ssl http2; server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ... } ```
Certbot often adds this automatically; if not, you can add `http2` yourself and reload Nginx.
---
Recommended SSL settings (practical and safe defaults)
You can keep SSL config inside the server block or include a snippet. A simple modern baseline:
```nginx ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off;
Session settings (optional)
ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ```Nginx defaults are generally reasonable in recent versions, but explicitly setting TLS 1.2/1.3 helps avoid legacy protocols.
---
Security headers (and what they do)
Security headers reduce risk from common web attacks. Add them in your HTTPS server block. A practical set:
```nginx add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
HSTS: forces HTTPS for future visits (enable only after HTTPS works reliably)
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; ```About Content-Security-Policy (CSP)
CSP is powerful but can break sites if misconfigured. Start with a report-only policy or a minimal one you expand carefully:```nginx
Example only; adjust to your assets/CDNs
add_header Content-Security-Policy "default-src 'self'" always; ```Notes and common pitfalls
- Only enable HSTS after you’re sure you won’t serve HTTP again; otherwise you can lock yourself out.
- Use `always` so headers apply to error responses too.
Rate limiting (basic DDoS/abuse protection)
Nginx can limit request rates per IP. This helps with brute-force attempts and abusive clients.
Place these in the `http {}` block (usually in `/etc/nginx/nginx.conf`), or in a file included from it:
```nginx limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; ```
Then apply in a location:
```nginx location /api/ { limit_req zone=api_limit burst=20 nodelay; proxy_pass http://127.0.0.1:3000; } ```
Meaning:
- Allow ~10 requests/second per IP
- Allow short bursts of 20
- `nodelay` processes bursts immediately (without slowing), but still caps sustained rate
```nginx limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
server { # ... limit_conn conn_limit 20; } ```
---
Common reverse proxy configurations
Node.js (Express / Next.js / NestJS)
A strong baseline for Node apps on `127.0.0.1:3000`:
```nginx server { listen 80; server_name example.com www.example.com;
location /.well-known/acme-challenge/ { root /var/www/html; } location / { return 301 https://$host$request_uri; } }
server { listen 443 ssl http2; server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 20m;
location / { proxy_pass http://127.0.0.1:3000; 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;
# WebSockets support proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } ```
Practical tip: run Node behind a process manager like systemd or PM2 so it restarts on crash/reboot.
Python (Gunicorn + Flask/Django)
Most Python production setups run behind Gunicorn (HTTP) or uWSGI. Here’s Gunicorn on `127.0.0.1:8000`:
```nginx location / { proxy_pass http://127.0.0.1:8000; 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; } ```
Static files (recommended): let Nginx serve them directly:
```nginx location /static/ { alias /var/www/myapp/static/; expires 30d; add_header Cache-Control "public"; } ```
For Django, remember to configure `ALLOWED_HOSTS`, and if using proxy headers, do it intentionally and securely.
PHP (PHP-FPM) — not a reverse proxy, but common with Nginx
PHP is typically served via FastCGI, not `proxy_pass`. Example:
```nginx server { listen 443 ssl http2; server_name example.com; root /var/www/example.com/public; index index.php index.html;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.2-fpm.sock; }
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ { expires 30d; access_log off; } } ```
If you’re hosting both PHP and a Node API, Nginx can route by path:
- `/` → PHP site
- `/api/` → Node upstream via `proxy_pass`
Troubleshooting and validation
Test configuration
Always run:```bash sudo nginx -t sudo systemctl reload nginx ```
Confirm your site is using HTTPS and HTTP/2
Use curl:```bash curl -I https://example.com ```
To verify HTTP/2:
```bash curl -I --http2 https://example.com ```
Common issues
- 502 Bad Gateway: your upstream app is down, wrong port, or blocked by firewall/permissions.
- Infinite redirect loop: app doesn’t trust `X-Forwarded-Proto` and keeps redirecting to HTTPS (or back to HTTP). Ensure proxy headers are set and the app is configured to trust them.
- Large uploads failing (413): increase `client_max_body_size`.
- Wrong client IP in logs: ensure `X-Forwarded-For` is set; configure your app/framework to use it.
A practical “good default” checklist
- Nginx server block per domain (`server_name`)
- `proxy_pass` to your app on localhost/private network
- Set `Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`
- Certbot-managed TLS certs + renewal tested
- HTTP → HTTPS redirect
- `listen 443 ssl http2;`
- Basic security headers (plus HSTS once stable)
- Rate limit sensitive endpoints (`/login`, `/api`)
- Serve static files directly where possible