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 Host must be present or the application sees the upstream address and generates the wrong absolute URLs.
  • X-Forwarded-Proto is what tells the application it is behind TLS. Without it, redirects go to http and you get a loop.
  • proxy_http_version 1.1 plus Connection "" is required for upstream keepalive to work at all.
  • add_header inside a location block 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>
TaskNginxApache
Virtual hostserver { server_name ... }VirtualHost
Proxyproxy_passProxyPass and ProxyPassReverse
Preserve HostSet Host explicitlyProxyPreserveHost On
WebSocket upgradeUpgrade and Connection headersmod_proxy_wstunnel
Compressiongzip on, or Brotli via a modulemod_deflate
Config testnginx -tapachectl configtest
Reloadsystemctl reload nginxsystemctl 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;
}
SymptomCauseFix
502 Bad GatewayUpstream not listening or crashedCheck the app process and the upstream port
504 Gateway TimeoutUpstream slower than the proxy timeoutRaise the timeout and find out why it is slow
Redirect loopX-Forwarded-Proto missingForward it and trust it in the application
Wrong client IP in logsProxy hops not trustedset_real_ip_from, or mod_remoteip
413 on uploadclient_max_body_sizeRaise it on the proxy and the app server
Headers missing on one pathadd_header inside a locationRepeat them in that block or use an include
WebSocket disconnects at 60 sDefault proxy read timeoutRaise 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.

Custom domains and TLS Security hardening, cost control and migrations

Last refreshed 2026-09-18.