Nginx
Nginx is an event-driven web server and reverse proxy. Unlike Apache’s process-per-connection model, Nginx handles concurrency through a small, fixed number of worker processes – each managing thousands of connections via non-blocking I/O. Think of it as a switchboard operator: one person handling hundreds of calls simultaneously, routing each to the right destination, rather than hiring a new employee for every caller.
This makes Nginx extremely efficient under load. It is the correct default for serving static files at scale, reverse-proxying to application backends, terminating TLS, and load balancing. It is not a general-purpose application server – that is what sits behind it.
1. Installation
Alpine Linux
apk add nginx
rc-update add nginx default
rc-service nginx start
Arch Linux
sudo pacman -S nginx
sudo systemctl enable --now nginx
Ubuntu / Debian
sudo apt update && sudo apt install nginx
sudo systemctl enable --now nginx
Ubuntu also ships nginx-full (with more modules) and nginx-light (minimal). The default nginx package installs nginx-core.
Rocky Linux / RHEL
# From AppStream (standard)
sudo dnf install nginx
sudo systemctl enable --now nginx
# From the official Nginx repo (latest stable)
sudo tee /etc/yum.repos.d/nginx.repo << 'EOF'
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
EOF
sudo dnf install nginx
sudo systemctl enable --now nginx
Firewall – Open Ports After Install
# Rocky / RHEL (firewalld)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
# Ubuntu (ufw)
sudo ufw allow 'Nginx Full'
# Alpine (iptables)
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
Verify
nginx -v
nginx -V # version + compiled modules + flags
sudo nginx -t # test configuration syntax
curl -I http://localhost # check response headers
2. File and Directory Structure
| Path | Purpose |
| : | : |
| /etc/nginx/nginx.conf | Main configuration file |
| /etc/nginx/conf.d/ | Drop-in server block configs (Alpine, Rocky) |
| /etc/nginx/sites-available/ | Virtual host configs (Ubuntu – inactive) |
| /etc/nginx/sites-enabled/ | Symlinks to active sites (Ubuntu) |
| /etc/nginx/snippets/ | Reusable config fragments (Ubuntu) |
| /etc/nginx/mime.types | MIME type mappings |
| /usr/share/nginx/html/ | Default document root |
| /var/www/html/ | Document root (Ubuntu convention) |
| /var/log/nginx/access.log | Access log |
| /var/log/nginx/error.log | Error log |
| /var/run/nginx.pid | PID file |
| /etc/nginx/dhparam.pem | DH parameters for TLS (you generate this) |
Alpine uses /etc/nginx/http.d/ for virtual hosts instead of conf.d/.
3. Configuration Structure
Nginx’s config is hierarchical. Context blocks nest inside one another:
main context
└── events {}
└── http {}
├── upstream {}
├── server {}
│ ├── location {}
│ └── location {}
└── server {}
Each level inherits directives from its parent. Directives in child blocks override parent values for that scope.
Main Context Directives
user nginx; # worker process user
worker_processes auto; # number of workers (auto = CPU count)
worker_rlimit_nofile 65535; # max open files per worker
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
Events Block
events {
worker_connections 1024; # max simultaneous connections per worker
use epoll; # I/O method (epoll on Linux -- best)
multi_accept on; # accept as many connections as possible
}
HTTP Block
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
4. Server Blocks (Virtual Hosts)
A server block defines how Nginx handles requests for a particular domain and port.
Basic HTTP Server
server {
listen 80;
listen [::]:80; # IPv6
server_name example.com www.example.com;
root /var/www/example.com;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
}
Ubuntu – Enable a Site
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Disable the Default Site (Ubuntu)
sudo rm /etc/nginx/sites-enabled/default
Default Server (catch-all for unmatched domains)
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444; # drop the connection silently
}
5. TLS / HTTPS Configuration
Self-Signed Certificate (for testing)
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/self.key \
-out /etc/nginx/ssl/self.crt \
-subj "/CN=localhost"
Let’s Encrypt with Certbot
# Alpine
apk add certbot certbot-nginx
certbot --nginx -d example.com -d www.example.com
# Arch
sudo pacman -S certbot certbot-nginx
sudo certbot --nginx -d example.com
# Ubuntu
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
# Rocky
sudo dnf install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
Auto-renewal (runs twice daily, only renews when < 30 days remain):
sudo systemctl enable --now certbot.timer # systemd
# Or cron:
echo "0 0,12 * * * root certbot renew --quiet" | sudo tee /etc/cron.d/certbot
HTTPS Server Block (Production)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# TLS hardening
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
# DH parameters (generate once: openssl dhparam -out /etc/nginx/dhparam.pem 2048)
ssl_dhparam /etc/nginx/dhparam.pem;
# Session resumption
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Generate DH Parameters
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048
# 4096-bit for high-security environments (slower)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 4096
6. Reverse Proxy
A reverse proxy receives client requests and forwards them to a backend server (Node.js, Gunicorn, PHP-FPM, etc.).
Basic Reverse Proxy
server {
listen 80;
server_name app.example.com;
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_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
}
Proxy with WebSocket Support
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name ws.example.com;
# ... ssl config ...
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Reusable Proxy Headers Snippet
Create /etc/nginx/snippets/proxy-headers.conf:
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_set_header X-Forwarded-Host $host;
proxy_cache_bypass $http_upgrade;
Include it:
location / {
proxy_pass http://127.0.0.1:3000;
include snippets/proxy-headers.conf;
}
7. Upstream and Load Balancing
The upstream block defines a pool of backend servers. Nginx distributes requests across them.
Round Robin (default)
upstream backend {
server 10.0.0.1:3000;
server 10.0.0.2:3000;
server 10.0.0.3:3000;
}
server {
location / {
proxy_pass http://backend;
}
}
Least Connections
upstream backend {
least_conn;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
IP Hash (sticky sessions)
upstream backend {
ip_hash;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
Weighted
upstream backend {
server 10.0.0.1:3000 weight=3; # gets 3x more requests
server 10.0.0.2:3000 weight=1;
}
Server Parameters
upstream backend {
server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.3:3000 backup; # only used if all primaries fail
keepalive 32; # persistent connections to backend
}
8. Location Blocks
Location blocks match request URIs and define how to handle them.
Match Priorities (in order)
= exact match (highest priority)
^~ prefix match, stop searching if matched
~ regex match (case-sensitive)
~* regex match (case-insensitive)
prefix match (no modifier -- lowest priority, most common)
Examples:
# Exact match -- only /
location = / {
return 200 "root";
}
# Prefix match -- stops regex search if matched
location ^~ /static/ {
root /var/www;
expires 30d;
}
# Regex -- case-sensitive
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
}
# Regex -- case-insensitive
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public";
access_log off;
}
# General prefix -- fallback
location / {
try_files $uri $uri/ /index.html;
}
try_files Logic
location / {
# Try: 1. exact file 2. directory 3. fall back to index.html (SPAs)
try_files $uri $uri/ /index.html;
}
location /api/ {
# Try file, then pass to backend, then 404
try_files $uri @backend;
}
location @backend {
proxy_pass http://127.0.0.1:3000;
}
Named Locations (@)
location / {
try_files $uri @fallback;
}
location @fallback {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
9. PHP-FPM Integration
# Alpine
apk add php83 php83-fpm php83-opcache
# Arch
sudo pacman -S php php-fpm
# Ubuntu
sudo apt install php8.2-fpm php8.2-cli php8.2-common php8.2-mysql
# Rocky
sudo dnf install php php-fpm php-mysqlnd
sudo systemctl enable --now php-fpm
Nginx configuration:
server {
listen 80;
server_name php.example.com;
root /var/www/php.example.com;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
# Rocky/Alpine: unix:/run/php-fpm/www.sock
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}
Check PHP-FPM socket location:
grep "listen = " /etc/php*/php-fpm.d/www.conf
grep "listen = " /etc/php/*/fpm/pool.d/www.conf
10. Static File Serving and Caching
server {
listen 80;
server_name static.example.com;
root /var/www/static;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/json application/xml
application/rss+xml application/atom+xml image/svg+xml;
# Cache static assets aggressively
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(css|js)$ {
expires 1M;
add_header Cache-Control "public";
access_log off;
}
location ~* \.(woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
access_log off;
}
# No cache for HTML
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# Sendfile + optimisation
sendfile on;
tcp_nopush on;
tcp_nodelay on;
location / {
try_files $uri $uri/ =404;
autoindex off;
}
}
11. Rate Limiting
http {
# Define a zone: key = IP, size = 10MB memory, rate = 10 req/sec
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
# burst: allow up to 20 queued requests
# nodelay: don't delay burst requests, process immediately
proxy_pass http://backend;
}
location /login {
limit_req zone=login burst=5;
proxy_pass http://backend;
}
}
}
Connection limiting:
http {
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /download/ {
limit_conn addr 5; # max 5 connections per IP
limit_rate 500k; # throttle bandwidth per connection
}
}
}
12. Access Control
IP Allowlist/Blocklist
location /admin/ {
allow 10.0.0.0/8;
allow 192.168.1.0/24;
allow 203.0.113.50;
deny all;
}
HTTP Basic Authentication
# Install htpasswd
apk add apache2-utils # Alpine
sudo apt install apache2-utils # Ubuntu
sudo dnf install httpd-tools # Rocky
sudo pacman -S apache # Arch
# Create password file
sudo htpasswd -c /etc/nginx/.htpasswd username
sudo htpasswd /etc/nginx/.htpasswd anotheruser # add user (no -c)
location /private/ {
auth_basic "Restricted Area";
auth_basic_user_file /etc/nginx/.htpasswd;
}
Block User Agents
if ($http_user_agent ~* (curl|wget|python|scrapy|libwww)) {
return 403;
}
Block Referrer Spam
if ($http_referer ~* (spam-site\.com|bad-actor\.net)) {
return 403;
}
13. Redirects and Rewrites
Permanent Redirect (301)
# Redirect www to non-www
server {
listen 80;
server_name www.example.com;
return 301 https://example.com$request_uri;
}
Rewrite
# Old URL structure → new
rewrite ^/old-path/(.*)$ /new-path/$1 permanent;
# Force trailing slash on directories
rewrite ^([^.]*[^/])$ $1/ permanent;
rewrite vs return
# return is faster -- no regex engine, just string matching
return 301 https://example.com$request_uri;
# rewrite is for regex transformations
rewrite ^/old/(.*) /new/$1 last;
rewrite flags:
last– stop processing rewrites, start new location matchingbreak– stop processing rewrites, use current locationpermanent– 301 redirectredirect– 302 redirect
14. Logging
Log Formats
http {
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
log_format json_combined escape=json
'{"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status":$status,'
'"bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time",'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent"}';
access_log /var/log/nginx/access.log main;
access_log /var/log/nginx/access.json.log json_combined;
}
Per-Server Logging
server {
access_log /var/log/nginx/example.com.access.log main;
error_log /var/log/nginx/example.com.error.log warn;
}
Disable Access Log for Static Assets
location ~* \.(jpg|css|js|ico)$ {
access_log off;
}
Error Log Levels
debug | info | notice | warn | error | crit | alert | emerg
error_log /var/log/nginx/error.log warn;
Log Rotation
Create /etc/logrotate.d/nginx:
/var/log/nginx/*.log {
daily
missingok
rotate 52
compress
delaycompress
notifempty
create 640 nginx adm
sharedscripts
postrotate
if [ -f /var/run/nginx.pid ]; then
kill -USR1 `cat /var/run/nginx.pid`
fi
endscript
}
15. Service Management
systemd (Arch, Ubuntu, Rocky)
sudo systemctl start nginx
sudo systemctl stop nginx
sudo systemctl restart nginx
sudo systemctl reload nginx # graceful reload -- no dropped connections
sudo systemctl status nginx
sudo systemctl enable nginx
sudo systemctl disable nginx
OpenRC (Alpine)
sudo rc-service nginx start
sudo rc-service nginx stop
sudo rc-service nginx restart
sudo rc-service nginx reload
sudo rc-service nginx status
sudo rc-update add nginx default
sudo rc-update del nginx default
Signal-Based Control
sudo nginx -s reload # graceful reload (re-reads config, no downtime)
sudo nginx -s reopen # reopen log files (after rotation)
sudo nginx -s quit # graceful shutdown (finish current requests)
sudo nginx -s stop # fast shutdown (drops connections)
Configuration Testing
sudo nginx -t # test syntax
sudo nginx -T # test + dump entire config
sudo nginx -t -c /path/to/nginx.conf # test a specific config file
16. Nginx Variables Reference
Key built-in variables usable in configs:
$host request Host header (or server_name if absent)
$hostname server's hostname
$remote_addr client IP
$remote_user HTTP auth username
$request full request line (method + URI + protocol)
$request_uri full URI with query string
$uri URI (without query string, may be rewritten)
$args query string
$query_string same as $args
$scheme http or https
$server_protocol HTTP/1.0 or HTTP/1.1 or HTTP/2.0
$server_name matched server_name value
$server_port port server received request on
$status response status code
$body_bytes_sent bytes sent in response body
$request_time request processing time in seconds
$upstream_addr upstream server address
$upstream_response_time time to get upstream response
$http_user_agent User-Agent header
$http_referer Referer header
$http_x_forwarded_for X-Forwarded-For header
$ssl_protocol TLS protocol version
$ssl_cipher TLS cipher suite
$time_local current time in local timezone
$time_iso8601 current time in ISO 8601
$pid worker process PID
$connection connection serial number
17. Performance Tuning
# worker_processes: match CPU cores
worker_processes auto;
worker_cpu_affinity auto;
# max open files per worker
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# File transfer optimisation
sendfile on;
tcp_nopush on;
tcp_nodelay on;
aio on; # async I/O (for large files)
directio 512; # bypass OS cache for files > 512KB
# Keepalive
keepalive_timeout 65;
keepalive_requests 1000;
# Buffers
client_body_buffer_size 128k;
client_max_body_size 10m;
client_header_buffer_size 1k;
large_client_header_buffers 4 16k;
# Timeouts
client_body_timeout 12;
client_header_timeout 12;
send_timeout 10;
reset_timedout_connection on;
# Gzip
gzip on;
gzip_min_length 1000;
gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript;
# Open file cache
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
18. Nginx for Incus / Container Deployments
When proxying to Incus containers, containers have their own IPs on the bridge network.
upstream containers {
server 10.89.0.10:3000; # container IP
server 10.89.0.11:3000;
}
server {
listen 443 ssl http2;
server_name app.example.com;
# ... TLS config ...
location / {
proxy_pass http://containers;
include snippets/proxy-headers.conf;
# Pass real client IP through
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Get container IPs:
incus list --format csv -c n4 # name + IPv4
19. Troubleshooting
# Test config
sudo nginx -t
# View live error log
sudo tail -f /var/log/nginx/error.log
# View access log
sudo tail -f /var/log/nginx/access.log
# Check what's listening on port 80/443
sudo ss -tlnp | grep ':80\|:443'
# Check nginx process
ps aux | grep nginx
sudo systemctl status nginx
# Check permissions on document root
ls -la /var/www/example.com
# Verify TLS certificate
openssl s_client -connect example.com:443 -servername example.com
# Check certificate expiry
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -noout -dates
# Trace HTTP request
curl -v http://example.com/path
# Check gzip
curl -H "Accept-Encoding: gzip" -I http://example.com/style.css