Almost every Nginx configuration in the real world reduces to three patterns: serving static files, serving a SPA with client-side routing, or acting as a reverse proxy to an application. If you understand those three blocks plus two or three key directives, you can maintain any server. This article breaks them down with the exact configuration.
Pattern 1: static site
server {
listen 80;
http2 off;
server_name midominio.com;
root /var/www/midominio;
index index.html index.htm;
client_max_body_size 10M;
location / {
try_files $uri $uri/ =404;
}
}
The directive doing all the work is try_files: it tries the options in order and serves the first that exists. Here: is there a file matching the URI exactly? A directory? If neither exists, return 404. Without try_files, Nginx falls back to its default behavior, far less predictable with clean URLs.
Pattern 2: SPA (React, Vue, Svelte)
A client-routed SPA receives requests like /users/42/edit that match no real file — the JavaScript router must handle them. One single change from the previous pattern:
location / {
try_files $uri $uri/ /index.html;
}
The final fallback is no longer =404 but /index.html: any unknown route serves the application, which boots, reads the browser URL and renders the matching view. It is literally a one-word change separating a server that works with React Router from one returning 404 whenever anyone refreshes an inner page.
Pattern 3: reverse proxy
Here Nginx stops serving files and starts forwarding every request to your application (Node, Python, Go...) listening on another port:
server {
listen 80;
http2 off;
server_name midominio.com;
client_max_body_size 10M;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
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_read_timeout 60s;
}
}
Those proxy_set_header lines are not decoration:
X-Forwarded-Foraccumulates the chain of real client IPs. Without it your application sees every request as coming from127.0.0.1— fatal for rate limiting, logs and geolocation.X-Forwarded-Protoreports that the client arrived over HTTPS even though the internal connection is HTTP. Without it, frameworks generating absolute URLs producehttp://links in production.Upgrade+Connection "upgrade"enable WebSockets, which need a protocol hop the proxy must explicitly allow.client_max_body_sizecaps accepted body size; by default Nginx cuts at 1 MB and uploads fail with a mysterious 413 error.
The add_header inheritance trap
This behavior surprises even veteran admins: add_header directives are NOT inherited when a location block defines any of its own. In this example:
server {
add_header X-Frame-Options "DENY" always;
location / {
# inherits X-Frame-Options
}
location ~* \.(css|js|png)$ {
expires 30d;
add_header Cache-Control "public, immutable";
# loses X-Frame-Options!
}
}
The assets block defines its own add_header (the cache one), so Nginx discards the inherited ones from server level. For static assets this is irrelevant — nobody embeds CSS in an iframe — but you must know it whenever you add headers inside locations with dynamic content.
HTTPS: redirection and certificates
With Let's Encrypt certificates (via Certbot), the pattern is two server blocks: old port 80 only redirects, and 443 does the real work:
server {
listen 80;
server_name midominio.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name midominio.com;
ssl_certificate /etc/letsencrypt/live/midominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/midominio.com/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# ... rest identical ...
}
Two modern details: http2 on is the current syntax (since Nginx 1.25.1); the old one was listen 443 ssl http2;. And HSTS with max-age=63072000 tells browsers not to even attempt HTTP for two years — enable it only once HTTPS is proven stable.
Gzip belongs in the http block
gzip compression is configured in the http {} block of the main /etc/nginx/nginx.conf file, not in your server block:
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_comp_level 6 is the sweet spot: higher levels gain a few bytes for a lot of CPU.
Generate all three patterns configured
Choosing mode (static, SPA or reverse proxy) and toggling SSL, asset cache, security headers and gzip is what our Nginx Config Generator does: fill in domain, ports and paths, then copy the complete configuration ready for /etc/nginx/sites-available/.
FAQ
Where does my config file go? On Debian/Ubuntu, /etc/nginx/sites-available/midominio with a symlink from sites-enabled. After changing anything: nginx -t to validate and systemctl reload nginx to apply without downtime.
Nginx or Caddy? Caddy automates TLS certificates without touching anything and is excellent for personal projects. Nginx remains the production standard for raw performance, ecosystem and documentation when weird problems arise.
Why do my WebSockets drop after 60 seconds? The default proxy_read_timeout closes idle connections. With inactive WebSockets, raise it or implement application-level ping/pong.
Generate your complete configuration with the Nginx Config Generator, SSL and security headers included.