Nginx reverse proxy with SSL: Step-by-step setup for proxy_pass configuration, Let's Encrypt certificates, HTTPS redirects, security headers, WebSocket proxying, and Cloudflare integration.

An Nginx reverse proxy with SSL is how you expose backend applications to the internet securely. Instead of your Node.js API, Python app, or Docker container listening on a public port, Nginx sits in front — terminating TLS, handling HTTPS redirects, adding security headers, and forwarding requests to your application over a private connection. This guide covers the full setup from installing Nginx and obtaining Let's Encrypt certificates to production-ready configurations with security headers, WebSocket support, and Cloudflare origin SSL.

Why Use Nginx as a Reverse Proxy

Most application frameworks (Express, Django, Flask, Rails) include a built-in HTTP server. These are designed for development, not production. They handle one request at a time or have basic concurrency — Nginx handles tens of thousands of concurrent connections with minimal memory. Beyond performance, Nginx gives you:

I run Nginx in front of every web application I deploy, whether it's running directly on the host or inside Docker Compose. The configuration patterns are the same either way.

Installing Nginx on Ubuntu and Debian

Install from the default repositories and enable auto-start:

sudo apt update && sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx

Verify it's running by visiting your server's IP in a browser — you should see the default Nginx welcome page. If you have UFW enabled, allow HTTP and HTTPS:

sudo ufw allow 'Nginx Full'

Nginx configuration lives in /etc/nginx/. The main file is nginx.conf, and site-specific configs go in /etc/nginx/sites-available/ with symlinks in /etc/nginx/sites-enabled/.

Basic Reverse Proxy Configuration with proxy_pass

How do I configure Nginx to proxy requests to a backend app?

Create a new site configuration. The proxy_pass directive tells Nginx where to forward requests. If your API runs on localhost:3000:

# /etc/nginx/sites-available/myapp.conf
server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

Enable the site and test the configuration:

sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

The proxy_set_header lines are critical. Without X-Real-IP, your application sees all requests coming from 127.0.0.1 instead of the actual client IP. Without X-Forwarded-Proto, your app can't tell if the original request was HTTP or HTTPS — which breaks redirect logic and secure cookie flags. Always test with nginx -t before reloading.

SSL with Let's Encrypt and Certbot

How do I get a free SSL certificate for my Nginx server?

Let's Encrypt provides free, automatically renewable SSL certificates. Certbot is the official client that handles everything — generating the certificate, configuring Nginx, and setting up auto-renewal:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot modifies your Nginx config automatically: it adds the ssl_certificate and ssl_certificate_key directives, creates an HTTP-to-HTTPS redirect, and configures modern TLS settings. After running, your site is accessible over HTTPS with a valid certificate.

Verify auto-renewal is set up:

sudo certbot renew --dry-run

Certbot installs a systemd timer (or cron job) that renews certificates before they expire (every 90 days, with renewal attempts at 60 days). If the dry run succeeds, you never need to think about certificate renewal again.

Production Nginx Configuration with SSL and Security Headers

Certbot's auto-generated config is a good start, but a production setup needs security headers, optimized SSL settings, and proper caching. Here's the complete configuration I use:

# /etc/nginx/sites-available/myapp.conf
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

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

    # SSL certificates (managed by Certbot)
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Modern TLS configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    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;

    # Reverse proxy to application
    location / {
        proxy_pass http://127.0.0.1:3000;
        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_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 4 16k;
    }

    # Static files (served directly by Nginx)
    location /static/ {
        alias /var/www/myapp/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

What do the security headers protect against?

Strict-Transport-Security (HSTS) tells browsers to always use HTTPS for this domain — even if a user types http://. The 2-year max-age with includeSubDomains covers your entire domain. X-Frame-Options SAMEORIGIN prevents your site from being embedded in iframes on other domains (clickjacking protection). X-Content-Type-Options nosniff stops browsers from guessing MIME types — a vector for XSS attacks through uploaded files. Referrer-Policy controls how much URL information is sent when users click links to other sites.

Test with nginx -t and reload:

sudo nginx -t && sudo systemctl reload nginx

Proxying Multiple Applications on Different Paths

How do I serve multiple backends from one Nginx server?

Use separate location blocks to route traffic to different backend services:

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

    # ... SSL and headers as above ...

    # Frontend app
    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;
    }

    # API backend
    location /api/ {
        proxy_pass http://127.0.0.1:4000/;
        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;
    }

    # Admin panel (IP-restricted)
    location /admin/ {
        allow 203.0.113.50;
        deny all;
        proxy_pass http://127.0.0.1:5000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Note the trailing slash on proxy_pass http://127.0.0.1:4000/. With the slash, Nginx strips the /api/ prefix before forwarding — so /api/users hits the backend as /users. Without the slash, the full path is forwarded. This is one of the most common sources of 404 errors in reverse proxy setups.

The admin panel uses allow/deny directives for IP-based access control — an additional layer on top of UFW firewall rules.

WebSocket Proxying with Nginx

How do I proxy WebSocket connections through Nginx?

WebSocket connections start as an HTTP upgrade request. Nginx needs two additional headers to pass the upgrade through to your backend:

location /ws/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;
}

The Upgrade and Connection headers are what make WebSocket proxying work. Without them, the connection stays as regular HTTP and the WebSocket handshake fails. proxy_read_timeout 86400s (24 hours) prevents Nginx from closing idle WebSocket connections — the default 60-second timeout kills long-lived connections that are waiting for events.

Cloudflare Origin SSL Setup

Do I still need SSL on my server if I use Cloudflare?

Yes. Without origin SSL, the connection between Cloudflare and your server is unencrypted — anyone intercepting traffic between Cloudflare's edge and your server can read it. Cloudflare offers free origin certificates specifically for this:

ssl_certificate /etc/nginx/ssl/origin.crt;
ssl_certificate_key /etc/nginx/ssl/origin.key;

Set Cloudflare's SSL mode to Full (Strict) — this ensures Cloudflare validates your origin certificate. "Full" without "Strict" accepts self-signed certificates, which defeats the purpose. With this setup, traffic is encrypted from user to Cloudflare (their edge certificate) and from Cloudflare to your server (your origin certificate). I use Cloudflare origin certs on most of my production servers because they never expire (practically) and don't require renewal automation.

Nginx Rate Limiting for API Protection

Protect your backend from abuse and DDoS with Nginx's built-in rate limiting:

# In the http block of /etc/nginx/nginx.conf
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
# In your server block
location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://127.0.0.1:4000/;
    # ... proxy headers ...
}

location /api/auth/login {
    limit_req zone=login burst=5;
    proxy_pass http://127.0.0.1:4000/auth/login;
    # ... proxy headers ...
}

The api zone allows 10 requests per second with a burst of 20 — covering normal usage with room for page loads that trigger multiple API calls. The login zone limits to 1 request per second — aggressive, but login endpoints are the primary target for credential stuffing. nodelay processes burst requests immediately rather than spacing them out; omit it for login to enforce actual throttling.

Troubleshooting Common Nginx Reverse Proxy Issues

Always check the Nginx error log for details:

sudo tail -50 /var/log/nginx/error.log

For the complete server security stack — SSH, firewall, and kernel hardening alongside your Nginx proxy — see the Linux server hardening checklist and the VPS security guide. For a deeper dive into Nginx configuration, check the Nginx wiki reference.