Linux Lesson 39 of 47

Running a Production Web Stack: nginx/Apache, TLS/Let's Encrypt, Reverse Proxy & systemd Hardening

Somewhere between “I installed a web framework and it runs on localhost:8000” and “the public internet can reach my site over HTTPS without me getting paged at 2am” sits a whole layer most tutorials skip: the production web tier. It is the layer that terminates TLS so your app never touches a private key, that redirects every plaintext request to HTTPS, that hides your fragile application server behind a fast, hardened C program built to eat hostile traffic, and that stays up when someone points a scraper at your login page.

On Linux that layer is almost always nginx (sometimes Apache/httpd) acting as a reverse proxy in front of an application server — gunicorn, uwsgi, php-fpm, or a Node process — with Let’s Encrypt providing free, auto-renewing certificates and systemd wrapping the whole thing in a sandbox. Get this right once and it runs for years. Get it wrong and you leak your app on 0.0.0.0, serve a login form over cleartext HTTP, wake up to an expired certificate, or hand an attacker the whole box because nginx could write /etc.

This lesson builds that tier from first principles and hands you the real config files — not fragments, whole working blocks — for every piece: the nginx config model, reverse proxying with correct X-Forwarded-* headers and WebSocket upgrades, TLS done to a modern standard, certbot and its renewal timer, systemd unit hardening tied to SELinux and the firewall, rate limiting and performance tuning, socket-vs-TCP app integration, and observability. By the end you will have stood up nginx → gunicorn over a unix socket, on HTTPS, rate-limited, gzipped, and sandboxed, and you will know which hop is lying when it returns a 502.

Why this matters

Your application framework ships a development server and tells you, in bold, never use it in production. That warning is real. Django’s runserver, Flask’s built-in server, Rails’ webrick, Node’s bare http.createServer — they are single-threaded or lightly concurrent, they do not terminate TLS well, they do not survive a slow-client attack, and they were written to make development pleasant, not to face the internet. The production answer is universal across every stack: put a real web server in front, and let it reverse-proxy to your app.

That front server does the jobs your app should not: it terminates TLS in one place (one certificate, one cipher policy, one HSTS header for the whole fleet), it serves static files without waking a Python worker, it buffers slow clients so your expensive app workers are never tied up dribbling bytes to a phone on 3G, it rate-limits abuse at the cheap edge instead of the costly upstream, and it presents a single hardened attack surface on ports 80 and 443 while your app hides on loopback. This is not optional ceremony; it is the shape of essentially every web system in production, from a one-box side project to a fleet behind a load balancer.

Beginners hit this the moment they try to “deploy.” The app runs locally but the internet can’t reach it; they bind it to 0.0.0.0:8000 and now it is reachable — and also completely exposed, on the wrong port, over HTTP, with the dev server’s debug pages one misconfiguration away from leaking secrets. The fix is to stop exposing the app at all and put nginx in front. Everything in this lesson flows from that one decision.

The mental model to carry through: nginx is a reverse proxy and a TLS terminator; your app is a private upstream it talks to over loopback or a socket; systemd is the supervisor that keeps nginx alive and boxed in; Let’s Encrypt is the certificate authority that makes HTTPS free and automatic. Hold those four roles clearly and every config file below is obvious.

The anatomy of a production web tier

Before touching a config file, get the map right. A request from a browser to your app crosses several hops, and the Linux box you are configuring sits at a specific one. Reading a request left to right:

client → DNS → edge/CDN/load-balancer → reverse proxy (nginx) → app/upstream → data store

Not every tier has every hop — a small deployment may be just nginx and an app on one box — but the roles are always the same, and knowing which hop owns which failure is how you debug fast. Here is the whole tier, what each layer does, who typically owns it, and the failures it causes:

Layer What it does Where it runs Typical failures it causes
Client Browser/app; DNS resolve, TLS handshake, retries End user’s device Stale DNS cache, old TLS, mixed-content warnings
DNS Maps example.com → IP (A/AAAA), CNAME, CAA Registrar / DNS provider NXDOMAIN, wrong record, TTL too long during cutover
Edge / CDN / LB TLS offload, caching, WAF, spreads load, DDoS absorb Cloud LB / CDN (optional) 502 from bad backend health, wrong X-Forwarded-*
Reverse proxy (nginx) TLS terminate, redirect, rate-limit, serve static, proxy to app Your Linux box 502/504, cert expiry, wrong port, SELinux/perm denials
App / upstream Your code: gunicorn, php-fpm, uwsgi, Node Same box or a private subnet Worker crash, slow query, socket permission, OOM
Data store Postgres/MySQL/Redis the app queries DB host (often separate) Connection limit, slow query surfaces as 504 upstream

The bolded row is the subject of this lesson: the Linux box running nginx as a reverse proxy. In a minimal deployment it also runs the app; in a larger one the app moves to its own hosts and nginx proxies across the network, or a cloud load balancer sits in front of several nginx boxes. The nginx configuration is nearly identical in all three shapes — what changes is only whether proxy_pass targets unix:/run/app.sock, 127.0.0.1:8000, or http://app-subnet:8000.

Two design rules define a good web tier and everything below serves them:

Terminate TLS once, at the edge of your trust boundary. The certificate, the cipher list, the HSTS policy — one place owns them. Behind that point, traffic can be plain HTTP on loopback because it never leaves the box (or travels a trusted private network). Terminating TLS in every app is a maintenance nightmare and a private-key sprawl risk.

The app is never directly reachable from the internet. It binds 127.0.0.1 or a unix socket. The only public listeners are nginx on 80 and 443. This single rule turns “my Flask debug page leaked the SECRET_KEY” from a catastrophe into an impossibility — the debug page is not reachable.

For the layers below nginx — the database the app queries — tuning and connection limits are their own deep topic covered in the hosting databases: Postgres/MySQL tuning lesson; a slow query there surfaces up here as a 504 at the proxy, so the two lessons meet at the timeout.

nginx: install, the config model, and serving static

nginx (pronounced “engine-x”) is an event-driven web server and reverse proxy. Its whole architecture is one master process (runs as root, reads config, binds ports 80/443, owns the certs) that spawns several worker processes (run as an unprivileged user, handle every connection). Workers are event-driven — one worker juggles thousands of connections with an epoll loop rather than a thread per connection — which is why nginx handles slow clients and high concurrency on modest hardware.

Install and enable it. The package name and default identities differ by family:

# Debian / Ubuntu
sudo apt update && sudo apt install -y nginx
# RHEL / Fedora / Rocky / Alma
sudo dnf install -y nginx

# both families: enable at boot and start now
sudo systemctl enable --now nginx
systemctl status nginx --no-pager

The distro defaults you must know before editing anything:

Thing Debian / Ubuntu RHEL / Fedora / Rocky
Package nginx nginx
Service unit nginx.service nginx.service
Worker user www-data nginx
Main config /etc/nginx/nginx.conf /etc/nginx/nginx.conf
Site config dir /etc/nginx/sites-available + sites-enabled (symlinks) /etc/nginx/conf.d/*.conf
Default doc root /var/www/html /usr/share/nginx/html
Logs /var/log/nginx/{access,error}.log /var/log/nginx/{access,error}.log

That sites-available/sites-enabled split is a Debian convention, not an nginx feature: you write a config in sites-available/ and enable it by symlinking it into sites-enabled/, which lets you disable a site without deleting its config. RHEL skips it and drops everything in conf.d/. Both work because nginx.conf ends with an include that pulls them in.

The config model: contexts nest

nginx configuration is a tree of contexts (blocks). A directive is only legal inside certain contexts. Learn the nesting and the file stops looking like noise:

Context Scope Holds directives like
main (top of file) Global process settings user, worker_processes, pid, include
events { } Connection processing worker_connections, use epoll
http { } All HTTP handling log_format, gzip, upstream, include sites
server { } One virtual host listen, server_name, ssl_certificate, root
location { } One URL path within a server proxy_pass, try_files, root, expires
stream { } Raw TCP/UDP proxy (not HTTP) server, proxy_pass (L4)

A stripped /etc/nginx/nginx.conf shows the skeleton — everything else lives in included files:

# /etc/nginx/nginx.conf — the top-level frame
user  www-data;                       # worker user (nginx on RHEL)
worker_processes  auto;               # one worker per CPU core
pid  /run/nginx.pid;

events {
    worker_connections  1024;         # max simultaneous connections PER worker
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    sendfile      on;                 # kernel-space file sending, no user-space copy
    keepalive_timeout  65;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" "$http_user_agent"';
    access_log  /var/log/nginx/access.log  main;
    error_log   /var/log/nginx/error.log   warn;

    include /etc/nginx/conf.d/*.conf;         # RHEL puts sites here
    include /etc/nginx/sites-enabled/*;       # Debian puts sites here
}

You almost never edit nginx.conf itself. You add one file per site under conf.d/ (or sites-available/). Here is the simplest real site — serving static files:

# /etc/nginx/conf.d/static-site.conf  (or sites-available/static-site)
server {
    listen 80;
    listen [::]:80;                      # IPv6 too
    server_name example.com www.example.com;

    root  /var/www/example;              # doc root on disk
    index index.html;

    location / {
        try_files $uri $uri/ =404;       # serve the file, or dir, or 404 — never proxy
    }

    location /assets/ {
        expires 30d;                     # let browsers cache static assets
        add_header Cache-Control "public, immutable";
    }
}

The edit-test-reload loop you will run a thousand times

Never reload nginx without testing first. A syntax error in a reload can, in the worst case, take the site down. The discipline is three commands:

sudo nginx -t                 # parse + validate config; prints the exact file:line of any error
sudo systemctl reload nginx   # graceful reload: workers finish in-flight requests, then swap config
# equivalent low-level form:
sudo nginx -s reload          # sends SIGHUP to the master

nginx -t is your seatbelt. It catches missing semicolons, unknown directives, and files it can’t read before they hit production. reload is graceful — it never drops a connection — whereas restart kills and re-spawns (a brief blip). The signals nginx understands directly:

Command Signal Effect
nginx -t Test config, report errors, change nothing
nginx -s reload SIGHUP Re-read config, start new workers, drain old ones
nginx -s reopen SIGUSR1 Reopen log files (use after log rotation)
nginx -s quit SIGQUIT Graceful shutdown (finish in-flight requests)
nginx -s stop SIGTERM Fast shutdown (drop connections)
systemctl reload nginx SIGHUP Same as -s reload, via the unit

location matching: the rule that trips everyone

Within a server, nginx picks exactly one location to handle a request, and the selection order is not top-to-bottom. It is by modifier priority:

Modifier Example Meaning Priority
= location = /health Exact match 1 (highest — wins immediately)
^~ location ^~ /assets/ Prefix match, stop regex search 2
~ location ~ \.php$ Regex, case-sensitive 3 (first match in file order)
~* location ~* \.(jpg|png)$ Regex, case-insensitive 3
(none) location / Prefix match 4 (longest prefix wins)

The rule: nginx tests exact (=) first, then remembers the longest matching prefix; if that prefix used ^~ it stops there, otherwise it tries regex locations in file order and the first regex that matches wins; if no regex matches, it falls back to the remembered prefix. The classic bug is expecting location / to catch a request that a regex location ~ \.php$ actually grabbed. When in doubt, use = for exact paths like /healthz and ^~ for static prefixes to short-circuit the regex hunt.

nginx as a reverse proxy

This is the heart of the lesson. A reverse proxy accepts a client’s request, forwards it to an upstream server, and relays the response back — so the client only ever talks to nginx, never to your app. The minimal form is a single proxy_pass:

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;   # forward everything to the app on loopback
    }
}

That works, but it is naive — the app now sees every request as coming from 127.0.0.1 over plain HTTP, with no idea who the real client is. A correct proxy block restores the truth and controls buffering and timeouts. Here is the production shape, and below it the full request path it implements:

# an upstream is a named pool of backends — even one is worth naming
upstream app_backend {
    server 127.0.0.1:8000;          # or: server unix:/run/app.sock;
    keepalive 32;                   # reuse connections to the app (needs http/1.1 below)
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://app_backend;

        # --- restore the client's identity for the app ---
        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;

        # --- keepalive to the upstream ---
        proxy_http_version 1.1;
        proxy_set_header Connection "";      # clear "close" so keepalive works

        # --- timeouts: fail fast, don't hang forever ---
        proxy_connect_timeout 5s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }
}

The X-Forwarded-* headers are not decoration — they are the contract between proxy and app. Behind a proxy, $remote_addr from the app’s view is always the proxy. nginx rewrites reality with these headers, and your app framework must be told to trust them:

Header set by nginx Value What the app does with it
Host $host Build correct absolute URLs, route by domain
X-Real-IP $remote_addr The single real client IP
X-Forwarded-For $proxy_add_x_forwarded_for Full client→proxy chain (appends, doesn’t replace)
X-Forwarded-Proto $scheme (http/https) Know the request was HTTPS at the edge
X-Forwarded-Host $host Original Host if you rewrite it

⚠️ These headers are client-spoofable if you trust them blindly. Only trust X-Forwarded-* from your proxy. In Django set SECURE_PROXY_SSL_HEADER; in gunicorn set --forwarded-allow-ips; in Node/Express set app.set('trust proxy', 'loopback'). Trust the header from the whole internet and an attacker forges their source IP past your rate limits and geo-blocks.

Where this sits in the whole request path — and the six things nginx does to every request between the client and your app — is the diagram to burn into memory:

The production reverse-proxy request path: an HTTPS client reaches nginx, which terminates TLS with a Let's Encrypt certificate and 301-redirects port 80 to 443; the reverse-proxy stage rate-limits with limit_req and sets X-Forwarded-* headers before forwarding over a unix socket to gunicorn or php-fpm workers bound to loopback; the response is gzipped, cached and logged; and the entire nginx systemd unit is wrapped in a ProtectSystem=strict sandbox

Reading it left to right: the client only ever speaks HTTPS to nginx (badge 1 — TLS terminates here); plain port 80 exists solely to 301-redirect to 443 (badge 2); the reverse-proxy stage rate-limits floods (badge 3) and rewrites X-Forwarded-* so the app sees the real client (badge 4); the nginx unit runs inside a systemd sandbox so a compromise can’t own the box (badge 5); and the app hides on a private unix socket, unreachable from the internet (badge 6). Every section below is one of these badges in detail.

Load balancing across several app instances

An upstream with more than one server is a load balancer. nginx spreads requests by a chosen method, with per-server health parameters:

upstream app_backend {
    least_conn;                                    # method (see table)
    server 10.0.0.11:8000  weight=2 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:8000  max_fails=3 fail_timeout=30s;
    server 10.0.0.13:8000  backup;                 # only used if others are down
    keepalive 32;
}

The balancing methods and their use cases:

Method Directive Picks the backend by Use when
Round-robin (default) Next in rotation Stateless apps, equal servers
Weighted RR weight=N Rotation scaled by weight Uneven server sizes
Least connections least_conn; Fewest active connections Long/variable request times
IP hash ip_hash; Hash of client IP Sticky sessions without shared state
Generic hash hash $request_uri; Hash of any key Cache affinity by URL
Random random two least_conn; Random of two, then least-conn Large fleets, cheap fairness

The per-server params matter as much as the method: max_fails=3 fail_timeout=30s means “after 3 failed attempts in 30s, take this server out of rotation for 30s”; backup holds a server in reserve; down marks one administratively out. This passive health checking is free in open-source nginx (active health checks are an nginx Plus feature).

WebSocket upgrade

WebSockets start as an HTTP request that asks to “upgrade” the connection to a persistent bidirectional stream. nginx will not pass the Upgrade/Connection headers through by default — you must forward them explicitly, or your WebSocket handshakes silently fail with a 400:

location /ws/ {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;                       # required for upgrade
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;                      # WS connections are long-lived
}

Buffering and timeouts: the two dials behind most proxy pain

nginx buffers the upstream’s response by default: it reads the whole response from your app as fast as the app can produce it, frees the app worker, then dribbles the bytes out to a slow client at the client’s pace. This is a feature — it protects your expensive app workers from slow clients — but it surprises people streaming large responses or Server-Sent Events, where you want proxy_buffering off;. The dials:

Directive Default What it controls
proxy_buffering on Buffer the whole upstream response before/while sending to client
proxy_buffer_size 4k/8k Size of the buffer for the response headers
proxy_buffers 8 4k/8k Number × size of buffers for the response body
proxy_connect_timeout 60s Max time to establish the upstream connection
proxy_send_timeout 60s Max gap while sending the request to upstream
proxy_read_timeout 60s Max gap while reading the response — the usual 504 cause
proxy_next_upstream error timeout Which failures cause a retry on the next upstream server

proxy_read_timeout is the one you tune most: if your app legitimately takes 90 seconds for a report, the default 60s returns 504 Gateway Timeout even though the app is working. Raise it for that location — but not globally, or you lose the fast-fail protection everywhere else.

When the fault is between nginx and the upstream, nginx synthesises the status code — so knowing which code means what localises the failure to a single hop before you read a single log line:

Code What nginx is saying Typical cause
502 Bad Gateway “I reached the upstream but got a broken/refused answer” App crashed, wrong port, socket permission, SELinux
503 Service Unavailable “No upstream available to serve this” All backends down/failed, or limit_req throttled
504 Gateway Timeout “The upstream connected but didn’t answer in time” Slow app / slow DB query vs proxy_read_timeout
499 Client Closed Request “The client gave up before the upstream answered” Impatient client or too-slow upstream (nginx-specific code)

Apache/httpd: the alternative

nginx is the default choice, but you will meet Apache HTTP Server (the package is apache2 on Debian, httpd on RHEL) on a huge installed base — especially anything PHP or anything using .htaccess. It does the same jobs with different syntax and a different concurrency model.

Apache’s config is directive-based inside <VirtualHost> blocks. The reverse-proxy equivalent of the nginx block above:

# /etc/apache2/sites-available/app.conf   (a2ensite app; systemctl reload apache2)
# needs: a2enmod proxy proxy_http headers
<VirtualHost *:80>
    ServerName app.example.com

    ProxyPreserveHost On
    ProxyPass        "/" "http://127.0.0.1:8000/"
    ProxyPassReverse "/" "http://127.0.0.1:8000/"

    RequestHeader set X-Forwarded-Proto "http"
    ErrorLog  ${APACHE_LOG_DIR}/app-error.log
    CustomLog ${APACHE_LOG_DIR}/app-access.log combined
</VirtualHost>

Apache’s own distro defaults differ the same way nginx’s do — know them before editing:

Thing Debian / Ubuntu RHEL / Fedora / Rocky
Package apache2 httpd
Service unit apache2.service httpd.service
Worker user www-data apache
Main config /etc/apache2/apache2.conf /etc/httpd/conf/httpd.conf
Vhost dir sites-available + sites-enabled /etc/httpd/conf.d/*.conf
Enable a site a2ensite name (drop .conf in conf.d/)
Enable a module a2enmod proxy LoadModule in conf.modules.d/
Doc root default /var/www/html /var/www/html

The head-to-head that decides which to run:

Aspect nginx Apache (httpd)
Concurrency model Event loop, few workers, C10K-friendly Prefork/worker/event MPM; historically thread/process-per-connection
Static file serving Extremely fast, low memory Fast, heavier per connection
Reverse proxy First-class (proxy_pass, upstream) Via mod_proxy / mod_proxy_http
Per-directory override None (by design — config is central) .htaccess (per-dir, read every request)
Dynamic content Always proxied to an app server Can embed via mod_php, or proxy to php-fpm
Config style Blocks + directives, include Directives + <VirtualHost>, a2enmod/a2ensite
Module loading Compiled/loaded at build/start LoadModule, a2enmod at runtime
Memory under load Flat, predictable Grows with connection count (prefork)
Best fit today Reverse proxy, static, high concurrency, TLS edge Legacy PHP apps, .htaccess-dependent apps, shared hosting

The one Apache feature nginx deliberately lacks is .htaccess — per-directory config files that Apache reads on every request if AllowOverride permits. It is convenient for shared hosting (each tenant tweaks their own dir without touching the main config) but it is a performance tax (a stat + parse per request per directory) and a frequent source of “why is this rule not applying” confusion. nginx’s stance is that config belongs in one central, tested place — which is faster and, for a single-tenant app, simpler. If you are migrating a PHP app that leans on .htaccess rewrites, you translate those RewriteRules into nginx location/rewrite/try_files blocks once, centrally.

Pick nginx for a new reverse-proxy/TLS-edge deployment. Keep or pick Apache when a legacy app requires .htaccess or mod_php, or when your team’s muscle memory is entirely Apache and the app is modest. The TLS, systemd-hardening and firewall sections below apply to both — only the server-config syntax differs.

TLS done right: Let’s Encrypt, certbot & a strong config

HTTPS is non-negotiable for anything public: browsers mark HTTP pages “Not secure,” HTTP/2 effectively requires TLS, and any form posting a password over cleartext is a breach waiting to happen. The blocker used to be cost and manual renewal. Let’s Encrypt removed both — free, automated, 90-day certificates — and certbot is the client that gets and renews them.

Getting a certificate with certbot

Install certbot and, for nginx, its nginx plugin:

# Debian / Ubuntu
sudo apt install -y certbot python3-certbot-nginx
# RHEL / Fedora / Rocky (via EPEL)
sudo dnf install -y certbot python3-certbot-nginx
# Or the vendor-recommended snap (any distro):
sudo snap install --classic certbot && sudo ln -s /snap/bin/certbot /usr/bin/certbot

certbot proves you control the domain by solving an ACME challenge. The three you will use, and when:

Challenge / mode Command How it proves control Use when
nginx plugin certbot --nginx -d example.com -d www.example.com certbot edits nginx config, serves the token, then writes the TLS config for you Simplest; nginx is running and certbot may edit it
webroot (HTTP-01) certbot certonly --webroot -w /var/www/example -d example.com Drops a file under /.well-known/acme-challenge/, LE fetches it over :80 You want certbot to touch files only, not your config
standalone (HTTP-01) certbot certonly --standalone -d example.com certbot runs its own temporary server on :80 No web server running yet (must free port 80)
DNS-01 certbot certonly --manual --preferred-challenges dns -d '*.example.com' Add a _acme-challenge TXT record Wildcards (*.example.com) and hosts not publicly reachable on :80

The nginx plugin is the fast path: sudo certbot --nginx -d example.com -d www.example.com obtains the cert, then rewrites your server block to listen on 443 with the cert, adds a 301 redirect from 80, and reloads. For wildcard certificates (*.example.com) HTTP-01 cannot work — you cannot prove control of infinite subdomains by serving files — so you must use DNS-01, ideally with a DNS-provider plugin (e.g. python3-certbot-dns-cloudflare) so renewal is fully automatic instead of manual TXT-record edits.

Certificates land in a predictable place; point nginx at these paths:

/etc/letsencrypt/live/example.com/fullchain.pem   # cert + intermediate chain  → ssl_certificate
/etc/letsencrypt/live/example.com/privkey.pem      # private key                → ssl_certificate_key

Auto-renewal: the systemd timer

Let’s Encrypt certs live 90 days and certbot renews them when ~30 days remain. You never do this by hand — the package installs a systemd timer that runs certbot renew twice a day. Renewal is idempotent: it only acts on certs near expiry.

# is the renewal timer active?  (name is certbot.timer, or snap.certbot.renew.timer for the snap)
systemctl list-timers | grep -i certbot
sudo systemctl status certbot.timer --no-pager

# ALWAYS test renewal without touching the real cert first:
sudo certbot renew --dry-run

The mechanics of timers — OnCalendar, Persistent=true, how a timer maps to a .service — are covered in depth in the systemd units, services, targets & journald lesson; here the point is simply that renewal is a scheduled job you must verify runs, because a silently-broken timer is how certificates expire on a Sunday. After renewal, nginx must reload to pick up the new cert. certbot handles this with a deploy hook:

# reload nginx automatically after any successful renewal
sudo certbot renew --deploy-hook "systemctl reload nginx"
# or drop a script into the hooks dir (runs after every renewal):
#   /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh   (chmod +x)

A strong TLS server block

Getting a cert is half the job; configuring TLS well is the other half. A modern, A-grade config — TLS 1.2/1.3 only, strong ciphers, HSTS, session resumption, and the HTTP→HTTPS redirect:

# HTTP :80 — redirect everything to HTTPS (and let ACME renew over :80)
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ { root /var/www/example; }  # renewal path
    location / { return 301 https://$host$request_uri; }               # everything else → HTTPS
}

# HTTPS :443 — the real site
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    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;

    # protocols + ciphers (Mozilla "intermediate" profile)
    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;
    ssl_prefer_server_ciphers off;      # let modern clients pick (best for TLS 1.3)

    # session resumption (fewer full handshakes)
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP stapling (see note below)
    ssl_stapling        on;
    ssl_stapling_verify on;

    # HSTS — force HTTPS for 2 years, incl. subdomains (add 'preload' only when sure)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

    root /var/www/example;
    # ... your location / proxy blocks ...
}

The knobs that matter, and why:

Directive Recommended value Why
ssl_protocols TLSv1.2 TLSv1.3 TLS 1.0/1.1 are deprecated and fail PCI/scanners
ssl_ciphers Mozilla intermediate list Forward-secret (ECDHE), AEAD (GCM/CHACHA20) only
ssl_prefer_server_ciphers off TLS 1.3 clients pick best; server-forcing is legacy
add_header Strict-Transport-Security max-age=63072000; includeSubDomains Browser refuses HTTP after first visit
ssl_stapling on Server staples revocation proof — faster, more private
ssl_session_cache shared:SSL:10m Resume sessions, skip full handshakes
http2 on Multiplexing; requires TLS in practice
return 301 https://... on :80 No content served over cleartext

Two current caveats worth knowing. First, http2 on; is the modern directive (nginx ≥ 1.25.1); older configs wrote listen 443 ssl http2;. Second, OCSP stapling is in flux: Let’s Encrypt announced it is winding down OCSP in favour of short-lived certs and CRLs, so on very recent certs stapling may become a no-op — keep the directives (they degrade gracefully) but don’t be alarmed when stapling reports “no response” as the CA retires the endpoints.

Test it — don’t assume

After reloading, verify from the outside:

Test Command / tool Checks
Handshake + chain openssl s_client -connect example.com:443 -servername example.com Cert chain, protocol, cipher; Verify return code: 0 (ok)
Expiry date echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates notBefore/notAfter
Full grade SSL Labs (browser) A/A+ grade, protocol + cipher audit
Local deep scan testssl.sh example.com Every protocol, cipher, vuln (Heartbleed, etc.)
Cipher enumeration nmap --script ssl-enum-ciphers -p 443 example.com Which ciphers the server actually offers

The -servername flag on openssl s_client sends SNI — without it, a server hosting many certs hands you the default one and you’ll misdiagnose “wrong certificate.” Always pass it.

Hardening the service: user, systemd sandbox, SELinux & firewall

nginx is internet-facing C code parsing hostile input. Assume it will one day have a bug. Hardening is about ensuring that when it does, the blast radius is a jail, not the whole box. Four layers, from process identity outward.

1. Run workers as an unprivileged user

The master starts as root only to bind ports 80/443 (privileged) and read the private key, then immediately drops the workers to www-data/nginx. That is already the default (user www-data;). The point of the remaining layers is to shrink even the master’s power and box in the workers.

2. systemd unit hardening — the sandbox

systemd can wrap any service in a kernel-enforced sandbox with a few directives. You do not edit the packaged unit; you add an override with systemctl edit nginx, which writes /etc/systemd/system/nginx.service.d/override.conf:

# systemctl edit nginx   →   /etc/systemd/system/nginx.service.d/override.conf
[Service]
# --- filesystem ---
ProtectSystem=strict                 # whole FS read-only...
ReadWritePaths=/var/log/nginx /var/cache/nginx /var/lib/nginx /run
ProtectHome=true                     # /home, /root, /run/user invisible
PrivateTmp=true                      # private /tmp, /var/tmp

# --- privileges ---
NoNewPrivileges=true                 # setuid binaries can't escalate
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_CHOWN
AmbientCapabilities=CAP_NET_BIND_SERVICE

# --- kernel + namespaces ---
ProtectKernelTunables=true           # /proc/sys, /sys read-only
ProtectKernelModules=true            # no module load/unload
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
SystemCallFilter=@system-service     # allowlist syscalls
SystemCallArchitectures=native

# --- limits ---
LimitNOFILE=65535                    # file-descriptor ceiling (see perf section)

Apply and score it:

sudo systemctl daemon-reload && sudo systemctl restart nginx
systemd-analyze security nginx        # 0=locked-down … 10=exposed; watch it drop

The directives that carry the most weight:

Directive Effect Why it matters for a web server
ProtectSystem=strict Entire FS read-only except ReadWritePaths An RCE can’t rewrite /etc, drop a cron job, or plant a binary
ReadWritePaths= Whitelist the few dirs nginx must write Logs + cache only; nothing else is writable
ProtectHome=true /home, /root invisible A traversal bug can’t read users’ SSH keys
PrivateTmp=true Private, empty /tmp No tmp-file race, no leaking to other services
NoNewPrivileges=true Block privilege escalation via setuid A dropped worker can’t regain root
CapabilityBoundingSet= Cap the capabilities the master may hold Keep only CAP_NET_BIND_SERVICE; drop the rest
SystemCallFilter=@system-service Allowlist syscalls A shellcode syscall (e.g. ptrace, mount) is refused
RestrictAddressFamilies= Only IPv4/6 + unix sockets No raw/packet sockets for a compromised worker

⚠️ ProtectSystem=strict will break nginx immediately if you forget a path it writes — the classic miss is the cache dir or the PID file, which fail with a permission error at start. Add exactly what it needs to ReadWritePaths and no more. If you want to also serve a non-standard doc root that nginx must read, it stays readable (strict only blocks writes). This is the same toolkit covered generally in the systemd lesson linked above — here we apply it to a real internet-facing service.

3. SELinux booleans (RHEL family)

On RHEL/Fedora/Rocky with SELinux enforcing, nginx runs in the httpd_t domain and is boxed by policy — which is great until it blocks something you actually need. The single most common surprise: SELinux forbids the web server from making outbound network connections by default, which is exactly what a reverse proxy does. The result is a 502 with a permission denial in the audit log and a perfectly correct nginx config. The fix is a boolean:

# allow nginx/httpd to open outbound connections (reverse proxy, FastCGI to a remote host)
sudo setsebool -P httpd_can_network_connect on

The booleans and contexts you will reach for:

Symptom SELinux fix What it does
Reverse proxy → 502, AVC denial setsebool -P httpd_can_network_connect on Let httpd/nginx make outbound TCP
Proxy to a DB port setsebool -P httpd_can_network_connect_db on Narrower: DB ports only
Serve from /srv/www (non-standard root) semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?" && restorecon -Rv /srv/www Label files so httpd_t may read them
Listen on a non-standard port (8443) semanage port -a -t http_port_t -p tcp 8443 Let nginx bind a port policy doesn’t know
Find why something was denied ausearch -m avc -ts recent / sealert -a /var/log/audit/audit.log Read + explain the AVC denial

The mandatory-access-control model behind these booleans — types, domains, transitions, and the AppArmor alternative on the Debian side — is the subject of the SELinux/AppArmor mandatory access control lesson; when a config that is obviously correct still returns 403 or 502 on a RHEL box, SELinux is the first suspect, and ausearch -m avc -ts recent is the first command.

4. Firewall: expose only 80 and 443

The box should accept inbound traffic on exactly two ports (plus SSH for you). Everything else — including your app’s 8000, which must stay on loopback — is closed:

# firewalld (RHEL/Fedora)
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload

# ufw (Ubuntu)
sudo ufw allow 'Nginx Full'        # opens 80 + 443; or: ufw allow 80,443/tcp

# nftables (raw)
sudo nft add rule inet filter input tcp dport {80,443} accept

Note the app port is deliberately absent — binding the app to 127.0.0.1:8000 means the firewall never even needs a rule for it, because it is not reachable off-box. The full firewall model — zones, firewalld vs nftables vs legacy iptables, and how the packet path works — is the firewalls: firewalld, nftables & iptables lesson; for the web tier the rule is simply “80 and 443 in, nothing else.”

Resilience & performance

A production edge does more than proxy — it protects the upstream and squeezes the wire. The high-value knobs:

Rate limiting and connection limits

Rate limiting is your cheapest defence against brute-force, scraping and accidental floods. It lives in two directives: a _zone (shared memory holding per-key counters, defined in http {}) and the limit_req that applies it (in a server/location):

http {
    # 10 MB zone keyed by client IP, allowing 10 requests/second sustained
    limit_req_zone  $binary_remote_addr  zone=api:10m  rate=10r/s;
    # cap concurrent connections per IP
    limit_conn_zone $binary_remote_addr  zone=conn:10m;
}

server {
    location /login {
        limit_req  zone=api  burst=20  nodelay;   # allow short bursts, then throttle
        limit_conn conn 10;                         # max 10 simultaneous conns/IP
        proxy_pass http://app_backend;
    }
}

burst=20 nodelay is the important nuance: it lets a legitimate user’s quick burst (a page loading 15 assets) through immediately, while a sustained flood above 10r/s gets 503. Without nodelay, bursts are queued and delayed rather than served fast then throttled. The rate-limiting directives:

Directive Where Purpose
limit_req_zone http Define a rate zone keyed on a variable
limit_req server/location Apply a rate limit + burst + nodelay
limit_conn_zone http Define a concurrent-connection zone
limit_conn server/location Cap simultaneous connections per key
limit_req_status http/server Status code returned when throttled (default 503)

Compression, keepalive and static caching

Feature Config Effect
gzip gzip on; gzip_types text/css application/json application/javascript; gzip_comp_level 5; 60–80% smaller text responses
brotli brotli on; brotli_types ...; (needs ngx_brotli module) Better ratio than gzip; not built-in
Client keepalive keepalive_timeout 65; Reuse client connections, skip handshakes
Upstream keepalive upstream {... keepalive 32; } + proxy_http_version 1.1; Reuse connections to the app
Static cache headers location /static/ { expires 30d; add_header Cache-Control "public, immutable"; } Browsers cache; fewer requests
Proxy cache proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=c:10m max_size=1g; proxy_cache c; nginx caches upstream responses
sendfile / tcp_nopush sendfile on; tcp_nopush on; Zero-copy file send, full packets

gzip is built in and free; brotli compresses better but requires the ngx_brotli module, which is not compiled into stock nginx — you either install a distro package that includes it or build the dynamic module. For text-heavy sites the extra 15–20% is worth it; for an API returning small JSON, gzip is plenty.

Worker and file-descriptor tuning

nginx’s throughput ceiling is worker_processes × worker_connections, and each connection costs file descriptors (one for the client, often one for the upstream). The defaults are conservative:

Directive Where Recommended Meaning
worker_processes main auto One worker per CPU core
worker_connections events 10244096 Max connections per worker
worker_rlimit_nofile main ≥ 2× worker_connections FD ceiling nginx sets for itself
LimitNOFILE systemd unit 65535 FD ceiling the supervisor grants the process
keepalive_requests http 1000 Requests per kept-alive connection

The trap: worker_connections 8192; is meaningless if the process can only open 1024 file descriptors. The FD ceiling is set in two places and both must agree — worker_rlimit_nofile inside nginx and LimitNOFILE in the systemd unit (systemd’s limit wins if lower). Under load you hit “too many open files” in error.log, connections start failing, and the fix is to raise both and reload. This is the single most common performance cliff for a busy proxy.

A health-check endpoint

Give load balancers and monitors a cheap endpoint that proves nginx is up without waking the app:

location = /healthz {
    access_log off;                 # don't log every probe
    return 200 "ok\n";
    add_header Content-Type text/plain;
}

Use = /healthz (exact match) so it short-circuits instantly and never gets caught by a proxy location /. A deeper check that also verifies the app is reachable would proxy_pass to an app health route instead.

App integration: gunicorn, php-fpm, node — socket vs TCP

The upstream nginx proxies to is your application server. How it binds — a unix socket or a TCP port on loopback — is a real decision with security and performance consequences.

Unix socket (unix:/run/app.sock): the app and nginx are on the same host; the socket is a filesystem object, so access is gated by file permissions, and it skips the TCP/IP stack entirely (lower latency, no loopback overhead). The socket must be readable and writable by the nginx user — the number-one 502 after a redeploy is a socket recreated with the wrong owner or mode.

TCP on loopback (127.0.0.1:8000): simpler, works when the app is on a different host (just change the IP), and easier to probe with curl. Slightly more overhead and no file-permission gate — anything on the box that can reach loopback can reach the app, so you rely on the firewall/binding, not file mode.

Aspect Unix socket TCP loopback
Scope Same host only Same host, or across hosts
Access control File permissions (owner/group/mode) Bind address + firewall
Overhead Lowest (no IP stack) Slightly higher
Debuggability curl --unix-socket /run/app.sock curl http://127.0.0.1:8000
Common failure 502 “permission denied” on socket App bound 0.0.0.0 and exposed
Best for Single-box app + nginx Multi-host, or quick local testing

The wiring for each stack — the app command and the matching nginx block:

App server Bind command nginx directive
gunicorn (Python/WSGI) gunicorn --workers 4 --bind unix:/run/gunicorn.sock app:app proxy_pass http://unix:/run/gunicorn.sock;
uwsgi (Python) uwsgi --socket /run/uwsgi.sock --module app:app uwsgi_pass unix:/run/uwsgi.sock; + include uwsgi_params;
php-fpm (PHP) listens on /run/php/php8.2-fpm.sock (pool config) fastcgi_pass unix:/run/php/php8.2-fpm.sock; + include fastcgi_params;
Node (Express/etc.) app.listen(3000, '127.0.0.1') proxy_pass http://127.0.0.1:3000;

A full PHP location block (the pattern for any FastCGI app) shows the extra params FastCGI needs versus a plain HTTP proxy:

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_pass  unix:/run/php/php8.2-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_index index.php;
}

Let nginx serve static, never the app

The single biggest performance win in a web app is to serve static and media files directly from nginx and never let the request reach your Python/PHP workers. Your app workers are precious (there are four of them); nginx serves a CSS file in microseconds without waking one:

location /static/ { alias /srv/app/static/; expires 30d; access_log off; }
location /media/  { alias /srv/app/media/;  expires 7d; }
location /        { proxy_pass http://app_backend; }   # only dynamic requests reach the app

alias maps the URL prefix to a disk path; root would append the whole URL to the path — a common mix-up that yields 404s. Use alias when the URL prefix and directory name differ, root when they match.

Observability: logs, log format, goaccess & stub_status

When the pager goes off, logs are the truth. nginx writes two:

log_format detailed '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent rt=$request_time '
                    'uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time '
                    '"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log detailed;

The variables that earn their place in a proxy log:

Variable Meaning
$status HTTP status returned to the client
$request_time Total time nginx spent on the request (client-visible)
$upstream_response_time Time the upstream app took — isolates slow app vs slow client
$upstream_connect_time Time to connect to the upstream (spikes = upstream saturated)
$upstream_addr Which backend served it (debugging load balancing)
$body_bytes_sent Response size (spot huge/tiny anomalies)

The $request_time vs $upstream_response_time split is diagnostic gold: if request time is high but upstream time is low, the client is slow (or you’re not buffering); if both are high, the app is slow — go look at the database.

Two tools turn logs into insight:

Tool Command Gives you
stub_status location = /nginx_status { stub_status; allow 127.0.0.1; deny all; } Live counters: active connections, accepts, handled, requests, reading/writing/waiting
goaccess goaccess /var/log/nginx/access.log --log-format=COMBINED Real-time terminal/HTML dashboard: top URLs, status codes, bandwidth, visitors

stub_status is the pulse — a few integers you can scrape into Prometheus; goaccess is the autopsy — point it at the access log and get an instant dashboard of your top URLs, error rates and traffic sources without shipping logs anywhere. Lock stub_status to localhost (allow 127.0.0.1; deny all;) so you don’t leak internal metrics.

Hands-on lab

Build the whole stack on one Ubuntu or Rocky VM (or WSL/container with systemd): nginx terminating TLS-style config, reverse-proxying to a gunicorn app over a unix socket, rate-limited, with a hardened systemd unit. Where a real domain and public cert are needed, we note the exact command and use a self-signed cert so the lab runs fully offline.

Step 1 — install nginx and a tiny app.

sudo apt update && sudo apt install -y nginx python3-pip python3-venv    # or: dnf install nginx python3-pip
python3 -m venv ~/appenv && ~/appenv/bin/pip install flask gunicorn
mkdir -p ~/app && cat > ~/app/wsgi.py <<'PY'
from flask import Flask, request
app = Flask(__name__)
@app.get("/")
def home(): return f"Hello from the app. You are {request.headers.get('X-Forwarded-For','?')}\n"
@app.get("/healthz")
def health(): return "ok\n"
PY

What just happened: a minimal Flask app that echoes the X-Forwarded-For header — so you can see whether nginx forwarded the real client IP.

Step 2 — run gunicorn on a unix socket, under systemd.

sudo tee /etc/systemd/system/myapp.service >/dev/null <<'UNIT'
[Unit]
Description=Demo gunicorn app
After=network.target

[Service]
User=www-data
WorkingDirectory=/home/%i/app
RuntimeDirectory=myapp
ExecStart=/home/YOURUSER/appenv/bin/gunicorn --workers 2 --bind unix:/run/myapp/app.sock wsgi:app
Restart=on-failure

[Install]
WantedBy=multi-user.target
UNIT
sudo sed -i "s/YOURUSER/$USER/; s/%i/$USER/" /etc/systemd/system/myapp.service
sudo systemctl daemon-reload && sudo systemctl enable --now myapp
ls -l /run/myapp/app.sock          # socket exists, owned by www-data

What just happened: gunicorn runs as www-data and binds a unix socket under /run/myapp/ (created by RuntimeDirectory). The app is not listening on any TCP port — it is unreachable except through the socket.

Step 3 — a self-signed cert (stand-in for Let’s Encrypt).

sudo mkdir -p /etc/nginx/ssl
sudo openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
  -keyout /etc/nginx/ssl/lab.key -out /etc/nginx/ssl/lab.crt \
  -subj "/CN=lab.local"

What just happened: a throwaway cert so the TLS block works offline. ⚠️ In production you never do this — you run sudo certbot --nginx -d yourdomain.com and get a real, browser-trusted cert. The nginx TLS directives are identical either way; only the cert paths change.

Step 4 — the nginx site: TLS, redirect, proxy, rate limit, headers.

sudo tee /etc/nginx/conf.d/myapp.conf >/dev/null <<'NGINX'
limit_req_zone $binary_remote_addr zone=lab:10m rate=5r/s;
upstream app { server unix:/run/myapp/app.sock; }

server {                                   # :80 → redirect to HTTPS
    listen 80; server_name lab.local;
    location / { return 301 https://$host$request_uri; }
}
server {                                   # :443 → the site
    listen 443 ssl; http2 on; server_name lab.local;
    ssl_certificate     /etc/nginx/ssl/lab.crt;
    ssl_certificate_key /etc/nginx/ssl/lab.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

    location = /healthz { proxy_pass http://app; access_log off; }
    location / {
        limit_req zone=lab burst=10 nodelay;
        proxy_pass http://app;
        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;
    }
}
NGINX
sudo nginx -t && sudo systemctl reload nginx

What just happened: one file wires the whole edge — 80 redirects to 443, 443 terminates TLS, / proxies to the app over the socket with correct forwarded headers and a 5r/s rate limit. nginx -t proved it valid before reload.

Step 5 — prove it end to end.

curl -kI  http://lab.local  --resolve lab.local:80:127.0.0.1     # expect 301 → https
curl -k   https://lab.local --resolve lab.local:443:127.0.0.1    # expect the app's hello + your IP
curl -k   https://lab.local/healthz --resolve lab.local:443:127.0.0.1   # expect: ok

What just happened: -k accepts the self-signed cert; --resolve fakes DNS for lab.local. The first call shows the HTTP→HTTPS redirect; the second shows the app response with X-Forwarded-For populated — proof the header plumbing works.

Step 6 — trip the rate limit on purpose.

for i in $(seq 1 30); do
  curl -k -s -o /dev/null -w "%{http_code} " https://lab.local --resolve lab.local:443:127.0.0.1
done; echo

What just happened: firing 30 requests fast, you see a run of 200s then 503s as limit_req throttles you above 5r/s. That is your login page surviving a brute-force.

Step 7 — harden the nginx unit and score it.

sudo systemctl edit nginx    # paste the [Service] override from the hardening section
sudo systemctl daemon-reload && sudo systemctl restart nginx
systemd-analyze security nginx | tail -5     # exposure score should drop toward "OK"
curl -k https://lab.local --resolve lab.local:443:127.0.0.1   # still serving — sandbox didn't break it

What just happened: the same nginx now runs inside a ProtectSystem=strict sandbox and still serves traffic. You have the full production shape: TLS edge → rate-limited reverse proxy → private-socket app → hardened supervisor.

Cleanup: sudo systemctl disable --now nginx myapp; sudo rm /etc/nginx/conf.d/myapp.conf /etc/systemd/system/myapp.service; sudo systemctl daemon-reload.

Common mistakes and troubleshooting

The web tier fails in a small number of very recognisable ways. Learn to read the pair of symptom (what the client sees) and evidence (what the error log says), and diagnosis is fast:

Symptom Likely cause Confirm with Fix
502 Bad Gateway App down, or nginx can’t reach the socket/port error.log: connect() failed (111: Connection refused) or (13: Permission denied) Start the app; fix socket owner/mode; open the loopback port
502 on RHEL, config looks perfect SELinux blocks outbound connect ausearch -m avc -ts recent shows name_connect denial setsebool -P httpd_can_network_connect on
504 Gateway Timeout Upstream slower than proxy_read_timeout $upstream_response_time near/over 60s Speed up the app/query; raise proxy_read_timeout for that location
Cert renewal fails Port 80 blocked, or webroot path wrong certbot renew --dry-run error; :80 not reachable Open 80 for ACME; fix -w webroot; check the renewal timer is active
403 Forbidden Doc-root perms, missing index, or SELinux label error.log: Permission denied / directory index forbidden Fix perms; add index; restorecon / set httpd_sys_content_t
“too many open files” FD ceiling too low for the load error.log: worker ... too many open files Raise worker_rlimit_nofile and LimitNOFILE, reload
App sees every client as 127.0.0.1 Missing/untrusted X-Forwarded-For App logs show one IP for everyone Set the header in nginx; configure the app to trust it
Redirect loop (ERR_TOO_MANY_REDIRECTS) App builds http:// URLs behind HTTPS proxy App redirects to http, nginx bounces back to https Send X-Forwarded-Proto $scheme and tell the app to honour it
Default “Welcome to nginx” page Your site config isn’t enabled/matched nginx -T doesn’t show your server block Enable the site (symlink/conf.d), correct server_name, reload
nginx -t fails after edit Syntax error (missing ;, bad path) nginx -t prints file:line Fix the exact line it names; test again before reload

Three gotchas deserve prose because they cost the most hours:

The socket-permission 502. You deploy, gunicorn recreates its socket, and suddenly every request is 502 with (13: Permission denied) in the error log. The socket exists — but it is owned by the app’s user with a mode nginx (www-data/nginx) cannot access. The robust fixes are: run the app under the same group as nginx and set the socket mode to 660 with a matching umask, or use systemd’s RuntimeDirectory= with correct ownership. The reason this bites after a deploy is that the socket is recreated on every app restart — a permission that was right yesterday is wrong today because a redeploy changed the app’s umask. Always check ls -l on the socket when you see a permission-denied 502.

The SELinux 502 that makes you doubt your sanity. On RHEL, you write a flawless reverse-proxy config, nginx -t passes, the app is up and healthy, and every request still returns 502. Nothing in the nginx error log explains it beyond a generic connect failure. The cause is that SELinux, enforcing by default, does not permit the web server to make outbound network connections — the exact thing a reverse proxy does — until you flip httpd_can_network_connect. The tell is in the audit log, not the nginx log: ausearch -m avc -ts recent shows a name_connect denial against httpd_t. One setsebool -P httpd_can_network_connect on fixes it permanently. This single boolean is responsible for an enormous share of “nginx reverse proxy 502 on RHEL/CentOS” support threads.

The 504 that is really the database. A 504 at nginx feels like an nginx problem, but nginx is only reporting that the upstream did not answer in time. Nine times in ten the upstream is blocked on a slow database query. The $upstream_response_time in your access log confirms it — if that number is 30+ seconds, the app is waiting on something downstream, and raising proxy_read_timeout only papers over it. The real fix is upstream: index the query, add a connection pool, or cache — the province of database tuning, not the proxy. Read the two numbers together ($request_time and $upstream_response_time) and you instantly know whether to look at nginx, the app, or the database.

Cheat-sheet

Everything you will reach for, in one place.

Task Command / directive
Test config nginx -t
Show full effective config nginx -T
Graceful reload systemctl reload nginx / nginx -s reload
Reopen logs after rotation nginx -s reopen
Install (Debian / RHEL) apt install nginx / dnf install nginx
Doc root default /var/www/html (Deb) · /usr/share/nginx/html (RHEL)
Worker user default www-data (Deb) · nginx (RHEL)
Get a cert (nginx plugin) certbot --nginx -d example.com -d www.example.com
Get a wildcard cert certbot certonly --manual --preferred-challenges dns -d '*.example.com'
Test renewal certbot renew --dry-run
Renewal timer status systemctl list-timers | grep certbot
Reverse proxy proxy_pass http://127.0.0.1:8000;
Proxy to socket proxy_pass http://unix:/run/app.sock;
Forward client identity proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Forward scheme proxy_set_header X-Forwarded-Proto $scheme;
WebSocket upgrade proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
HTTP → HTTPS return 301 https://$host$request_uri;
HSTS add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
Modern protocols ssl_protocols TLSv1.2 TLSv1.3;
Rate limit (zone) limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
Rate limit (apply) limit_req zone=api burst=20 nodelay;
gzip gzip on; gzip_types application/json text/css;
Serve static directly location /static/ { alias /srv/app/static/; expires 30d; }
Health endpoint location = /healthz { return 200 "ok\n"; }
Live status location = /nginx_status { stub_status; allow 127.0.0.1; deny all; }
Log dashboard goaccess /var/log/nginx/access.log --log-format=COMBINED
Test TLS openssl s_client -connect example.com:443 -servername example.com
SELinux outbound (RHEL) setsebool -P httpd_can_network_connect on
Read SELinux denial ausearch -m avc -ts recent
Open firewall (firewalld) firewall-cmd --permanent --add-service={http,https}; firewall-cmd --reload
Harden the unit systemctl edit nginxProtectSystem=strict, PrivateTmp=true, NoNewPrivileges=true
Score the unit systemd-analyze security nginx
Raise FD limit worker_rlimit_nofile 65535; + LimitNOFILE=65535
Tail errors live tail -f /var/log/nginx/error.log

Interview and exam questions

Q: What is a reverse proxy, and why put one in front of your application server? A: A reverse proxy accepts client requests and forwards them to a backend, relaying the response — so clients only ever talk to the proxy. You put nginx in front of an app to terminate TLS in one place, serve static files without waking app workers, buffer slow clients so they don’t tie up expensive workers, rate-limit abuse at the cheap edge, and hide the app on loopback so it is never directly exposed. The app’s own dev server should never face the internet.

Q: A reverse-proxy config returns 502 on RHEL but is byte-for-byte correct. First thing you check? A: SELinux. Enforcing by default, it forbids the web server (httpd_t) from making outbound network connections — exactly what a reverse proxy does. Confirm with ausearch -m avc -ts recent (a name_connect denial) and fix with setsebool -P httpd_can_network_connect on. The denial is in the audit log, not the nginx error log, which is why it’s baffling.

Q: Difference between 502 and 504 from nginx? A: 502 Bad Gateway means nginx reached the upstream but got a broken or refused answer (app down, wrong port, socket permission denied). 504 Gateway Timeout means nginx connected but the upstream didn’t respond within proxy_read_timeout (default 60s) — usually a slow app or slow database query. 502 = “bad/no answer”; 504 = “no answer in time.”

Q: Why must you set X-Forwarded-For and X-Forwarded-Proto, and what’s the risk of trusting them blindly? A: Behind a proxy the app sees 127.0.0.1 over HTTP for every request. X-Forwarded-For carries the real client IP; X-Forwarded-Proto tells the app the edge was HTTPS (preventing http:// redirect loops). The risk: these headers are client-settable, so the app must trust them only from the proxy’s IP (e.g. gunicorn --forwarded-allow-ips, Django SECURE_PROXY_SSL_HEADER), or an attacker forges their source IP past rate limits and geo-blocks.

Q: How does Let’s Encrypt auto-renewal work, and how do you verify it? A: Certs last 90 days; certbot’s package installs a systemd timer (certbot.timer) that runs certbot renew twice daily, which renews any cert within ~30 days of expiry and does nothing otherwise. Verify with systemctl list-timers | grep certbot and, critically, certbot renew --dry-run. Add --deploy-hook "systemctl reload nginx" so nginx picks up the new cert. A silently-dead timer is how certs expire on a weekend.

Q: You need a certificate for *.example.com. Which ACME challenge, and why? A: DNS-01. HTTP-01 (webroot/standalone) proves control by serving a file over port 80 for a specific hostname — you cannot do that for infinite subdomains. DNS-01 proves control by publishing a _acme-challenge TXT record, which covers the wildcard. Use a DNS-provider plugin (e.g. certbot-dns-cloudflare) so renewal stays automatic instead of manual TXT edits.

Q: What does ProtectSystem=strict do to an nginx unit, and what breaks if you misconfigure it? A: It makes the entire filesystem read-only for the process except paths you list in ReadWritePaths. For nginx you must whitelist /var/log/nginx, the cache dir, and the runtime/PID dir, or nginx fails to start with a permission error. The payoff: an nginx RCE can’t rewrite /etc, plant a cron job, or drop a binary — the compromise is jailed. Score the result with systemd-analyze security nginx.

Q: Socket vs TCP for the upstream — trade-offs? A: A unix socket (unix:/run/app.sock) works only same-host, is gated by file permissions, and skips the IP stack for lower latency. TCP loopback (127.0.0.1:8000) works across hosts, is easier to curl, but has no file-permission gate. The classic socket failure is a 502 “permission denied” after a redeploy recreated the socket with the wrong owner; the classic TCP failure is binding 0.0.0.0 and exposing the app.

Q: How is nginx’s location chosen? Give the priority order. A: Not top-to-bottom. Exact (=) wins immediately; otherwise nginx notes the longest matching prefix — if it used ^~ it stops there, else it tries regex locations (~/~*) in file order and the first match wins; failing all regex, it falls back to the longest prefix. Use = /healthz for exact paths and ^~ /static/ to short-circuit the regex search.

Q: (RHCSA/LFCS-style) Deploy a static site on RHEL served by nginx at /srv/www, on the firewall, with SELinux enforcing. Steps? A: dnf install nginx; put files in /srv/www; set the SELinux context: semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?" && restorecon -Rv /srv/www; point root /srv/www; in a server block; nginx -t && systemctl enable --now nginx; open the firewall: firewall-cmd --permanent --add-service={http,https} && firewall-cmd --reload. Without the SELinux relabel you get 403; without the firewall the port is filtered.

Q: (Practical) error.log shows “too many open files” under load. Diagnose and fix. A: nginx hit its file-descriptor ceiling — each connection costs FDs. Raise it in both places that cap it: worker_rlimit_nofile 65535; in nginx’s main context and LimitNOFILE=65535 in the systemd unit (systemd’s limit wins if lower). systemctl daemon-reload, restart nginx. Also sanity-check that worker_connections isn’t set higher than the FD limit allows.

Q: Why serve /static/ from nginx instead of proxying it to the app? A: App workers are scarce and expensive (a handful of processes); nginx serves a file from disk in microseconds without waking one. Proxying static assets to gunicorn/php-fpm wastes a worker per CSS file and cripples throughput. Map location /static/ { alias /srv/app/static/; expires 30d; } so only dynamic requests reach the app.

Key takeaways

linuxnginxapachereverse-proxytlslets-encryptcertbotsystemdhardeningrate-limitinggunicornphp-fpmselinuxweb-server
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments