Nginx powers over 30% of all web servers worldwide. Whether you're serving a static site, reverse proxying to a Node.js app, or load balancing across multiple backend servers, Nginx is likely involved. Understanding its configuration is an essential skill for any web developer who deploys their own applications.
Nginx configuration is made up of directives (simple key-value statements ending in a semicolon) organized into nested contexts such as http, server, and location. The main file usually lives at /etc/nginx/nginx.conf, and per-site configuration goes in /etc/nginx/conf.d/ or /etc/nginx/sites-available/ with a symlink in sites-enabled/. Two things trip up newcomers: settings inherit downward from outer contexts into inner ones, and nothing takes effect until you reload the service. Keep both in mind as you work through the examples below.
Basic Server Block
A server block is Nginx's equivalent of an Apache virtual host: it tells Nginx which requests it is responsible for, based on the port in listen and the domain in server_name. The root directive points at the directory files are served from, and index lists the default files to try when a request ends in a slash.
The most important line here is try_files $uri $uri/ =404;. It tells Nginx to look for a file matching the requested path, then a directory of that name, and to return a 404 if neither exists. Without an explicit fallback, misconfigured servers can leak directory listings or pass unexpected paths to a backend. A classic mistake in this area is confusing root with alias: root appends the full request URI to the path, while alias replaces the matched location prefix — mixing them up produces baffling 404s for files that clearly exist on disk.
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html index.php;
# Serve static files directly
location / {
try_files $uri $uri/ =404;
}
# Custom error pages
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}
Reverse Proxy to Node.js/App Server
A reverse proxy sits between the internet and your application server. Nginx accepts the client connection, then forwards the request to a process listening on a local port — a Node.js app on 3000, a Python service behind Gunicorn, or PHP-FPM. This split plays to each tool's strengths: Nginx efficiently handles TLS termination, compression, slow clients, and static files, while your application only deals with business logic.
The proxy_set_header lines are not boilerplate you can skip. Without Host and X-Forwarded-For, your application sees every request as coming from 127.0.0.1, which breaks logging, per-user rate limiting, and absolute URL generation. X-Forwarded-Proto tells the app whether the original request used HTTPS, which frameworks rely on for secure cookies and correct redirects. The Upgrade and Connection headers let WebSocket connections pass through the proxy instead of being silently downgraded. One subtle gotcha: whether or not you put a trailing slash on the proxy_pass URL changes how the location prefix is rewritten, so always test both a root path and a nested path after changing it.
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
SSL/TLS with Let's Encrypt
HTTPS is mandatory for modern sites — browsers flag plain HTTP as "Not secure", and features like HTTP/2, service workers, and geolocation require it. The configuration below uses two server blocks: the first catches all HTTP traffic and issues a permanent 301 redirect to HTTPS, and the second serves the encrypted site. Let's Encrypt provides certificates for free; the certbot client obtains them, places them under /etc/letsencrypt/live/, and renews them automatically before their 90-day expiry.
Restricting ssl_protocols to TLS 1.2 and 1.3 disables older protocol versions with known weaknesses, and every modern client has supported these versions for years. Be deliberate with the HSTS header, though: once a browser sees it, it will refuse plain HTTP to your domain for the entire max-age — a full year here. Enable it only after confirming HTTPS works everywhere, including every subdomain if you keep includeSubDomains, because there is no quick way to undo it for visitors who already received the header.
# Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# HSTS (force HTTPS for 1 year)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
root /var/www/example.com/public;
# ... rest of config
}
Gzip Compression
Gzip typically shrinks text-based responses — HTML, CSS, JavaScript, JSON — by 60–80%, which directly improves load times on slower connections. Each directive in the snippet below earns its place: gzip_min_length skips tiny responses where compression overhead outweighs the savings, and gzip_vary adds a Vary: Accept-Encoding header so caches and CDNs store separate compressed and uncompressed copies.
Compression level 5 is the practical sweet spot; levels 8–9 burn noticeably more CPU for only a percent or two of extra savings. Notice that the gzip_types list contains only text formats. Never add JPEG, PNG, WOFF2, or video types — those formats are already compressed, so gzipping them wastes CPU and can even make files slightly larger. If you want to go further, look at the Brotli module: it compresses better than gzip and is supported by every modern browser.
# In http {} or server {} block
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
Security Headers
Response headers are one of the cheapest security wins available. X-Frame-Options: SAMEORIGIN stops other sites from embedding yours in an iframe, which blocks clickjacking attacks. X-Content-Type-Options: nosniff prevents browsers from second-guessing MIME types, a trick attackers use to get uploaded files executed as scripts. Referrer-Policy limits how much of your URLs leaks to third-party sites when users click outbound links, and Permissions-Policy declares up front that your pages never need the camera, microphone, or location.
The Content-Security-Policy header is the most powerful of the set — and the most likely to break your site, because it blocks every script, style, and image source you have not explicitly allowed. Roll it out with Content-Security-Policy-Report-Only first and watch the browser console for violations before enforcing it. Also note the always flag on each directive: without it, Nginx omits these headers on error responses like 404s and 500s, which is exactly when you still want them present.
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Caching Static Assets
Browser caching removes repeat requests entirely, and the rules below encode the standard strategy: cache fingerprinted assets aggressively, never cache HTML. The one-year expires combined with Cache-Control: immutable is only safe when your build tool writes a content hash into each filename (for example app.3f9c2b.js) — when the file changes, the name changes, so a stale copy can never be served.
HTML is the opposite case. The page is what references those hashed filenames, so it must be revalidated on every visit or users will keep loading yesterday's bundle. That is why the second location sets no-cache, which — despite its name — allows caching but forces the browser to check with the server before reusing the copy. Turning off access_log for static assets is a small bonus that keeps your logs focused on meaningful page requests.
# Cache static assets aggressively
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff2|woff|ttf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Don't cache HTML
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache";
}
Rate Limiting
Rate limiting protects your backend from scrapers, brute-force login attempts, and misbehaving clients stuck in a retry loop. Nginx implements it with a shared-memory zone: zone=api:10m allocates 10 MB — enough to track roughly 160,000 unique client IPs — and rate=10r/s sets the sustained allowance per IP.
Real traffic is bursty, which is what the burst and nodelay parameters handle. burst=20 lets a client briefly exceed the rate by up to 20 queued requests, and nodelay serves that burst immediately instead of trickling it out. Setting limit_req_status 429 returns the semantically correct "Too Many Requests" instead of Nginx's default 503, which well-behaved API clients know how to back off from. One caveat: if Nginx sits behind a load balancer or CDN, $binary_remote_addr will be the proxy's address rather than the user's, so you would need to restore the real client IP from a forwarded header before keying limits on it.
# Define rate limit zone (in http {} block)
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# Apply to specific location
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
Useful Commands
Make nginx -t a reflex. It validates the entire configuration and reports the exact file and line of any mistake, without touching the running server. Only after it passes should you apply changes — and prefer reload over restart: a reload starts new worker processes with the new configuration while the old workers finish their in-flight requests, so no connection is dropped. If the new configuration turns out to be broken, the reload is refused and the old workers keep serving traffic.
When something misbehaves, the error log is almost always where the answer lives. Permission problems, upstream connection failures, and misconfigured paths all surface there with reasonably clear messages, while the access log tells you what clients are actually requesting and which status codes they receive.
# Test configuration syntax sudo nginx -t # Reload configuration (zero downtime) sudo systemctl reload nginx # View error logs sudo tail -f /var/log/nginx/error.log # View access logs sudo tail -f /var/log/nginx/access.log
Common Pitfalls
A handful of mistakes account for most Nginx debugging sessions. Knowing them in advance will save you hours:
- Editing the wrong file. Distributions ship several config locations; run
nginx -T(capital T) to dump the full merged configuration and confirm your change is actually in it. - Assuming
add_headerinherits. If alocationblock contains even oneadd_header, all headers defined in the parentserverblock are dropped for that location and must be repeated there. - Misreading location matching order. Exact matches (
=) win first, then prefix matches marked^~, then regex locations in file order, then the longest plain prefix. The declaration order of plain prefix locations does not matter, which surprises many people. - Unexplained 413 errors on uploads. The default
client_max_body_sizeis only 1 MB; raise it wherever users upload files. - Using
ifinsidelocationblocks. The official documentation calls this "if is evil" for a reason — it behaves unpredictably with most directives. Prefertry_files,map, or separate locations.
Wrapping Up
The snippets in this guide cover the configuration that the vast majority of production sites need: a server block, a reverse proxy, TLS, compression, caching, security headers, and rate limiting. Start by assembling them into a config for a staging server, run nginx -t, and verify each behavior with curl -I so you can see the headers Nginx actually sends. Then run your domain through an external checker such as SSL Labs to confirm the TLS setup scores well. From there, the official Nginx documentation is the best resource for going deeper into load balancing, proxy caching with proxy_cache, and HTTP/3 support.
Try Our Free Developer Tools
Minify and beautify your HTML, CSS, and JavaScript.