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 matching
  • break – stop processing rewrites, use current location
  • permanent – 301 redirect
  • redirect – 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