This checklist moves operators from an observed problem to a verified result across bare-metal (systemd), Docker, and Kubernetes NGINX Ingress Controller topologies. Every step captures state first, limits blast radius, uses explicit placeholders, verifies the outcome, and documents a tested rollback. Supported Nginx versions: 1.20.x–1.26.x (stable and mainline as of 2024). NGINX Plus-only features are called out where they differ from OSS.
Prerequisites & Assumptions
Before any operation, confirm the following baseline. Missing prerequisites are the most common cause of failed reloads and silent misconfigurations.
Nginx version range and nginx -V interpretation Run nginx -V (capital V) to print the configure arguments. The output reveals the OpenSSL version, PCRE, zlib, and dynamic module paths. Example annotated output for Nginx 1.24.0 on Ubuntu 22.04:
nginx version: nginx/1.24.0
built by gcc 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04)
built with OpenSSL 3.0.2 15 Mar 2022
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx \
--modules-path=/usr/lib/nginx/modules --conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid \
--lock-path=/var/lock/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp \
--http-proxy-temp-path=/var/cache/nginx/proxy_temp \
--http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \
--http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \
--http-scgi-temp-path=/var/cache/nginx/scgi_temp \
--with-http_ssl_module --with-http_v2_module --with-http_realip_module \
--with-http_stub_status_module --with-http_gzip_static_module \
--with-threads --with-file-aio --with-http_v3_module \
--with-cc-opt='-g -O2 -ffile-prefix-map=/build/nginx-... -fstack-protector-strong \
-Wformat -Werror=format-security -fPIC -Wdate-time -D_FORTIFY_SOURCE=2' \
--with-ld-opt='-Wl,-Bsymbolic-functions -Wl,-z,relro -Wl,-z,now -fPIC' \
--add-dynamic-module=../ngx_http_geoip2_module
Key fields to record: nginx version, built with OpenSSL, configure arguments (especially --with-http_v3_module for QUIC/HTTP3, --with-http_stub_status_module for metrics, dynamic module paths).
Deployment topology matrix
- Topology — Manager — Config Source — Reload Mechanism — Version Mapping
- Bare metal — systemd (
nginx.service) —/etc/nginx/files —systemctl reload nginx/nginx -s reload— Package version (e.g.,nginx=1.24.0-1~jammy) - Docker (official) — Container runtime — Bind-mounted
/etc/nginx/or baked image —docker kill -s HUP <container>ordocker exec <container> nginx -s reload— Image tagnginx:1.24-alpineembeds that Nginx version - Kubernetes Ingress Controller — Deployment + ConfigMap — ConfigMap
nginx-configuration+ TLS Secrets —kubectl rollout restart deployment/ingress-nginx-controller -n ingress-nginx— Controller v1.9.x embeds Nginx 1.25.x; checkkubectl describe podfor--nginx-version
Required tools nginx (binary in PATH), systemctl / docker / kubectl, openssl (>= 1.1.1 for TLS 1.3), curl (with --resolve support), jq, ss or netstat, audit2allow (SELinux), certbot (for Let's Encrypt scenario).
Permissions and capabilities
- Sudo for config write (
/etc/nginx/) and reload (systemctl reload nginx). - Read access to
/var/log/nginx/(or container log driver). - Non-root port 80/443: grant
CAP_NET_BIND_SERVICEto the Nginx binary (setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx) or run master as root with workers dropping privileges (user nginx;innginx.conf). - Docker: container must run with
--cap-add=NET_BIND_SERVICEif not root. - Kubernetes: Ingress Controller pod typically runs as root (hostNetwork) or with
NET_BIND_SERVICEcapability.
Security baseline (apply before any change)
- TLS 1.2+ only:
ssl_protocols TLSv1.2 TLSv1.3; - Strong ciphers:
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:DHE-RSA-AES256-GCM-SHA384'; - HSTS:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;(only on HTTPS server blocks). server_tokens off;inhttpblock.- Rate limiting zones defined in
http:limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
Architecture context Nginx uses a master/worker process model. The master reads configuration, manages worker processes, and handles signals. Workers share memory zones (SSL session cache, rate limit counters, upstream health state). Cache loader/manager processes populate disk cache on startup. Graceful reload (nginx -s reload / SIGHUP) starts new workers with new config while old workers drain existing connections. Binary upgrade (kill -USR2 master) replaces the executable without dropping connections—requires master_process on; and pid file writable.
Version & Environment Inventory
Capture a complete, timestamped snapshot before any change. Run all commands as read-only; they must not mutate state.
Commands and expected output
- Version and module inventory
nginx -v
nginx -V 2>&1 | head -20
Expected: version line (e.g., nginx version: nginx/1.24.0) and configure arguments as shown in Prerequisites. Failure signal: non-zero exit, nginx: command not found, or missing expected modules (e.g., --with-http_v3_module absent when HTTP/3 required).
- Full configuration dump with syntax test
nginx -T 2>&1 | tee /tmp/nginx-inventory-$(date +%F_%H%M%S).txt
Expected: concatenated config from nginx.conf and all include files, preceded by nginx: the configuration file /etc/nginx/nginx.conf syntax is ok and nginx: configuration file /etc/nginx/nginx.conf test is successful. Failure signal: nginx: [emerg] ... syntax error, missing included file, or permission denied reading a config file. Capture the exact line number and file.
- Process and resource limits
systemctl status nginx --no-pager
# or Docker
docker inspect --format '{{.State.Pid}} {{.HostConfig.CapAdd}}' <container>
# or Kubernetes
kubectl describe pod -n ingress-nginx -l app.kubernetes.io/component=controller | head -40
ps auxf | grep nginx
ulimit -n
cat /proc/$(cat /var/run/nginx.pid)/limits 2>/dev/null | grep 'Max open files'
Expected: master PID, worker count matching worker_processes (or auto), open file limit >= worker_connections worker_processes 2. Failure signal: worker count mismatch, ulimit -n < 1024, SELinux/AppArmor denials in dmesg or audit.log.
Extract from nginx -T output or nginx -V configure arguments: error_log, access_log, pid file. Verify directory permissions: ls -ld /var/log/nginx /var/run/nginx.pid.
- Log and pid paths
Annotated nginx -T truncated example (shows include hierarchy):
# /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Security baseline
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256...';
# Rate limit zone
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# Upstream block
upstream app_backend {
least_conn;
server 192.0.2.10:8080 max_fails=3 fail_timeout=30s;
server 192.0.2.11:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
# /etc/nginx/sites-enabled/app.example.com.conf
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/app.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/app.example.com/privkey.pem;
ssl_trusted_certificate /etc/nginx/ssl/app.example.com/chain.pem;
ssl_stapling on;
ssl_stapling_verify on;
location /healthz {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app_backend;
proxy_read_timeout 60s;
proxy_set_header Host $host;
}
}
Safe Configuration Path
Follow this exact workflow for every configuration change. Never edit in place without a timestamped backup.
Workflow
- Snapshot current config
BACKUP_DIR="/etc/nginx.bak.$(date +%F_%H%M%S)"
cp -a /etc/nginx "$BACKUP_DIR"
echo "Backup saved to $BACKUP_DIR"
Example: increase proxy_read_timeout for /api/ only.
- Edit a single scoped block
# Edit the specific file
vim /etc/nginx/sites-enabled/app.example.com.conf
Change:
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app_backend;
+ proxy_read_timeout 60s;
proxy_set_header Host $host;
}
- Syntax test
nginx -t
Expected success:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Failure example (missing semicolon):
nginx: [emerg] invalid parameter "proxy_read_timeout 60s" in /etc/nginx/sites-enabled/app.example.com.conf:27
nginx: configuration file /etc/nginx/nginx.conf test failed
Do not proceed until nginx -t passes.
- Diff against backup
nginx -T 2>&1 | grep -v '^nginx:' | diff -u "$BACKUP_DIR/nginx.conf" -
Verify only the intended lines changed.
- Graceful reload
# Bare metal
systemctl reload nginx
# or
nginx -s reload
# Docker
docker exec <container> nginx -s reload
# Kubernetes (ConfigMap change)
kubectl rollout restart deployment/ingress-nginx-controller -n ingress-nginx
- Post-reload verification
# Confirm new config active
nginx -T 2>&1 | grep -A2 'proxy_read_timeout 60s'
# Health check
curl -v --resolve app.example.com:443:192.0.2.100 https://app.example.com/healthz
Expected: 200 OK, Server: nginx header, no 5xx in access log for the request.
Version-specific gotchas
- Dynamic modules:
load_module /usr/lib/nginx/modules/ngx_http_geoip2_module.so;must appear beforeeventsblock. Path changes across package versions. ssl_certificate/ssl_certificate_keymust be readable by the worker user (nginxorwww-data). Test:sudo -u nginx cat /etc/nginx/ssl/app.example.com/privkey.pem >/dev/null.includedirective ordering matters: later files override earlier ones for same directive in same context.- Kubernetes Ingress Controller: ConfigMap changes require controller pod restart;
nginx -s reloadinside the pod does not persist.
Blast radius control
- Change one
serverorlocationblock at a time. - Avoid global
httpblock changes (e.g.,ssl_ciphers) without canary: deploy to one node, verify, then roll out. - Use
nginx -Tdiff to confirm scope.
Rollback procedure
# Restore backup and reload
mv /etc/nginx /etc/nginx.failed.$(date +%F_%H%M%S)
mv "$BACKUP_DIR" /etc/nginx
nginx -t && systemctl reload nginx
# Verify old config active
nginx -T 2>&1 | grep -c 'proxy_read_timeout 60s' # should be 0
Verification & Diagnostics
Use these checks to confirm healthy operation and diagnose anomalies. Each produces an observable signal.
Health checks
# Synthetic request with SNI and forced IP (bypasses DNS)
curl -v --resolve app.example.com:443:192.0.2.100 https://app.example.com/healthz
Expected: HTTP/2 200, Server: nginx, response body OK. Failure: non-2xx, curl: (56) Recv failure: Connection reset by peer, TLS handshake failure.
# Process health
systemctl is-active nginx # returns "active"
ps auxf | grep nginx # master + N workers, same master PID as pid file
Metrics collection Enable stub_status in a protected location:
location /nginx_status {
stub_status on;
allow 192.0.2.0/24; # monitoring subnet
deny all;
}
Scrape:
curl -s http://192.0.2.100/nginx_status
Output:
Active connections: 43
server accepts handled requests
12345 12345 67890
Reading: 6 Writing: 1 Waiting: 36
Prometheus exporter (sidecar or DaemonSet): nginx-prometheus-exporter scrapes /nginx_status and exposes /metrics.
Access log latency analysis
# 95th percentile request time and upstream time (last 1000 lines)
awk '{print $NF, $(NF-1)}' /var/log/nginx/access.log | tail -1000 | \
sort -n | awk '{a[NR]=$1} END {print "p95 request_time:", a[int(NR*0.95)]}'
Log format must include $request_time and $upstream_response_time.
TLS validation
openssl s_client -connect app.example.com:443 -servername app.example.com -verify_return_error < /dev/null
Expected: Verify return code: 0 (ok), Protocol: TLSv1.3, Cipher: TLS_AES_256_GCM_SHA384, cert notAfter > 30 days, OCSP stapling response present. Failure: verify error:num=10:certificate has expired, verify error:num=20:unable to get local issuer certificate, no OCSP response.
Worker health
# Worker count vs configured
grep 'worker_processes' /etc/nginx/nginx.conf
ps -eo pid,ppid,cmd | grep nginx | grep -v grep | wc -l
Expected: worker count == worker_processes (or CPU cores if auto). Failure: fewer workers (OOM kill, segfault), master PID changed unexpectedly (unplanned reload/restart).
Log correlation
# Recent errors/warnings
grep -E 'error|warn|alert|emerg' /var/log/nginx/error.log | tail -20
# 5xx responses in access log
awk '$9 ~ /^5[0-9]{2}$/' /var/log/nginx/access.log | tail -20
Failure signals: connect() failed (111: Connection refused) while connecting to upstream, upstream timed out (110: Connection timed out), SSL_do_handshake() failed, worker process exited on signal 11 (SIGSEGV).
Failure Modes & Recovery
Each failure mode includes detection, root cause isolation, and a tested recovery path.
1. Config syntax error on reload
- Detection:
nginx -tfails;systemctl reload nginxreturns error; new workers do not start. - Recovery: Do not reload. Fix syntax in edited file. Run
nginx -tuntil clean. Then reload. - Rollback: If already reloaded and old workers gone, restore backup (see Safe Configuration Path rollback).
2. TLS cert/key mismatch or expired
- Detection:
openssl s_clientshowsverify error:num=10oropenssl x509 -noout -modulus -in cert.pem | openssl md5!= same for key. - Recovery: Replace cert/key pair atomically (write new files, then
mvinto place). Ensure permissions:chmod 640 /etc/nginx/ssl/app.example.com/privkey.pem; chown root:nginx /etc/nginx/ssl/app.example.com/privkey.pem. Runnginx -tthen reload. - Rollback: Keep previous cert/key in
/etc/nginx/ssl/app.example.com/prev/; swap back on failure.
3. Upstream unavailable (502 Bad Gateway)
- Detection:
error.logshowsconnect() failed (111: Connection refused) while connecting to upstream, access log shows 502. - Root cause isolation:
- Check
proxy_passDNS:resolver 127.0.0.11 valid=10s;(Docker) or explicitresolver 8.8.8.8;+set $upstream http://app_backend; proxy_pass $upstream;for dynamic resolution. - Verify upstream health:
curl -v http://192.0.2.10:8080/healthzfrom Nginx host. - Firewall/Security Groups:
ss -tlnp | grep :8080on upstream;iptables -L -n/ cloud SG rules. - Recovery: Fix upstream or DNS; Nginx re-resolves on next request (with
resolver). - OSS note:
health_checkinmatchblock is NGINX Plus only. OSS usesproxy_next_upstream error timeout http_502 http_503 http_504;+ external active checks (e.g.,nginx-upstream-check-moduleor sidecar).
4. Worker OOM / too many open files
- Detection:
error.logshowsaccept() failed (24: Too many open files),worker process exited on signal 9 (SIGKILL)(OOM killer).dmesg | grep -i kill. - Recovery:
- Raise limits:
ulimit -n 65535in systemd override (LimitNOFILE=65535),worker_rlimit_nofile 65535;inmaincontext,worker_connections 4096;inevents. - Math:
worker_rlimit_nofile >= worker_connections * 2(listening + upstream sockets). - Restart required (not reload) after limit changes:
systemctl restart nginx. - Rollback: Revert limits, restart.
5. Binary upgrade failure
- Procedure (zero-downtime binary replacement):
# 1. Start new master with new binary (USR2)
kill -USR2 $(cat /var/run/nginx.pid)
# 2. Verify new master PID in /var/run/nginx.pid.newbin
sleep 2
# 3. Gracefully shut down old workers (WINCH)
kill -WINCH $(cat /var/run/nginx.pid.oldbin)
# 4. Wait for old workers to drain (monitor connections)
# 5. If new master healthy, quit old master (QUIT)
kill -QUIT $(cat /var/run/nginx.pid.oldbin)
- Rollback (if new master unhealthy):
kill -HUP $(cat /var/run/nginx.pid.oldbin) # old master reaps workers, resumes
kill -QUIT $(cat /var/run/nginx.pid) # quit new master
6. SELinux/AppArmor denial
- Detection:
audit.logshowsavc: denied { read } for pid=... comm="nginx" path="/etc/nginx/ssl/...". - Recovery:
grep nginx /var/log/audit/audit.log | audit2allow -M nginx_local
semodule -i nginx_local.pp
- Verify:
nginx -t && systemctl reload nginx.
Consolidated Operations Checklist
- Phase — Action — Command — Expected Signal — Failure Signal — Rollback/Recovery — Blast Radius — Frequency
- Inventory — Capture version & modules —
nginx -V 2>&1 \| tee /tmp/inv-\$(date +%F).txt— OpenSSL 1.1.1+, required modules listed — Missing--with-http_v3_module, non-zero exit — N/A (read-only) — None — Weekly / pre-change - Inventory — Dump full config —
nginx -T 2>&1 \| tee /tmp/dump-\$(date +%F).txt— Syntax ok, all includes resolved —[emerg]syntax error, missing file — N/A — None — Weekly / pre-change - Inventory — Check process & limits —
ps auxf \| grep nginx; cat /proc/\$(cat /var/run/nginx.pid)/limits— Worker count matches,Max open files>= 65535 — Worker count mismatch, low ulimit — Investigate systemdLimitNOFILE— None — Daily - Config — Timestamped backup —
cp -a /etc/nginx /etc/nginx.bak.\$(date +%F_%H%M%S)— Directory created — Permission denied — N/A — Config dir — Pre-change - Config — Edit single block —
vim /etc/nginx/sites-enabled/app.example.com.conf— File saved — Syntax error on save — Discard changes — One location — Per change - Config — Syntax test —
nginx -t—syntax is ok,test is successful—[emerg]with line/file — Fix syntax, retest — None — Per change - Config — Diff vs backup —
nginx -T \| grep -v '^nginx:' \| diff -u /etc/nginx.bak.../nginx.conf -— Only intended lines changed — Unexpected diffs — Restore backup, re-edit — Config scope — Per change - Config — Graceful reload —
systemctl reload nginx—reloaded, new workers spawned —Job failed, old workers persist — Restore backup, reload — All workers — Per change - Verify — Health check —
curl -v --resolve app.example.com:443:192.0.2.100 https://app.example.com/healthz—200 OK,Server: nginx— Non-2xx, TLS error, timeout — Rollback config — Single endpoint — Post-change / daily - Verify — TLS validation —
openssl s_client -connect app.example.com:443 -servername app.example.com -verify_return_error—Verify return code: 0, TLSv1.3, cert >30d — Expired cert, chain incomplete, no OCSP — Rollback cert/key — TLS termination — Weekly / post-renewal - Verify — Metrics scrape —
curl -s http://192.0.2.100/nginx_status— Active connections >0, counters increment — Connection refused, empty — Checkstub_statuslocation, allow list — Metrics endpoint — Daily - Verify — Log scan —
grep -E 'error|warn' /var/log/nginx/error.log \| tail -20— No new[alert]/[emerg]— Upstream connect refused, SSL handshake fail — Investigate upstream/TLS — Log analysis — Hourly / alert-driven - Recovery — Config rollback —
mv /etc/nginx /etc/nginx.failed.\$(date +%F); mv /etc/nginx.bak... /etc/nginx; nginx -t && systemctl reload nginx— Old config active, healthz 200 — Reload fails — Binary upgrade rollback — Entire config — On failure - Recovery — Cert rollback —
mv /etc/nginx/ssl/app.example.com /etc/nginx/ssl/app.example.com.bad; mv /etc/nginx/ssl/app.example.com.prev /etc/nginx/ssl/app.example.com; systemctl reload nginx— Valid cert served — Cert still invalid — Re-run certbot — TLS termination — On renewal failure - Recovery — Binary upgrade rollback —
kill -HUP \$(cat /var/run/nginx.pid.oldbin); kill -QUIT \$(cat /var/run/nginx.pid)— Old master resumes, new master exits — Old master gone — Full service restart — Process tree — On upgrade failure
Realistic Technical Scenario: Zero-Downtime TLS Certificate Rotation
Context: Bare metal, systemd, Nginx 1.24.0, serving app.example.com with Let's Encrypt certificates managed by certbot. Certificate expires in 7 days. Goal: rotate without dropping connections.
Step-by-step execution
- Pre-change snapshot
BACKUP_DIR="/etc/nginx.bak.$(date +%F_%H%M%S)"
cp -a /etc/nginx "$BACKUP_DIR"
cp -a /etc/letsencrypt/live/app.example.com /etc/letsencrypt/live/app.example.com.prev.$(date +%F)
- Renew certificate (certbot)
certbot renew --cert-name app.example.com --dry-run # test first
certbot renew --cert-name app.example.com # actual renewal
Expected: Congratulations, all renewals succeeded. New cert in /etc/letsencrypt/live/app.example.com/.
- Deploy new cert/key with correct permissions
CERT_DIR="/etc/nginx/ssl/app.example.com"
mkdir -p "$CERT_DIR"
cp /etc/letsencrypt/live/app.example.com/fullchain.pem "$CERT_DIR/fullchain.pem.new"
cp /etc/letsencrypt/live/app.example.com/privkey.pem "$CERT_DIR/privkey.pem.new"
chmod 640 "$CERT_DIR/privkey.pem.new"
chown root:nginx "$CERT_DIR/privkey.pem.new"
mv "$CERT_DIR/fullchain.pem.new" "$CERT_DIR/fullchain.pem"
mv "$CERT_DIR/privkey.pem.new" "$CERT_DIR/privkey.pem"
- Verify cert/key match and validity
openssl x509 -noout -modulus -in "$CERT_DIR/fullchain.pem" | openssl md5
openssl rsa -noout -modulus -in "$CERT_DIR/privkey.pem" | openssl md5
# Outputs must match
openssl x509 -noout -dates -in "$CERT_DIR/fullchain.pem"
# notAfter > 30 days
- Syntax test and diff
nginx -t
nginx -T 2>&1 | grep -v '^nginx:' | diff -u "$BACKUP_DIR/nginx.conf" -
Expected: only SSL file timestamps/size differ; no directive changes.
- Graceful reload
systemctl reload nginx
- Post-reload verification
# TLS handshake and cert details
openssl s_client -connect app.example.com:443 -servername app.example.com -verify_return_error < /dev/null 2>&1 | \
grep -E 'Protocol|Cipher|Verify return|notAfter|subject=|issuer='
# Synthetic health check with forced IP
curl -v --resolve app.example.com:443:192.0.2.100 https://app.example.com/healthz 2>&1 | \
grep -E 'HTTP/|Server:|< OK'
# Log check
sleep 2
grep -E 'error|warn' /var/log/nginx/error.log | tail -10
Expected: Protocol: TLSv1.3, Verify return code: 0, notAfter matches new cert, HTTP/2 200, no new errors.
- Rollback plan (if verification fails)
# Restore previous cert/key
mv /etc/nginx/ssl/app.example.com /etc/nginx/ssl/app.example.com.failed.$(date +%F_%H%M%S)
mv /etc/letsencrypt/live/app.example.com.prev.$(date +%F) /etc/letsencrypt/live/app.example.com
cp /etc/letsencrypt/live/app.example.com/fullchain.pem /etc/nginx/ssl/app.example.com/
cp /etc/letsencrypt/live/app.example.com/privkey.pem /etc/nginx/ssl/app.example.com/
chmod 640 /etc/nginx/ssl/app.example.com/privkey.pem
chown root:nginx /etc/nginx/ssl/app.example.com/privkey.pem
systemctl reload nginx
# Verify old cert active
openssl s_client -connect app.example.com:443 -servername app.example.com < /dev/null 2>&1 | grep 'notAfter'
Trade-offs and risks
- Certbot renewal may fail due to rate limits or DNS challenges;
--dry-runmitigates. - Atomic
mvof cert files prevents Nginx reading partial files during reload. - Worker processes hold open file descriptors to old cert; graceful reload ensures new workers pick up new files while old workers finish TLS handshakes with old cert.
- If OCSP stapling enabled,
ssl_stapling_filemay need update;nginx -s reloadrefreshes staple fromssl_trusted_certificate.
Conclusion
This checklist converts Nginx operations from reactive debugging into a repeatable, version-scoped discipline. Every phase—inventory, safe configuration, verification, failure recovery—ties a specific command to an observable signal and a tested rollback. The TLS rotation scenario demonstrates end-to-end execution: snapshot, change, verify, recover. Operators should adopt one low-risk verification (e.g., daily nginx -t + healthz curl), record the baseline, and expand coverage weekly. A reliable workflow makes failure visible, protects sensitive values, limits changes to the intended resource, and defines recovery verification before an incident forces the decision.