Skip to main content

Nginx

A copy-paste reference for common Nginx setups. Domains are normalized to example.com-style placeholders — replace before use.

Contents

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.

ModifierMeaning
=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.

  • listen may 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_server per 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:

  1. Exact name (multiple exact matches → the first wins).
  2. Leading wildcard, e.g. *.example.com (longest match wins).
  3. Trailing wildcard, e.g. www.* (longest match wins).
  4. Regular expression, ~ prefix (the first matching one wins).
  5. 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).

VariableMeaning
$http_user_agentClient user agent (usually the browser)
$http_x_forwarded_forReal client IP when requests pass through a proxy
$http_refererThe 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 / Connection headers are the only difference from a normal HTTP reverse proxy.
  • The map forwards Connection: upgrade only when the client actually sent an Upgrade header — 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

DirectiveDefaultContextMeaning
proxy_read_timeout60shttp, server, locationRead timeout toward the upstream — the max interval between two successive read operations, not the whole-response time
proxy_send_timeout60shttp, server, locationWrite 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_addr keys 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/m becomes one per 600 ms, so ten requests inside 600 ms means only the first (plus whatever burst allows) 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.