Skip to content

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

/home/plant/nginx/
example.com.conf # http{} shell: logging, gzip, ssl, cache policy, includes
maps/
├── 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 check
servers/
├── 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:

/etc/nginx/nginx.conf
include /etc/nginx/modules-enabled/*.conf;
include /home/plant/nginx/example.com.conf;
events {
worker_connections 768;
}
Warning

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

Terminal window
nginx -t && systemctl --user reload nginx

Reload keeps the master process and swaps the workers, so there is no downtime.

Important

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

example.com.conf
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

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

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

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.

maps/trusted-ips.conf
# 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

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.

servers/pages.conf
# 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;
}
}
Note

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

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;
}
}
Note

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

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.

Warning

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 username with desired login name
  • You will be prompted to enter a password
  • -c creates the file (use only for first user)
Note

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 anotheruser

3. Verify file contents

cat /etc/nginx/.htpasswd

Expected format:

username:$apr1$randomhash...
anotheruser:$apr1$randomhash...