Nginx
A copy-paste reference for common Nginx setups. Domains are normalized to
example.com-style placeholders — replace before use.
Contents
- Install
- Configure a site
- Server and location selection
- Directives and variables
- Reverse proxy
- WebSocket reverse proxy
- SSL and HTTPS
- ACME and certificates
- TLS tuning
- Rate limiting
- Static files and caching
- Load balancing
- Troubleshooting
Install
Tested on Ubuntu.
sudo apt-get update
sudo apt-get install nginx
# Firewall: 'Nginx Full' opens 80 and 443
sudo ufw app list
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
systemctl status nginx
curl localhost:80 # should return the default home page
Configure a site
The main file is /etc/nginx/nginx.conf. Per-site configuration is kept
separately in sites-available/ and enabled by symlinking into
sites-enabled/.
# Remove the default site so it doesn't grab port 80
sudo rm /etc/nginx/sites-available/default
CONFIG_NAME=example
sudo vi /etc/nginx/sites-available/$CONFIG_NAME
# Enable it
sudo ln -s /etc/nginx/sites-available/$CONFIG_NAME /etc/nginx/sites-enabled/
sudo ls /etc/nginx/sites-enabled/
# Validate the syntax, then apply
sudo nginx -t
sudo systemctl reload nginx # reload = no dropped connections; restart if reload is not enough
Operational checks:
# Which processes listen on port 80
lsof -i -P -n | grep :80
# Which user the workers run as
ps aux | grep "nginx: worker process"
# Truncate a log without reloading nginx
sudo truncate --size 0 /var/log/nginx/access.log
403 Forbidden on static files
Nginx workers must be able to read (and traverse the whole path to) the files. Give the worker user access rather than loosening everything:
# Inspect the docroot and every parent directory
ls -ld /root/service/bitdove-static/
sudo namei -l /root/service/bitdove-static/
# Grant the www-data group read/traverse
sudo chown -vR :www-data /root/service/bitdove-static/
sudo chmod -vR g+rX /root/service/bitdove-static/
If the files live under a home directory the worker cannot traverse, either move
the docroot to /var/www, or set user in nginx.conf to match the owner
(last resort — do not set user root;, which runs workers as root).
Server and location selection
Nginx routes each request in two stages: it picks a server block (by
listen then server_name), then a location block within it.
Location modifier quick reference
Syntax: location [ = | ~ | ~* | ^~ ] /uri/ { ... }
= (exact) is checked first, then ^~, then the regex rules (~, ~*) in the
order they appear in the file, and finally the generic / prefix match.
Matching stops at the first successful rule, which then handles the request.
| Modifier | Meaning |
|---|---|
= | Exact match |
^~ | Prefix match on a literal string; when it wins, regex rules are skipped. Nginx does not URL-decode here, so /static/20%/aa can be matched by ^~ /static/ |
~ | Case-sensitive regex match |
~* | Case-insensitive regex match |
!~ / !~* | Case-sensitive / case-insensitive regex non-match |
(none) / | Generic prefix match — lowest priority, matches anything |
location = /ads.txt {
try_files /ads.txt =404;
}
Put if inside location — otherwise its priority can override the
location blocks.
Choosing the server block
Nginx first narrows candidates by the listen directive (IP address and port),
then, only when several blocks match equally, by server_name.
listenmay be an IP:port, a lone IP (implies port 80), a lone port (all interfaces), or a Unix socket path.- A block using
0.0.0.0(any interface) loses to one that names a specific IP; the port must always match exactly. - One
default_serverper IP:port handles anything that matches nothing else (otherwise the first-defined block wins).
server_name is then evaluated in this order, and the search stops at the first
category that yields a match:
- Exact name (multiple exact matches → the first wins).
- Leading wildcard, e.g.
*.example.com(longest match wins). - Trailing wildcard, e.g.
www.*(longest match wins). - Regular expression,
~prefix (the first matching one wins). - The default server block.
Choosing the location block
- Exact
=match wins immediately. - Otherwise the longest matching prefix is found. If it uses
^~, it wins and regex evaluation is skipped. - Otherwise the regex locations are tried in file order; the first match wins. Regexes inside the longest prefix are checked first.
- If no regex matches, the stored longest prefix is used.
By default regex matches take precedence over prefix matches, but = and ^~
let you override that. Because regex evaluation stops at the first hit, order
in the file matters for regex locations.
Internal redirects
A few directives can trigger a fresh location search rather than handling the
request in place: index, try_files (its last argument may be a URI),
rewrite (with last or no flag), and error_page. Keep this in mind when a
request seems to be handled by a different block than you expected.
Directives and variables
map
ngx_http_map_module creates a variable whose value depends on another
variable — the idiomatic way to compute the WebSocket Connection header.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
rewrite
# ^ anchors the start of the URI.
# Request https://example.com/web3-1/ against web3-1(.*)$
# full match -> web3-1/ captured group $1 -> /
Request-header variables
$http_<name> exposes an arbitrary request header: the part after $http_ is
the header name, lower-cased with dashes turned into underscores (so
User-Agent becomes $http_user_agent).
| Variable | Meaning |
|---|---|
$http_user_agent | Client user agent (usually the browser) |
$http_x_forwarded_for | Real client IP when requests pass through a proxy |
$http_referer | The link the user came from |
Shared proxy headers
Keep the common reverse-proxy headers in one include file, e.g.
/etc/nginx/proxy_params, and pull it into each proxying location:
proxy_set_header Host $http_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;
location / {
include proxy_params;
proxy_pass http://127.0.0.1:3000;
}
Reverse proxy
Node / SPA front-end
Terminate TLS at Nginx and proxy to the app on localhost. A second block redirects plain HTTP to HTTPS.
server {
server_name example.com www.example.com;
location / {
include proxy_params;
proxy_pass http://127.0.0.1:16666/;
proxy_set_header Host $host;
proxy_redirect off;
}
listen [::]:443 ssl;
listen 443 ssl;
http2 on; # nginx >= 1.25; older: 'listen 443 ssl http2'
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Next.js under a sub-path
For an app served at a sub-path, set basePath and proxy that prefix through:
// next.config.js
const nextConfig = {
basePath: '/new/path',
};
location /new/path {
include proxy_params;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
Tomcat upstream
upstream tomcat {
server 127.0.0.1:8080 fail_timeout=0;
}
server {
# Serve some assets directly, proxy the rest
location ^~ /alone-static {
root /digitslate;
}
location / {
include proxy_params;
proxy_pass http://tomcat/;
}
}
CORS on a proxied path
location /doc/bitdove {
alias /root/service/app/bitdove-doc/public/;
index index.html;
add_header Access-Control-Allow-Origin $http_origin;
}
WebSocket reverse proxy
How it works
WebSocket runs on ports 80/443 with the ws:// / wss:// scheme and upgrades
from HTTP/1.1 via a 101 Switching Protocols handshake. App servers usually
speak plain ws; to expose them securely over the internet, terminate TLS at
Nginx:
Client <- WSS -> Nginx (proxy) <- WS -> Application Server
The client requests the upgrade with Upgrade: websocket and
Connection: Upgrade; the server replies 101 Switching Protocols. After the
handshake both sides are peers and exchange data frames directly — no further
HTTP.
Single hop
Prerequisite: a domain with a certificate. /etc/nginx/conf.d/websocket.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket {
server localhost:8282; # appserver_ip:ws_port
}
server {
listen 443 ssl;
server_name example.com;
location / {
proxy_pass http://websocket;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}
- The two
Upgrade/Connectionheaders are the only difference from a normal HTTP reverse proxy. - The
mapforwardsConnection: upgradeonly when the client actually sent anUpgradeheader — cleaner than forwarding it unconditionally. - Idle connections close after 60s by default; raise
proxy_read_timeout, and have the upstream send periodic ping frames, to keep them alive.
Timeout parameters
| Directive | Default | Context | Meaning |
|---|---|---|---|
proxy_read_timeout | 60s | http, server, location | Read timeout toward the upstream — the max interval between two successive read operations, not the whole-response time |
proxy_send_timeout | 60s | http, server, location | Write timeout toward the upstream — measured between two successive write operations |
Two-tier (double) proxy
Use case: a domain unreachable from some networks (e.g. mobile). A public cloud
host forwards to an internal domain (inner.example.com) that resolves only
inside the network. Only the outermost hop uses wss; everything behind it is
plain ws on port 80.
Public host (TLS termination) — note the Host header is intentionally not
overridden on this hop:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name example.com;
location / {
proxy_pass http://inner.example.com;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# proxy_set_header Host $host; # left off deliberately
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
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;
}
Internal / gateway host:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket {
server localhost:8282; # appserver_ip:ws_port
}
server {
listen 80;
server_name inner.example.com;
location / {
proxy_pass http://websocket;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
SSL and HTTPS
Prefer obtaining certificates with Certbot (see ACME and certificates); it writes most of the SSL directives for you. The blocks below are for hand-written configs.
Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com www.example.com;
return 301 https://www.example.com$request_uri;
}
return 301 …$request_uri; is the recommended modern form. The older
rewrite ^/(.*)$ https://example.com/$1 permanent; still appears in many
articles but is no longer preferred.
HTTPS server block
server {
listen 443 ssl; # 'ssl on;' is deprecated — use 'listen … ssl'
http2 on; # nginx >= 1.25
server_name www.example.com;
root /var/www/example;
index index.html index.htm;
ssl_certificate /etc/ssl/example/fullchain.pem;
ssl_certificate_key /etc/ssl/example/privkey.pem;
ssl_session_timeout 5m;
ssl_protocols TLSv1.2 TLSv1.3; # SSLv2/3 and TLSv1.0/1.1 are insecure — do not enable
ssl_prefer_server_ciphers on;
# Modern nginx cipher defaults are reasonable; use Mozilla's SSL Config
# Generator if you need a tuned list.
# Redirect plain-HTTP requests that hit the HTTPS port (nginx status 497)
error_page 497 https://$host$uri?$args;
location / {
# ...
}
}
Add another virtual server on port 80 (above) that 301-redirects to this one.
PHP / FastCGI behind HTTPS
Some apps (e.g. phpMyAdmin) only see the forwarded port and keep generating
http:// URLs. Tell FastCGI the connection is HTTPS:
location ~ .*\.(php|php5)?$ {
try_files $uri =404;
fastcgi_pass unix:/tmp/php-cgi.sock;
fastcgi_index index.php;
fastcgi_param HTTPS on; # <-- the fix
include fcgi.conf;
}
ACME and certificates
Serve the ACME HTTP-01 challenge from a fixed webroot so certificates can be issued and renewed without stopping Nginx. Tested on Ubuntu 22.04 / 24.04.
# Webroot for the challenge
sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge/
echo 'OK' | sudo tee /var/www/letsencrypt/.well-known/acme-challenge/ping.txt
Reusable snippet, /etc/nginx/snippets/letsencrypt-acme-challenge.conf:
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
# Must match the webroot-path in /etc/letsencrypt/cli.ini.
# Use root, NOT alias. Files are served from
# /var/www/letsencrypt/.well-known/acme-challenge/
root /var/www/letsencrypt;
}
# Return 404 (not 403) for the bare directory. Trailing slash is required.
location = /.well-known/acme-challenge/ {
return 404;
}
Include it from both the 80 and 443 server blocks:
server {
listen 80;
listen [::]:80;
include /etc/nginx/snippets/letsencrypt-acme-challenge.conf;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
include /etc/nginx/snippets/letsencrypt-acme-challenge.conf;
}
Issue a certificate with the nginx plugin (see also docs/linux/tls-certbot.md):
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com --email [email protected]
TLS tuning
These are refinements once HTTPS works. With Certbot, most already come from
/etc/letsencrypt/options-ssl-nginx.conf — check before adding duplicates.
HTTP/2
# nginx >= 1.25
listen 443 ssl;
http2 on;
# Older nginx
listen 443 ssl http2;
Confirm with curl -I (look for HTTP/2) or the browser's network panel
(Protocol: h2).
OCSP stapling
Lets the server present the OCSP response so clients skip a round-trip to the CA (OCSP can be slow or blocked on some networks).
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /path/to/fullchain.pem;
After enabling, the first responses may not include a stapled OCSP reply until Nginx has fetched one — wait a few minutes, then test:
openssl s_client -connect example.com:443 -servername example.com \
-status -tlsextdebug < /dev/null 2>&1 | grep -i "OCSP response"
# Working: OCSP Response Status: successful (0x0)
# Not yet: OCSP response: no response sent
Session cache and buffer size
# ~4000 sessions per 1 MB; speeds up returning visitors
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 4h;
# Default 16k; drop to 4k for a snappier first byte on web/REST APIs,
# keep 16k for large downloads.
ssl_buffer_size 4k;
Rate limiting
Concurrent connections and bandwidth (limit_conn)
http {
# Shared-memory zone 'lczten' (10 MB) keyed by client IP.
# limit_conn_zone (http context only) replaced the old limit_zone.
limit_conn_zone $binary_remote_addr zone=lczten:10m;
server {
location / {
limit_conn lczten 10; # max 10 concurrent connections per IP
limit_rate 50k; # per-CONNECTION cap — an IP with 2 conns gets 2 x 50k
}
}
}
Request rate (limit_req)
Caps how many requests an IP may make over time — mitigates CC-style floods.
$binary_remote_addrkeys by client IP.zone=lrzten:10m— 1 MB stores ~16,000 IP states, so 10 MB covers 100k+ IPs.- The rate value must be an integer; for one request per two seconds write
30r/m. Nginx converts the rate to a per-request interval:100r/mbecomes one per 600 ms, so ten requests inside 600 ms means only the first (plus whateverburstallows) is accepted.
http {
limit_req_zone $binary_remote_addr zone=lrzten:10m rate=100r/s;
server {
location / {
# Example semantics for rate=20r/s, burst=5:
# seconds 1-4 at 19 req each -> 25 req in second 5 is allowed.
# 25 req all in second 1 -> the excess over burst returns 503.
# nodelay: without it, burst requests are queued and released at the
# average rate; with it, all are served immediately.
limit_req zone=lrzten burst=5 nodelay;
}
}
}
Static files and caching
Static alias with autoindex
location /static {
alias /root/app/static/;
autoindex on;
}
Simple file server (Basic Auth + autoindex)
Password-protected directory listing with an IP allowlist.
CONFIG_NAME=simple-file-server
USER_NAME=user001
# Which user nginx runs as
ps -o user,group,comm -C nginx
# Shared group so both your user and www-data can manage the files
sudo groupadd webfiles
sudo usermod -aG webfiles ubuntu
sudo usermod -aG webfiles www-data
sudo mkdir /var/www/sfs
sudo namei -l /var/www/sfs # check the whole path is traversable
sudo chown -R root:webfiles /var/www/sfs
sudo chmod -R 750 /var/www/sfs
# Basic-auth credentials (second command prompts for the password)
sudo sh -c "echo -n '$USER_NAME:' >> /etc/nginx/.htpasswd"
sudo sh -c "openssl passwd -apr1 >> /etc/nginx/.htpasswd"
/etc/nginx/sites-available/simple-file-server:
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/sfs;
server_name files.example.com;
location / {
auth_basic "You need to login";
auth_basic_user_file /etc/nginx/.htpasswd;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
allow 122.0.0.0/24;
deny all;
}
}
sudo ln -s /etc/nginx/sites-available/$CONFIG_NAME /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
curl -v -u USERNAME:PASSWORD -O http://files.example.com/file.txt
Disable caching (development)
Avoids constantly hard-refreshing while debugging:
location ~ .*\.(css|js|swf|php|htm|html)$ {
add_header Cache-Control no-store;
add_header Pragma no-cache;
}
Browser caching with expires (production)
For rarely-changing assets, set an expiry so browsers serve from local cache without re-requesting — cuts bandwidth and server load.
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
expires 30d; # images change rarely; shorten if they update often
}
location ~ .*\.(js|css)$ {
expires 10d;
}
Load balancing
For load balancing plus high availability, only the entry node needs TLS.
http {
upstream webserver {
server 172.16.0.11:80;
server 172.16.0.12:80;
}
server {
listen 80;
location / {
proxy_pass http://webserver;
}
}
}
Troubleshooting
Mixed content over HTTPS
A page loaded over HTTPS that pulls a script or stylesheet over http:// is
blocked by the browser as mixed content. The fix is to serve everything over
HTTPS; as a stopgap, have the browser upgrade insecure sub-requests:
add_header Content-Security-Policy "upgrade-insecure-requests";
This helps when an HTTPS site calls an HTTP API on the same origin. It does not fix genuinely HTTP-only third-party resources — those must be moved to HTTPS.
403 Forbidden
Almost always a filesystem-permission problem — see 403 Forbidden on static files.