Everything in this tutorial has pointed toward this lesson. You now have the vocabulary — TLS termination, reverse proxying, upstream pools, caching, rate limiting, security headers, and tuning knobs. This project wires all of those pieces together into a single, battle-tested Nginx configuration that you could deploy in front of a real application today. The sample app is a Node.js/Python/PHP process listening on 127.0.0.1:8000, but the pattern is identical for any language or framework.
We will build the configuration in deliberate layers — first making it work, then making it correct, then making it fast and resilient. That progression mirrors how senior engineers actually iterate on production infrastructure.
Project Architecture
Production Nginx layers: TLS termination, rate limiting, caching, and proxy pass to the upstream app pool.
Step 1 — Directory Layout and Prerequisites
Before writing a single directive, establish the file layout. Big-tech environments use sites-available / sites-enabled with symlinks so each vhost is independently managed and can be disabled without touching others.
# Install Nginx and Certbot (Debian/Ubuntu)
apt-get update && apt-get install -y nginx certbot python3-certbot-nginx
# Create directory structure
mkdir -p /etc/nginx/conf.d
mkdir -p /var/cache/nginx/app_cache
mkdir -p /var/log/nginx
mkdir -p /var/www/app/public
# Set cache directory ownership
chown -R www-data:www-data /var/cache/nginx
# Obtain a TLS certificate (DNS must already point to this server)
certbot certonly --nginx -d example.com -d www.example.com \
--non-interactive --agree-tos -m ops@example.com
Step 2 — Global nginx.conf Baseline
The global file sets worker tuning, logging format, and declares the shared cache zone. The cache zone lives here — not in the server block — because a zone is a shared memory region, not per-vhost.
# /etc/nginx/nginx.conf
user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /run/nginx.pid;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# --- MIME & basics ---
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# --- Keep-alive ---
keepalive_timeout 65;
keepalive_requests 1000;
# --- Buffers (tune to match your app response sizes) ---
client_body_buffer_size 16k;
client_max_body_size 50m;
proxy_buffer_size 16k;
proxy_buffers 4 64k;
proxy_busy_buffers_size 128k;
# --- Gzip compression ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript
application/xml text/xml image/svg+xml font/woff2;
# --- Rate-limiting zones (shared memory) ---
# General API limit: 30 requests/sec per IP, burst=60
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
# Login/auth limit: 5 requests/min per IP
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
# --- Proxy cache zone (disk-backed, 1GB max) ---
proxy_cache_path /var/cache/nginx/app_cache
levels=1:2
keys_zone=app_cache:20m
max_size=1g
inactive=60m
use_temp_path=off;
# --- Structured JSON log format (easy to ship to ELK/Splunk) ---
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"ip":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$uri",'
'"status":"$status",'
'"bytes":"$body_bytes_sent",'
'"rt":"$request_time",'
'"upstream_rt":"$upstream_response_time",'
'"cache":"$upstream_cache_status",'
'"ua":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.log json_combined;
error_log /var/log/nginx/error.log warn;
# --- Hide Nginx version from headers ---
server_tokens off;
# --- Include vhost configs ---
include /etc/nginx/conf.d/*.conf;
}
Why a JSON log format? Plain combined logs require regex parsers in every log aggregator. A structured JSON log ships directly to Elasticsearch, Datadog, or Splunk with zero transformation. Every field — upstream response time, cache hit status, method — becomes a filterable dimension. At scale, this is the difference between a 30-minute incident and a 3-minute one.
Step 3 — The Vhost Configuration
This is the complete /etc/nginx/conf.d/example.conf. Read the inline comments carefully — each directive has a reason that will matter in production.
Certificates expire every 90 days. Certbot installs a systemd timer (certbot.timer) that runs renewal twice daily, but it does not automatically reload Nginx after renewal. You must hook into the post-renewal step.
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
set -e
nginx -t && systemctl reload nginx
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# Test the full renewal flow without actually renewing
certbot renew --dry-run
Common Production Failure Modes
Every pattern in this configuration exists because something broke in production. Here are the most frequent failure modes engineers encounter with this exact setup:
502 Bad Gateway after deploy: The app process restarted but the upstream keepalive pool still holds a connection to the old process FD. Fix: set proxy_next_upstream error timeout invalid_header http_502 so Nginx retries the next backend on failure.
Cache poisoning via Host header injection: An attacker sends a crafted Host header; Nginx caches a response keyed to a different domain. Fix: explicitly set proxy_cache_key "$scheme$host$request_uri" and never include user-supplied headers in the cache key.
HSTS locking out a domain: If you set a long max-age and then need to revert to HTTP (expired cert, misconfiguration), browsers will refuse to connect for the entire HSTS duration. Start with max-age=300 in staging; only set 63072000 (two years) in production when fully stable.
Rate limiting blocking legitimate users behind NAT: A corporate proxy means hundreds of users share one IP. A per-IP rate limit of 30r/s becomes 0.3r/s per user. Mitigate with $http_x_forwarded_for (if a trusted proxy sets it) or increase burst values for API routes used by desktop apps.
Certificate renewal fails silently: Certbot renews the cert but the deploy hook was not made executable. Result: Nginx keeps serving the old certificate until it expires. Always certbot renew --dry-run immediately after adding renewal hooks.
Never set add_header inside a location block and also at the server block level — they do not merge. An add_header inside a location block replaces all inherited headers from the parent server block. If you set HSTS at the server level and then add any add_header inside a location /api/ block, the HSTS header will be silently dropped for all API responses. The fix is to repeat all required security headers in every location block, or to set them only at the server level and never inside location blocks.
What You Have Built
This configuration implements every layer of a production-grade front-end: TLS 1.2/1.3 with OCSP stapling and session resumption, HTTP/2, automated certificate renewal, HSTS preloading, a full security header set, disk-backed proxy caching with stale-while-revalidate semantics, per-route rate limiting with burst headroom, upstream keepalive connection pooling, structured JSON access logs, and a clean separation between static file serving (zero app process involvement) and dynamic proxying. It is the exact pattern used across cloud-native environments at high-traffic companies — parameterized to a different upstream block and a different domain, it becomes the front-end for any application in your stack.