Nginx powers 30%+ of the web as a web server, reverse proxy, and load balancer — but one missing semicolon or a proxy_pass slash breaks it. This complete guide shows how to configure Nginx from first server block to production (locations, proxy, upstream, TLS, basic auth, and log levels) with tested examples — no prior Nginx needed.
- File:
/etc/nginx/nginx.confincludeshttp { server { location / {} } }— every directive lives in a valid context; wrong context →emerg unknown directive. - First server:
server { listen 80; server_name example.com; root /var/www/html; location / { try_files $uri $uri/ /index.html; } }→nginx -t && nginx -s reload(always test before reload — graceful, zero-downtime). - Proxy:
location /api/ { proxy_pass http://app:3000; include proxy_params; }withproxy_set_header Host $host; X-Real-IP $remote_addr; X-Forwarded-For $proxy_add_x_forwarded_for; X-Forwarded-Proto $scheme; - Balance:
upstream app { least_conn; server app1:3000 weight=3; server app2:3000; }→location / { proxy_pass http://app; } - Auth & logs: protect with
auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd;— generate the file with our basic auth config generator (nohtpasswdCLI needed); set verbosity viaerror_log /var/log/nginx/error.log warn;— pick the right level with our error log level config generator.
What Is Nginx and When Should You Use It?
Nginx (engine-x) is event-driven software that handles 10k+ concurrent connections with very low RAM — unlike thread-per-connection servers. It sits in front of or instead of your app for four jobs:
- Web server — serve static files: HTML, CSS, JS, images via
root+try_files. Ideal for SPA builds (React/Vue) and marketing sites. - Reverse proxy — forward
/api/to Node/Python/Go atproxy_pass http://app:3000, adding TLS termination, caching, headers, and rate limiting at the edge. - Load balancer — distribute across
upstream { server app1:3000; server app2:3000; }with methods likeleast_conn,ip_hash. - Edge — TLS,
gzip,expires,basic_auth,access_log/error_logcontrol.
If your app is a static SPA, Nginx serves index.html with try_files $uri /index.html. If it's an API, Nginx sits in front and buffers clients slowly while proxying fast to the app — protecting Node from slow-client attacks. Docs: Beginner's Guide and NGINX Admin Guide — Web Server.
Nginx vs Apache — Quick Take
Both serve HTTP, but Nginx's event-driven worker model (one worker handles thousands via epoll/kqueue) scales with less RAM than Apache's process/thread models for many concurrent keep-alive connections. For most new builds, Nginx is the default edge; Apache's .htaccess per-directory overrides still suit shared hosting where users lack root.
Install and Find Your Config in 2 Minutes
- Ubuntu/Debian:
sudo apt update && sudo apt install nginx && sudo nginx -t && sudo systemctl enable --now nginx→ verifycurl -I http://localhost→200 OKwithServer: nginx. - RHEL/CentOS:
sudo dnf install nginx→ same enable. - Docker:
docker run -d -p 80:80 -v ./nginx.conf:/etc/nginx/nginx.conf:ro nginx:alpine - Config locations: main
/etc/nginx/nginx.conf, includes/etc/nginx/conf.d/*.confand/etc/nginx/sites-enabled/*(Debian convention) —includepulls them intohttp {}.
Check full effective config anytime: nginx -T (capital T) dumps the merged file with includes expanded — invaluable when you split sites across files. Reference: Alphabetical index of directives.
Syntax — Contexts, Directives, and Why "Unknown directive" Happens
Nginx syntax is directive value; terminated by semicolon, grouped in contexts {}. Hierarchy:
events { worker_connections 1024; }
http {
include mime.types;
access_log /var/log/nginx/access.log;
server {
listen 80; server_name example.com;
location / { root /var/www/html; }
location /api/ { proxy_pass http://app:3000; }
}
}
Every directive is valid only in certain contexts. Example: worker_connections is events-only; server_name is server-only; proxy_pass is location/ if / limit_except. Put it in the wrong block → emerg "proxy_pass" directive is not allowed here. See ngx_http_core_module per-directive Context line.
Master → Worker, reload Without Downtime
master reads config and manages workers; workers handle connections via non-blocking events. Workflow: edit file → nginx -t → nginx -s reload (graceful: new workers with new config, old workers drain). Never kill -9 master — that drops in-flight requests. See Controlling nginx.
Your First Server Block — Static Site That Actually Works on Refresh
server {
listen 80;
server_name example.com www.example.com;
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 7d;
add_header Cache-Control "public, immutable";
}
# block PHP attempts on static host
location ~ \.php$ { return 404; }
}
listen 80; binds port; server_name picks the block by Host header (first exact match, then wildcard, then regex). root points to disk; try_files $uri $uri/ /index.html is the SPA rule — without it, refreshing /dashboard returns 404 because no file /dashboard exists; with it, Nginx falls back to index.html and the JS router handles it.
Location Priority — The Order That Trips Beginners
Nginx tests in priority, not file order:
location = /exact— exact, e.g.,= /healthlocation ^~ /prefix— prefix, stop regex if matchedlocation ~ \.php$— regex case-sensitive;~* \.jpg$case-insensitivelocation /prefix— prefixlocation /— fallback
Example: request /assets/app.css matches /assets/ with ^~ → no regex check → serves static with expires. Request /api/users misses ^~ /assets/ then tests regexes. Docs: location.
Reverse Proxy — Put Nginx in Front of Node/Python/Go
Your app shouldn't face raw internet directly if you need TLS, caching, or slow-client protection. Nginx buffers.
upstream app {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://app;
include /etc/nginx/proxy_params; # Host, X-Real-IP, X-Forwarded-*
}
location /api/ {
proxy_pass http://app; # trailing slash matters — see below
}
}
proxy_params typically contains:
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;
Without these, your Node app sees 127.0.0.1 and http even when the client used https. With them, Express/Koa can trust req.ip and redirect correctly. Critical detail: proxy_pass http://app/ with trailing slash rewrites URI (strips matched prefix), without slash keeps full URI — mixing them causes /api/users → //users double slash bugs. Docs: proxy_pass and Reverse Proxy guide.
Load Balancing — One Upstream, Many Backends
upstream backend {
least_conn;
server app1.example.com:3000 weight=3 max_fails=3 fail_timeout=30s;
server app2.example.com:3000 weight=2;
server app3.example.com:3000 backup;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_next_upstream error timeout http_500 http_502;
}
}
Methods: round-robin (default), least_conn (to least busy), ip_hash (sticky by client IP), hash $request_uri (cache-friendly). weight biases, max_fails/fail_timeout marks bad, backup/down controls. For sticky sessions, prefer ip_hash or set a cookie in app — Nginx Open Source has no cookie sticky, NGINX Plus does via sticky cookie. Reference: ngx_http_upstream_module and HTTP Load Balancing.
TLS/SSL — From HTTP to HTTPS Without Fear
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header Strict-Transport-Security "max-age=63072000" always;
}
Use Certbot: sudo certbot --nginx -d example.com -d www.example.com generates the above and sets renewal. Nginx just needs ssl_certificate + ssl_certificate_key; the return 301 server forces HTTPS. Test: curl -I https://example.com → 200 with strict-transport-security. Docs: Configuring HTTPS.
Lock It — Basic Auth Without the htpasswd Headache
Staging or internal tools need one password before the app loads. Nginx does it natively:
server {
listen 443 ssl;
server_name staging.example.com;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://app;
}
# public health check without auth
location = /health { auth_basic off; proxy_pass http://app; }
}
.htpasswd format is user:encrypted via bcrypt. On many systems you'd run htpasswd -c /etc/nginx/.htpasswd alice, but minimal Docker images and Windows lack it. Generate the exact auth_basic block + .htpasswd line in your browser with our basic auth config generator — pick user/pass, copy the auth_basic snippet and the user:$2y$... line into .htpasswd, no CLI needed. Scope: auth_basic off; inside a location exempts health checks. Docs: ngx_http_auth_basic_module.
Error Log Levels — Pick Warn, Not Debug, for Prod
Nginx logs verbosity is a single directive:
error_log /var/log/nginx/error.log warn;
# levels: debug | info | notice | warn | error | crit | alert | emerg (least → most severe)
debug logs every select/write — gigabytes per hour under load, useful only for 5 minutes of diagnosis then back to warn. warn is prod default: warnings + errors + above, without info noise. You can also set per-context: global error_log in main, override inside http or server for one vhost. Don't have to memorize order — use our error log level config generator to preview the line with the right path and level plus the access_log companion. Context: error_log and access_log (combined vs buffer=32k gzip tuning).
Includes, Access Log, gzip, expires, and Rate Limit — Production Essentials
http {
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
access_log /var/log/nginx/access.log combined buffer=32k gzip flush=5m;
gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1024;
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location / {
limit_req zone=mylimit burst=20 nodelay;
proxy_pass http://app;
}
location ~* \.(jpg|png|woff2)$ { expires 30d; add_header Cache-Control "public, immutable"; }
}
}
Split sites: include /etc/nginx/sites-enabled/*.conf; keeps git history per domain. access_log ... buffer=gzip cuts disk I/O. gzip halves JSON/CSS size at tiny CPU cost — leave images already compressed. limit_req protects login endpoints from bursts — burst=20 nodelay queues then 503s. Full: gzip, expires, limit_req.
Test, Reload, and Troubleshoot — Don't Guess
nginx -t # syntax + file existence
nginx -T # dump merged config (check include expansion)
nginx -s reload # graceful reload
tail -f /var/log/nginx/error.log # live error watch
curl -I http://localhost/health # 200? 301? 404?
Before every push: nginx -t && nginx -s reload — -t catches missing ;, wrong context, unreadable ssl_certificate, or bad upstream name. Then curl -I confirms status code and Location header. In prod, watch error.log at warn tail to spot upstream timeouts immediately.
Top 5 Pitfalls Beginners Hit
| Symptom | Cause → Fix |
|---|---|
| 404 on SPA refresh /dashboard | location / { try_files $uri /index.html; } missing → add fallback; docs try_files |
| /api/users → //users or 404 | proxy_pass http://app/; trailing slash strips /api/ — decide: with slash rewrites, without keeps full URI |
| emerg unknown directive | Directive in wrong context (e.g., server_name inside location) → move to valid block per docs |
| App sees 127.0.0.1 not client IP | Missing proxy_set_header X-Forwarded-For → include proxy_params |
| Site loads old CSS after deploy | expires 30d but no cache-busting filename → use main.abc123.js hashing, keep immutable |
Other gotcha: if inside location is famously error-prone — prefer try_files and separate location blocks per If is Evil.
How to Keep Learning — Modular Files and Next Steps
Production layout most teams use:
/etc/nginx/nginx.conf # events + http + include conf.d/*.conf
/etc/nginx/conf.d/app.conf # upstream + server { } per app
/etc/nginx/proxy_params # shared headers
/etc/nginx/.htpasswd # basic auth file (chmod 640)
/var/log/nginx/access.log + error.log
Generate tedious blocks instead of copying wrong Stack Overflow snippets: our Nginx tools collection builds correct auth_basic, error_log, and upcoming rate-limit/gzip snippets with the right context and semicolons — paste, nginx -t, reload. When you outgrow single host, layer DNS or cloud LB in front of multiple Nginx edges — same server {} syntax, just more IPs.
Cache, WebSockets, and Rate Limit — Production Patterns
Beyond serving and proxying, three patterns separate a dev config from prod:
- proxy_cache (micro-cache): for
/api/that can tolerate 10s staleness, addproxy_cache mycache; proxy_cache_valid 200 10s; proxy_cache_use_stale error timeout;withproxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mycache:10m inactive=60m;inhttp {}. Even 5s cuts upstream load by 10× under burst. Invalidate viaproxy_cache_bypass $http_pragma;for authenticated requests. Docs: proxy_cache. - WebSockets: add inside
location /ws/ { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }— withoutUpgrade, handshake fails with 400. Keepproxy_read_timeout 3600s;for long-lived sockets. - Rate limit login: define
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;thenlocation = /login { limit_req zone=login burst=10 nodelay; proxy_pass http://app; }— bursts queue 10 then 503s, protecting brute force without blocking real users. Tuning at limit_req.
Security Headers and .htpasswd Reality — What to Set on Day One
Three headers most prod Nginx should add in http or server:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# for HTTPS only:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Don't add X-XSS-Protection blindly — modern browsers ignore it. For .htpasswd, Nginx expects bcrypt $2y$ — generated by htpasswd or our tool; .htpasswd must be outside root (e.g., /etc/nginx/.htpasswd, chmod 640 root:www-data) so it's not downloadable as static. Example line: alice:$2y$05$.... Without this placement, location / { root /var/www; } might serve /.htpasswd as text — test with curl http://localhost/.htpasswd should → 404, not 200.
Include Strategy — One File Per Site, No Monster nginx.conf
Month two, nginx.conf becomes 800 lines and merge conflicts bloom. Split early:
# /etc/nginx/nginx.conf
http {
include mime.types;
include /etc/nginx/conf.d/*.conf; # global: gzip, limit_req_zone, upstream
include /etc/nginx/sites-enabled/*.conf; # per-domain server { }
}
# /etc/nginx/sites-enabled/example.com.conf
server {
listen 80; server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl; server_name example.com;
include /etc/nginx/snippets/ssl-example.com.conf;
location / { proxy_pass http://backend; }
}
Benefits: git diff per file, nginx -T still shows merged view for debug, sites-enabled symlink pattern (Debian) lets you disable via rm symlink && nginx -s reload zero-downtime. Reference: include.
reload — copy these 3:
nginx -t && echo "syntax ok"
curl -I http://localhost/health # expect 200 vs 301 vs 404 reveals server_name/proxy_pass mistake
tail -n 50 /var/log/nginx/error.log | grep -E "emerg|error|crit" # should be quiet at warn level
If nginx -t reports duplicate listen or conflicting server name, two server blocks share the same listen + server_name — rename or merge.
Common Production Gotchas — Permissions, Real IP, and "if is evil"
Three mistakes fill forums weekly:
- 403 Forbidden on static:
root /var/www/appbut files owned bydeploy:deploy 700—www-dataworker can't read. Fix:chown -R www-data:www-data /var/www/app && chmod 755 dirsor setuser www-data;in top/etc/nginx/nginx.confto match owner. Check withsudo -u www-data ls /var/www/app/index.html. - App logs 10.0.0.2 not client IP: behind cloud LB, extra hop strips
X-Forwarded-For. Addreal_ip_header X-Forwarded-For; set_real_ip_from 10.0.0.0/8; real_ip_recursive on;so$remote_addrbecomes real client — critical for rate limit and audit. Module: realip. - if inside location:
location / { if ($uri ~* api) { proxy_pass ... } }unpredictably re-evaluates phases. Rule per Nginx wiki If is Evil: avoidiffor routing — use separatelocation = /api {}ortry_files+rewriteinservercontext.
Logging Deep Dive — access_log Format and error_log Per Server
Default combined is often enough, but prod wants buffer + gzip + json for log pipelines:
log_format json_combined escape=json '{ "time":"$time_iso8601", "ip":"$remote_addr", "method":"$request_method", "uri":"$request_uri", "status":$status, "rt":$request_time, "ua":"$http_user_agent" }';
access_log /var/log/nginx/access.log json_combined buffer=32k gzip flush=5m;
# per vhost override: no access log for health
location = /health { access_log off; proxy_pass http://app; }
error_log /var/log/nginx/error.log warn;
# one noisy vhost more verbose:
server { listen 80; server_name debug.example.com; error_log /var/log/nginx/debug_error.log notice; }
warn vs notice vs debug dramatically changes I/O — debug at 1k rps writes ~500 MB/hour. If you must enable debug, scope it: error_log /var/log/nginx/debug.log debug; events { debug_connection 192.0.2.1; } so only that IP triggers verbose. Docs: error_log levels.
From Config to Production — Docker, Systemd, and Zero-Downtime Deploy
Whether bare metal or container, the reload model stays: nginx -t && nginx -s reload. In Docker, docker compose up -d --no-deps nginx && docker exec nginx nginx -t validates without restart. Systemd: systemctl reload nginx is the same graceful reload. For immutable deploys, bake nginx.conf into the image at COPY nginx.conf /etc/nginx/nginx.conf:ro and test at build time: RUN nginx -t in Dockerfile fails the build on typo — catching missing ; before prod.
Finally, version your configs: nginx -T > /etc/nginx/snapshot-$(date +%F).conf.dump before each reload so diff shows what changed when 3am paging hits. Pair with tail -f /var/log/nginx/error.log at warn for the first minute post-reload — quiet means healthy.
Keep testing tight: even after nginx -t passes, run curl -I http://localhost/ and curl -I https://localhost/ -k plus nginx -T | grep -E "server_name|listen|proxy_pass|error_log" — the three checks catch server_name typo, listen collision, and proxy_pass slash mistake before users do.
Copy working server {} blocks as templates — one correct block reused beats four hand-typed variants with different typos.
Bonus: keep sites-enabled symlinks in git — disabling a site is rm symlink && nginx -s reload, no file delete.
Frequently Asked Questions
Where is nginx.conf and how do I find the active config?
Main is /etc/nginx/nginx.conf but it includes conf.d/*.conf and sites-enabled/*. Run nginx -T (capital T) to dump the full merged config with all includes expanded, or nginx -V for compile flags and default paths.
What is the difference between root and alias?
root /var/www/app appends full URI: /i/top.gif → /var/www/app/i/top.gif. alias /var/www/app/ replaces matched part: location /i/ { alias /data/w3/images/; } → /i/top.gif → /data/w3/images/top.gif. Need trailing slash on alias — missing it causes 404.
Why does proxy_pass with vs without trailing slash matter?
proxy_pass http://app/api/; with slash replaces the matched location prefix; without slash passes full URI. Example: location /api/ { proxy_pass http://app/; } maps /api/users → /users at upstream, while proxy_pass http://app; keeps /api/users. Pick one intentionally per proxy_pass.
How do I enable HTTPS on Nginx?
Add a second server { listen 443 ssl; ssl_certificate ...; ssl_certificate_key ...; } plus a port 80 server that return 301 https://$host$request_uri;. Easiest: sudo certbot --nginx -d example.com creates it and renews via cron. See Configuring HTTPS.
How do I protect a site with a password (basic auth)?
Add auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; in server or location, create .htpasswd with htpasswd -c /etc/nginx/.htpasswd alice or generate the line via our basic auth generator. Use auth_basic off; in excluded locations like = /health.
What error_log level should I use?
Levels: debug < info < notice < warn < error < crit < alert < emerg. Prod default warn (warnings + above), not debug which fills disk. Set via error_log /var/log/nginx/error.log warn; per error_log — or pick via our error log level generator.