Configuring Nginx and Apache as a reverse proxy
Server blocks and virtual hosts, proxying to an application server, forwarding the real client details, compression, caching rules, and a minimal hardened configuration.
Nginx in front of an application
upstream app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
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;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 25m;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /static/ {
root /srv/www;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
proxy_pass http://app;
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_set_header Connection "";
proxy_read_timeout 60s;
}
}proxy_set_header Hostmust be present or the application sees the upstream address and generates the wrong absolute URLs.X-Forwarded-Protois what tells the application it is behind TLS. Without it, redirects go tohttpand you get a loop.proxy_http_version 1.1plusConnection ""is required for upstream keepalive to work at all.add_headerinside alocationblock replaces inherited headers from the server block rather than adding to them, which is a common way to silently lose HSTS on one path.
Apache equivalents
<VirtualHost *:443>
ServerName example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:3000/ retry=0
ProxyPassReverse / http://127.0.0.1:3000/
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
<Location "/">
Require all granted
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
</Location>
ErrorLog /var/log/apache2/example-error.log
CustomLog /var/log/apache2/example-access.log combined
</VirtualHost>| Task | Nginx | Apache |
|---|---|---|
| Virtual host | server { server_name ... } | VirtualHost |
| Proxy | proxy_pass | ProxyPass and ProxyPassReverse |
| Preserve Host | Set Host explicitly | ProxyPreserveHost On |
| WebSocket upgrade | Upgrade and Connection headers | mod_proxy_wstunnel |
| Compression | gzip on, or Brotli via a module | mod_deflate |
| Config test | nginx -t | apachectl configtest |
| Reload | systemctl reload nginx | systemctl reload apache2 |
Both configurations should be validated before reload, and reload should be used rather than restart. A reload keeps existing connections and applies the new config; a restart drops every in-flight request.
Proxy behaviour that bites
# real client IP when there is another proxy or CDN in front
set_real_ip_from 203.0.113.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# long-lived connections need their own timeouts
location /socket {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | Upstream not listening or crashed | Check the app process and the upstream port |
| 504 Gateway Timeout | Upstream slower than the proxy timeout | Raise the timeout and find out why it is slow |
| Redirect loop | X-Forwarded-Proto missing | Forward it and trust it in the application |
| Wrong client IP in logs | Proxy hops not trusted | set_real_ip_from, or mod_remoteip |
| 413 on upload | client_max_body_size | Raise it on the proxy and the app server |
| Headers missing on one path | add_header inside a location | Repeat them in that block or use an include |
| WebSocket disconnects at 60 s | Default proxy read timeout | Raise the timeout for that location only |
⚠️
Trusting
X-Forwarded-For from any source lets a client forge its own IP and defeat every rate limit and block rule you have. Only set set_real_ip_from for addresses that genuinely sit in front of this server, and configure the application to trust the header only from those same addresses.FAQ
Nginx or Apache?
Nginx for a reverse proxy in front of an application, for concurrency and for a smaller memory footprint. Apache remains a good choice when you depend on
.htaccess overrides or a module that has no Nginx equivalent.Should the application server listen only on localhost?
Yes, unless it must be reachable directly. Binding to
127.0.0.1 means only the proxy can reach it, which removes a whole class of exposure.Related
Custom domains and TLS Security hardening, cost control and migrations
Last refreshed 2026-09-18.