Nginx Configuration
My nginx config is split across a few files so the parts I actually edit stay small. Everything is owned by my user, so no sudo is needed for day-to-day changes — see Running Nginx as Non-Root User.
Layout
example.com.conf # http{} shell: logging, gzip, ssl, cache policy, includesmaps/├── access.conf # who may reach what <- make something public├── auth.conf # basic auth realms + password files├── subdomains.conf # subdomain -> backend <- add a service└── trusted-ips.conf # IPs that bypass the public/private checkservers/├── http-reject.conf # :80, refuses everything├── pages.conf # *.pages.example.com, one static folder per subdomain├── proxy.conf # *.example.com, reverse proxy to backends└── main-site.conf # example.com, the apex static site/etc/nginx/nginx.conf is root-owned and does nothing but pull this in:
include /etc/nginx/modules-enabled/*.conf;include /home/plant/nginx/example.com.conf;
events { worker_connections 768;}include resolves relative paths against nginx’s prefix (/usr/share/nginx), not against the file doing the including. Always use absolute paths.
Common tasks
Add a subdomain
One line in maps/subdomains.conf. A bare number is proxied to http://127.0.0.1:<port>; anything starting with http is used as-is.
newapp.example.com 4000;otherapp.example.com https://127.0.0.1:8443;New subdomains are private by default — only trusted IPs reach them.
Make something public
Add it to $is_domain_public in maps/access.conf. To carve one host back out of a public wildcard, add it to $is_public_excluded — that check runs afterwards, so it wins.
Password-protect a site
Add the host to both maps in maps/auth.conf: $is_require_auth sets the realm name, $auth_file picks the password file. A more specific path can be set to off to leave it open.
Apply changes
nginx -t && systemctl --user reload nginxReload keeps the master process and swaps the workers, so there is no downtime.
nginx -t is the only thing that catches a broken config in advance. A failed reload is harmless, but nginx refuses to start on a bad config — so a reboot with an untested config takes every service down at once.
The files
Main file
pid /home/plant/nginx/nginx.pid;
http { # Access log format: timestamp [ip] - uri log_format custom_log '$time_iso8601 [$remote_addr] - $uri'; access_log /home/plant/nginx/custom_access.log custom_log;
# Extract the subdomain from *.pages.example.com requests # Example: notes.pages.example.com → $pages_subdomain = "notes" map $host $pages_subdomain { ~^(?P<subdomain>[^.]+)\.pages\.example\.com$ $subdomain; default ""; }
include /home/plant/nginx/maps/*.conf;
# Cache policy per file type - the value is used by add_header in the server files # Built asset names are not content-hashed, so html/css/js must revalidate every time map $uri $cache_control { "~*\.(?:woff2?|ttf|eot)$" "public, max-age=31536000, immutable"; # Fonts, content never changes "~*\.(?:pf_fragment|pf_meta|pf_index)$" "public, max-age=31536000, immutable"; # Pagefind, content-addressed names "~*\.(?:png|jpe?g|webp|gif|svg|ico)$" "public, max-age=604800"; # Images default "no-cache"; # Revalidate against the ETag, cheap 304s }
sendfile on; tcp_nopush on; types_hash_max_size 4096; types_hash_bucket_size 128;
include /etc/nginx/mime.types; default_type application/octet-stream;
gzip on; gzip_comp_level 9; # 1 (fastest) to 9 (best compression) - 6 is the sweet spot gzip_min_length 1024; # don't bother compressing tiny files gzip_proxied any; # also compress responses for proxied requests gzip_vary on; gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml font/woff font/woff2;
client_max_body_size 0; proxy_request_buffering off;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_cache shared:SSL:10m; ssl_session_timeout 1h; ssl_session_tickets on;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256'; ssl_prefer_server_ciphers on;
include /home/plant/nginx/servers/*.conf;}maps/subdomains.conf
# Map each subdomain to its backend port or full URL map $host $port_mapping { # Plain port, proxied to http://127.0.0.1:<port> #app.example.com 3000; #dashboard.example.com 85; # Full URL, when the backend speaks https #dns.example.com https://127.0.0.1:444; # DNS over HTTPS # Full URL, when the backend is another machine on the LAN #desktop.example.com http://192.168.1.100:3000; # Local dev machine # Wildcard, one backend for a whole subdomain tree #~^(.*)\.sites\.example\.com 2347; default ""; }
# If $port_mapping is already a full URL, use it as-is; otherwise wrap it as http://127.0.0.1:$port map $port_mapping $backend_url { "~*^http" $port_mapping; default http://127.0.0.1:$port_mapping; }maps/access.conf
# Subdomains accessible to the public internet (no IP restriction) map $host $is_domain_public { #public-app.example.com 1; ~^.+\.pages\.example\.com$ 1; # All *.pages.example.com subdomains default 0; }
# Subdomains excluded from public access even if matched by a public wildcard above map $host $is_public_excluded { #private-site.pages.example.com 1; default 0; }maps/auth.conf
# Paths and subdomains that require HTTP basic auth - value becomes the auth realm name # Key is $host$uri, so you can match on host, path, or both map $host$uri $is_require_auth { ~^private-site\.pages\.example\.com/public off; ~^private-site\.pages\.example\.com "Restricted Area"; default off; }
# Password file to use for each protected host or path (paired with $is_require_auth above) map $host$uri $auth_file { ~^private-site\.pages\.example\.com /etc/nginx/.htpasswd_private; default /etc/nginx/.htpasswd; }maps/trusted-ips.conf
update-nginx-ip.sh rewrites the address under the [AUTO-UPDATE] marker, so that comment has to stay.
# IPs allowed to access non-public subdomains (local network + server's own public IP) # The server's public IP is kept up to date automatically - do not remove [AUTO-UPDATE] map $remote_addr $is_trusted_ip { "~*^192\.168\." 1; # Local network "~*^172\." 1; # Docker network # [AUTO-UPDATE] 203.0.113.42 1; # Server's own public IP default 0; }servers/http-reject.conf
# HTTP - rejects all traffic; HTTPS is required server { listen 80;
server_name example.com *.example.com;
error_page 403 /403.html;
location / { return 403; }
location = /403.html { access_log off; alias /run/media/plant/SAMSUNG/SERVER/public/403.html; } }servers/pages.conf
Serves /home/plant/pages/<subdomain> for any *.pages.example.com host. Drop an empty _spa file in a site’s folder to make unknown paths fall back to index.html instead of 404.html.
# HTTPS - *.pages.example.com - serves a static folder per subdomain server { listen 443 ssl default_server; # Fallback for hosts matching no server_name http2 on;
server_name *.pages.example.com;
root /home/plant/pages/$pages_subdomain;
error_page 404 = @fallback;
set $allowed 0;
location / { if ($is_domain_public = 1) { set $allowed 1; }
if ($is_public_excluded = 1) { set $allowed 0; }
if ($is_trusted_ip = 1) { set $allowed 1; }
if ($allowed = 0) { access_log /home/plant/nginx/custom_access.log custom_log; return 403; }
auth_basic $is_require_auth; auth_basic_user_file $auth_file;
try_files $uri $uri/ =404;
add_header Cache-Control $cache_control always; }
location @fallback { if (-f $document_root/_spa) { rewrite ^ /index.html break; }
# no _spa file -> serve your custom 404 try_files /404.html =404; }
location = /404.html { if ($is_trusted_ip = 1) { access_log off; }
internal; } }This block carries default_server, so it also answers requests whose Host matches no other server_name. Without it the first block parsed would win that role, which would depend on filename sort order.
servers/proxy.conf
# HTTPS - *.example.com - reverse proxy to backend services server { listen 443 ssl; http2 on;
server_name *.example.com;
add_header X-XSS-Protection "0"; # Intentionally disabled - header is obsolete and counterproductive add_header X-Content-Type-Options "nosniff";
set $allowed 0;
location / { if ($is_domain_public = 1) { set $allowed 1; }
if ($is_public_excluded = 1) { set $allowed 0; }
if ($is_trusted_ip = 1) { set $allowed 1; }
if ($allowed = 0) { access_log /home/plant/nginx/custom_access.log custom_log; return 403; }
# No backend mapped for this subdomain - checked after the 403 above so an # untrusted client cannot tell an unmapped subdomain from a private one if ($port_mapping = "") { return 404; }
auth_basic $is_require_auth; auth_basic_user_file $auth_file;
# This is important for websockets proxy_http_version 1.1; proxy_redirect off;
access_log off; proxy_pass $backend_url; proxy_pass_request_headers on; 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_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header X-Requested-With $http_x_requested_with; proxy_set_header X-Content-Type-Options $http_x_content_type_options; } }The unmapped-subdomain check returns 404, but only after the 403. The other order would let an untrusted visitor tell “no such subdomain” (404) from “exists but private” (403) and enumerate every service.
servers/main-site.conf
# HTTPS - example.com - serves the main public static website server { listen 443 ssl; http2 on;
server_name example.com;
root /run/media/plant/SAMSUNG/SERVER/public;
error_page 404 /404.html;
location / { if ($is_trusted_ip = 1) { access_log off; }
try_files $uri $uri/ =404;
# add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; # add_header Pragma "no-cache"; # add_header Expires "0"; }
location = /404.html { if ($is_trusted_ip = 1) { access_log off; }
internal; }
# Directory listing for /static/, served from the files folder location /static/ { if ($is_trusted_ip = 1) { access_log off; }
autoindex on; alias /run/media/plant/SAMSUNG/SERVER/public/files/;
add_header X-Content-Type-Options nosniff; add_after_body /custom-file-listing.html; # Injects custom styles into the directory listing page }
# Serves the CSS used by the directory listing above location /custom.css { alias /run/media/plant/SAMSUNG/SERVER/public/custom-file-listing.html; } }Caching
$cache_control in the main file sets a policy per file type, applied with a single add_header Cache-Control $cache_control always; in each server block.
| Files | Policy | Why |
|---|---|---|
| html, css, js | no-cache | Built asset names are not content-hashed, so they must revalidate |
| woff2, ttf, eot | max-age=31536000, immutable | A font at a given name never changes |
.pf_* (pagefind) | max-age=31536000, immutable | Content-addressed filenames |
| images | max-age=604800 | Change rarely, and a stale one is harmless |
no-cache does not disable caching — it means “reuse it, but revalidate first”, so unchanged files come back as a cheap 304 off the ETag.
Do not do this with a location ~* \.(css|js)$ block. A regex location takes precedence over location /, so those requests would skip the access checks and basic auth entirely.
Create Password Authentication for Nginx
1. Create .htpasswd file and first user
sudo htpasswd -c /etc/nginx/.htpasswd username- Replace
usernamewith desired login name - You will be prompted to enter a password
-ccreates the file (use only for first user)
To have htpasswd you need to install httpd-tools, apache2-utils, or apache depending on your distro
2. Add additional users (no -c)
sudo htpasswd /etc/nginx/.htpasswd anotheruser3. Verify file contents
cat /etc/nginx/.htpasswdExpected format:
username:$apr1$randomhash...anotheruser:$apr1$randomhash...