Guides

Nginx reverse proxy with Let's Encrypt: put any app behind HTTPS

Proxy a Node, Python or Docker app on localhost through Nginx, get a free Let's Encrypt certificate with certbot, and pass WebSockets and real client IPs.

Published ~10 min read

Short answer: let your app listen on 127.0.0.1:3000, give Nginx a server block for the domain that does proxy_pass http://127.0.0.1:3000 and passes the forwarding headers, then run sudo certbot --nginx -d app.example.com. Certbot gets a free Let’s Encrypt certificate, adds HTTPS and the HTTP-to-HTTPS redirect to the server block, and renews the certificate automatically.

Two things must be in place before you start:

  • DNS: an A record (and an AAAA record if the server has IPv6) for app.example.com pointing at the server. Let’s Encrypt checks the domain over the internet, so it has to resolve first. See pointing a domain at your VPS.
  • Ports 80 and 443 open: sudo ufw allow 'Nginx Full' on Ubuntu and Debian with ufw. Port 80 stays open after HTTPS works, because the certificate check and the redirect use it.

How do I install Nginx and certbot?

Ubuntu · Debian
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo ufw allow 'Nginx Full'          # if ufw is active
Rocky Linux · CentOS Stream
sudo dnf install -y epel-release      # certbot comes from EPEL
sudo dnf install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
sudo setsebool -P httpd_can_network_connect 1   # SELinux: let Nginx connect to your app

That last line matters. Without it, SELinux stops Nginx from connecting to the app port, and every request ends in a 502 with (13: Permission denied) while connecting to upstream in the error log.

What does a minimal reverse-proxy server block look like?

First, one small file that every proxied site can share. It sets $connection_upgrade, which the WebSocket lines below use:

/etc/nginx/conf.d/connection-upgrade.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Then the site itself. Only the domain and the port change from app to app:

/etc/nginx/sites-available/app.example.com
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    # largest upload Nginx accepts (default: 1 MB)
    client_max_body_size 50m;

    location / {
        # the app, reachable only from this server
        proxy_pass http://127.0.0.1:3000;

        # tell the app what the client actually asked for
        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
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
enable and test
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
curl -I http://app.example.com        # should answer with your app's headers

On Rocky Linux and CentOS Stream there is no sites-available. Put the same server block into /etc/nginx/conf.d/app.example.com.conf and skip the symlink. Always run nginx -t before a reload: it names the file and line of any mistake, and the running Nginx keeps serving until the config is valid.

Which headers should I pass to the app?

Behind a reverse proxy, every request reaches the app from 127.0.0.1 over plain HTTP. The four headers put the original facts back:

  • Host is the domain the visitor asked for. Without it, the app sees 127.0.0.1:3000 and builds links and redirects with it.
  • X-Real-IP and X-Forwarded-For carry the visitor’s IP address, so logs, rate limits and geo lookups see the visitor instead of the proxy.
  • X-Forwarded-Proto says the visitor used HTTPS. Apps that do not know this generate http:// URLs, send their own redirect to HTTPS and loop, or refuse to set secure cookies.

The app has to be told to trust these headers. It should only trust them from the proxy, because anyone can send them:

trusting the proxy
// Express
app.set("trust proxy", "loopback");

# Flask / Werkzeug
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

# Django settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# uvicorn trusts these headers from 127.0.0.1 by default

This is also why the app should listen on 127.0.0.1 and not on 0.0.0.0. If the app port is reachable from outside, anyone can bypass the proxy and fake the headers.

How do I proxy WebSockets?

A WebSocket starts as an HTTP request with Upgrade: websocket and Connection: upgrade headers. Nginx does not pass those hop-by-hop headers on by itself, and it talks HTTP/1.0 to upstreams by default, which cannot upgrade. The three lines in the server block above (proxy_http_version 1.1 plus the Upgrade and Connection headers) fix both. The map sends upgrade when the client asks for it and close otherwise. Dashboards such as n8n, Grafana and Home Assistant, and anything built on Socket.IO, need this; without it the page loads but stays “connecting” or loses its live updates.

Nginx also closes a proxied connection that has been silent for 60 seconds. Most apps send keep-alive pings, but if yours does not, raise the timeout for that location, for example proxy_read_timeout 1h;.

How do I get and renew the certificate?

  1. 1Request the certificate
    certbot
    sudo certbot --nginx -d app.example.com --redirect

    Certbot asks for an e-mail address and for agreement to the terms the first time, proves to Let’s Encrypt that you control the domain over port 80, and edits the matching server block.

  2. 2Check what changed

    Afterwards the file looks roughly like this. Certbot marks its own lines:

    after certbot (shortened)
    server {
        server_name app.example.com;
        client_max_body_size 50m;
        location / { ... unchanged ... }
    
        listen [::]:443 ssl; # managed by Certbot
        listen 443 ssl; # managed by Certbot
        ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; # managed by Certbot
        include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
    }
    
    server {
        if ($host = app.example.com) {
            return 301 https://$host$request_uri;
        } # managed by Certbot
        listen 80;
        listen [::]:80;
        server_name app.example.com;
        return 404; # managed by Certbot
    }

    For HTTP/2, add http2 on; to the HTTPS block on Nginx 1.25.1 or newer (Debian 13). Older versions, such as Ubuntu 24.04’s 1.24 and Debian 12’s 1.22, use listen 443 ssl http2; instead.

    To see the redirect from outside, enter http://app.example.com in the HTTP header checker: it should show one 301 to the https:// address and then your app’s answer, with the certificate and its expiry date.

  3. 3Test the renewal
    renewal
    sudo certbot renew --dry-run      # simulates a renewal, changes nothing
    sudo certbot certificates         # names and expiry dates
    systemctl list-timers | grep certbot
    
    # Rocky / CentOS Stream (EPEL): the timer is not switched on by itself
    sudo systemctl enable --now certbot-renew.timer

Let’s Encrypt certificates are valid for 90 days. Certbot’s timer runs twice a day, renews a certificate once it is within 30 days of expiry, and reloads Nginx afterwards. Do not count on reminder e-mails: Let’s Encrypt stopped sending expiry notices in 2025, and it has announced shorter lifetimes, which is one more reason to leave renewal to the timer.

How do I run several apps on one VPS?

Give each app its own subdomain, its own port on 127.0.0.1 and its own server block. Nginx picks the block by server_name. To avoid repeating the header lines, move them into a snippet (on Rocky, create /etc/nginx/snippets first):

/etc/nginx/snippets/proxy.conf
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_http_version 1.1;
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection $connection_upgrade;
two apps, two subdomains
server {
    listen 80;
    server_name app.example.com;
    location / { proxy_pass http://127.0.0.1:3000; include snippets/proxy.conf; }
}

server {
    listen 80;
    server_name n8n.example.com;
    location / { proxy_pass http://127.0.0.1:5678; include snippets/proxy.conf; }
}

# then one certbot run per name, or both at once:
sudo certbot --nginx -d app.example.com -d n8n.example.com --redirect

Serving an app under a path instead, such as example.com/app/, works with location /app/ { proxy_pass http://127.0.0.1:3000/; }. The trailing slash strips /app before the request reaches the app. The catch is that the app still builds its links and assets from / unless it has a base-path setting, so pages load without CSS or links break. Subdomains avoid this entirely. A complete example with a real dashboard is self-hosting n8n.

How do I proxy a Docker container?

Publish the container’s port on 127.0.0.1 only, and point proxy_pass at it as usual:

Docker · Compose
docker run -d --name app -p 127.0.0.1:3000:3000 myimage

# docker-compose.yml
services:
  app:
    image: myimage
    ports:
      - "127.0.0.1:3000:3000"

Why do uploads fail with 413 Request Entity Too Large?

Nginx accepts request bodies of up to 1 MB by default and answers anything larger with 413 before the app sees it. Set client_max_body_size in the server block (as in the example above) or in a single location, then nginx -t and reload. If the app has its own upload limit, raise that too. When the 413 survives that, 413 Request Entity Too Large goes through every other layer that can send it, Cloudflare and PHP included.

Why do I get 502 or 504 right after setup?

  • 502 Bad Gateway: Nginx cannot reach the app. Check that it runs and listens where proxy_pass points: curl -I http://127.0.0.1:3000 on the server. A classic case is an app that binds to localhost and ends up on the IPv6 address ::1 only; bind it to 127.0.0.1 explicitly. On Rocky, check the SELinux boolean above. More causes in nginx 502 Bad Gateway.
  • 504 Gateway Timeout: the app accepted the request but did not answer within 60 seconds. See nginx 504 Gateway Timeout.

The first line of sudo tail /var/log/nginx/error.log after a failed request usually says which of these it is.

A 400 Bad Request with “The plain HTTP request was sent to HTTPS port” is the opposite mistake: something spoke http:// to port 443, a link or a proxy_pass line. See 400 Bad Request.

What changes behind Cloudflare?

  • Use SSL mode Full (strict). Cloudflare then connects to your server over HTTPS and checks the certificate, and a Let’s Encrypt certificate passes. Flexible connects over plain HTTP, and together with certbot’s redirect it gives you ERR_TOO_MANY_REDIRECTS.
  • Get the first certificate with the record set to DNS only (grey cloud), then switch the proxy on. Renewals work through the proxy. Alternatively use the DNS challenge with the python3-certbot-dns-cloudflare plugin and an API token, which needs no open port 80 and can issue wildcard certificates.
  • Restore the visitor’s IP. Behind Cloudflare, $remote_addr is a Cloudflare address. The realip module, built into the Debian, Ubuntu and Rocky packages, replaces it with the visitor’s IP:
/etc/nginx/conf.d/cloudflare-realip.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... one line for every range listed on https://www.cloudflare.com/ips/
real_ip_header CF-Connecting-IP;

Keep that list current. If Cloudflare adds a range you have not listed, requests from it show up with a Cloudflare IP again.

When Cloudflare shows its own error page instead of your site, the code says where the connection broke: 521 when nothing accepts it on the port, 522 when it times out, and 520 when your server answers with something Cloudflare cannot use.

Should I use Caddy instead of Nginx?

If all you need is HTTPS in front of a few apps, Caddy is less work. It gets and renews certificates by itself, redirects HTTP to HTTPS, and passes the forwarding headers and WebSockets without extra lines. The whole config for the example above is three lines:

/etc/caddy/Caddyfile
app.example.com {
    reverse_proxy 127.0.0.1:3000
}

Nginx is the better choice if it is already on the server, if you need its finer control over caching, buffering and rate limits, or if you want the far larger body of documentation and examples. Caddy is packaged in Debian 12 and later and in Ubuntu 24.04 (apt install caddy), and Caddy’s own repository has newer versions.

Where do rate limits belong?

Network-level DDoS protection and application limits do different jobs. On VMHeaven, always-on DDoS protection filters floods before they reach the server, but it cannot know that 50 POST requests a minute to your login form are an attack. That is the proxy’s job:

limit login attempts per IP
# /etc/nginx/conf.d/ratelimit.conf (http context)
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_status 429;

# in the server block
location = /login {
    limit_req zone=login burst=5 nodelay;
    proxy_pass http://127.0.0.1:3000;
    include snippets/proxy.conf;
}

Behind Cloudflare, set up the realip block first, or every visitor shares the same few Cloudflare addresses and hits the limit together. If real users start getting 429s, 429 Too Many Requests shows how to tune the zone and the burst. Everything in this guide runs on any KVM VPS with root access, on the Ubuntu, Debian and Rocky Linux templates alike, and a fresh Ubuntu VPS is the shortest path to it.

Frequently asked

Do I still need certbot if my site is behind Cloudflare?

Yes, or an equivalent. Cloudflare's Full (strict) mode, the one to use, needs a valid certificate on your server. A Let's Encrypt certificate from certbot works, and so does a Cloudflare Origin CA certificate, which only Cloudflare trusts. Without one you are left with Full mode on a self-signed certificate that Cloudflare does not verify, or Flexible mode, which sends traffic between Cloudflare and your server unencrypted.

How does Let's Encrypt renewal work with certbot?

Certbot installs a systemd timer that runs twice a day and renews any certificate within 30 days of expiry, then reloads Nginx. Test it with 'sudo certbot renew --dry-run'. On Rocky Linux and CentOS Stream, enable the timer once with 'sudo systemctl enable --now certbot-renew.timer'. Let's Encrypt no longer sends expiry e-mails, so do not rely on them.

Why does Nginx return 502 Bad Gateway right after I set up the proxy?

Nginx cannot reach the app at the address in proxy_pass. The app is not running, listens on a different port, or listens only on the IPv6 address ::1 while proxy_pass points at 127.0.0.1. On Rocky Linux, SELinux blocks the connection until you run 'sudo setsebool -P httpd_can_network_connect 1'. 'curl -I http://127.0.0.1:3000' on the server and /var/log/nginx/error.log show which it is.

Should I use Nginx or Caddy as a reverse proxy?

Caddy is simpler for HTTPS in front of a few apps: it issues and renews certificates itself, and a reverse proxy takes three lines of config. Nginx is the better fit if it is already installed, or if you need fine control over caching, buffering and rate limits. Either works well on a small VPS.

Related articles

All guides