How to Configure nginx as a Reverse Proxy
Configure nginx as a reverse proxy: proxy_pass basics, correct header forwarding, WebSocket upgrade handling, and TLS termination with Let's Encrypt.
Before you start
- ▸nginx installed and active (systemctl status nginx shows running)
- ▸A domain name pointed at your server's public IP
- ▸Port 80 and 443 open in your firewall (ufw, firewalld, or nftables)
- ▸A backend service already listening on a local port (e.g., 127.0.0.1:3000)
A reverse proxy sits in front of one or more backend services, forwarding client requests and returning responses on their behalf. nginx does this exceptionally well: low memory footprint, flexible configuration, and battle-tested SSL/TLS handling. This guide covers the four patterns you will reach for most often — basic proxy_pass, header forwarding, WebSocket proxying, and TLS termination — with concrete configuration blocks you can drop into a real server.
Prerequisites and Layout Assumptions
The examples assume nginx is already installed and running under systemd. Backend services listen on 127.0.0.1 on various ports (a common security posture — the backend never exposes itself directly). All config snippets belong under /etc/nginx/; the exact include layout varies by distro:
- Debian/Ubuntu: drop site files in
/etc/nginx/sites-available/and symlink tosites-enabled/. - Fedora/RHEL/Rocky: place files in
/etc/nginx/conf.d/with a.confextension. - Arch: same
conf.dpattern, or edit/etc/nginx/nginx.confdirectly.
Step 1 — Basic proxy_pass
The proxy_pass directive is the core of every reverse proxy setup. It tells nginx where to forward the request. The simplest case: forward all traffic for a virtual host to a local Node.js app on port 3000.
sudo nano /etc/nginx/sites-available/myapp.conf # Debian/Ubuntu
# or
sudo nano /etc/nginx/conf.d/myapp.conf # Fedora/RHEL/Arch
Paste this server block:
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
On Debian/Ubuntu, enable the site and reload:
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
On Fedora/RHEL/Arch, just test and reload (no symlink step needed):
sudo nginx -t
sudo systemctl reload nginx
Trailing-slash subtlety: proxy_pass http://127.0.0.1:3000; (no trailing slash) forwards the full URI including the location prefix. Adding a trailing slash strips the prefix. For a root location / this makes no difference, but for location /api/ it matters — be deliberate.
Step 2 — Forwarding the Right Headers
Without extra headers, your backend sees every request as originating from 127.0.0.1 and has no idea about the original host, protocol, or client IP. Fix that with a shared snippet.
sudo nano /etc/nginx/proxy_params
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_redirect off;
Debian/Ubuntu ships this file already; on other distros create it manually. Include it in any location block that proxies:
location / {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/proxy_params;
}
Why proxy_http_version 1.1? nginx defaults to HTTP/1.0 for upstream connections, which disables keep-alives and breaks WebSockets. Setting 1.1 is almost always the right call.
If your backend framework (Express, Django, etc.) uses X-Forwarded-For to determine the client IP, make sure it is configured to trust the proxy. Otherwise an attacker can spoof the header. In Express that is app.set('trust proxy', 1); in Django it is USE_X_FORWARDED_HOST = True plus SECURE_PROXY_SSL_HEADER.
Step 3 — Proxying WebSockets
WebSocket connections start as an HTTP Upgrade handshake. nginx drops the Upgrade and Connection headers by default (they are hop-by-hop), so you must forward them explicitly.
location /ws/ {
proxy_pass http://127.0.0.1:4000;
include /etc/nginx/proxy_params;
# WebSocket-specific
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Keep idle connections alive long enough for long-lived sockets
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
If your app mixes HTTP and WebSocket traffic on the same path, use a map to set Connection correctly for both:
# In the http {} block, outside server {}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Then inside location {}
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
This map sends upgrade when the client requested an upgrade, and close for normal HTTP — no disruption to regular requests.
Step 4 — TLS Termination
TLS termination means nginx handles HTTPS with clients while the backend receives plain HTTP locally. This keeps certificate management centralised and offloads crypto from your app.
Get a Certificate
Use Certbot with the nginx plugin (the fastest path on most distros):
# Debian/Ubuntu
sudo apt install certbot python3-certbot-nginx
# Fedora/RHEL 9+
sudo dnf install certbot python3-certbot-nginx
# Arch
sudo pacman -S certbot certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot edits your nginx config automatically. If you prefer manual control, use certonly and manage the server block yourself (shown below).
A Complete TLS Server Block
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx >= 1.25.1 syntax
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;
# Modern TLS settings (Mozilla Intermediate, as of 2024)
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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location / {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/proxy_params;
}
}
# Redirect plain HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
If you are on nginx older than 1.25.1, replace http2 on; with adding http2 to the listen directive: listen 443 ssl http2;.
Step 5 — Upstream Blocks for Load Balancing
When you have more than one backend instance, define an upstream block. nginx round-robins by default; add least_conn for uneven workloads.
upstream app_backend {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
keepalive 32; # idle keep-alive connections to the backend pool
}
server {
listen 443 ssl;
# ... ssl config ...
location / {
proxy_pass http://app_backend;
include /etc/nginx/proxy_params;
}
}
Step 6 — Verify the Configuration
Always test before reloading. A syntax error in a running nginx will leave the old config in place; a reload after a bad config change does the same — but it is still good practice to catch errors early.
sudo nginx -t
Expected output (will vary slightly by version):
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx
Confirm the proxy is working end-to-end:
curl -I https://example.com
Look for HTTP/2 200 (or 301 if testing HTTP), the Strict-Transport-Security header, and — if your backend sets one — a custom Server or application header.
For WebSocket verification, websocat is handy:
websocat wss://example.com/ws/
Troubleshooting
- 502 Bad Gateway: nginx reached the backend but got no response, or the backend is not listening. Check
sudo systemctl status myappand confirm the port withss -tlnp | grep 3000. - 504 Gateway Timeout: the backend is responding but too slowly. Raise
proxy_read_timeoutif the long response is expected (e.g., a slow API), or investigate backend performance. - WebSocket returns 400: the
UpgradeandConnectionheaders are not reaching the backend. Double-check your location block has bothproxy_set_headerlines and thatproxy_http_version 1.1is set. - Certificate errors after renewal: Certbot's systemd timer should handle this automatically. Confirm with
systemctl status certbot.timerand test withsudo certbot renew --dry-run. - Backend sees wrong IP: check your framework's trusted-proxy setting. Also verify SELinux (RHEL/Fedora) is not blocking nginx from connecting:
sudo setsebool -P httpd_can_network_connect 1.
Frequently asked questions
- What is the difference between proxy_pass with and without a trailing slash?
- Without a trailing slash nginx forwards the full URI including the location prefix. With a trailing slash it strips the prefix before forwarding. This only matters for non-root locations like /api/ — be deliberate about which behaviour you need.
- Do I need to open port 3000 in my firewall if nginx is proxying it?
- No — and you should not. Bind your backend to 127.0.0.1 so it is only reachable locally. nginx connects to it over the loopback interface, and only ports 80 and 443 need to be open to the outside world.
- Why does my backend still see 127.0.0.1 as the client IP?
- You need both the proxy_params headers in nginx and trusted-proxy configuration in your application framework. nginx sends X-Real-IP and X-Forwarded-For, but the app must be told to read them rather than the direct socket address.
- Does TLS between nginx and the backend matter?
- If both nginx and the backend run on the same host, plain HTTP on the loopback is generally fine. If they are on separate machines on an untrusted network, add TLS on that leg too — use proxy_ssl_* directives and point proxy_pass at https://.
- How do I get nginx to automatically reload when Certbot renews the certificate?
- Certbot installs a deploy hook at /etc/letsencrypt/renewal-hooks/deploy/. Create a script there with 'systemctl reload nginx' and make it executable. Certbot's systemd timer runs twice daily and will call the hook after a successful renewal.
Related guides
Build a Mesh VPN with Nebula
Build a fully self-hosted mesh VPN with Nebula: create a CA, sign node certs, configure lighthouses, enforce group-based firewall rules, and run as a systemd service.
Common Linux Network Ports Reference
Learn Linux port ranges, read /etc/services, find what's listening with ss and nmap, and apply solid firewall rules to expose or block the right ports.
How to Configure a Static IP on Linux
Configure a static IP on Linux using Netplan, NetworkManager (nmcli), or systemd-networkd across Ubuntu, Fedora, Debian, and Arch with verified steps.
Expose a Service with Cloudflare Tunnel
Expose local services to the internet without port-forwarding using Cloudflare Tunnel. Install cloudflared, create a named tunnel, configure ingress rules, and run as a systemd service.