Nginx: From Zero to Production

Why This Matters

You have just deployed a web application. It works perfectly on localhost:3000. Now you need to make it available to the world on port 80 and 443, serve static files efficiently, handle thousands of concurrent connections, add HTTPS, and set up proper logging. You need a web server.

Nginx (pronounced "engine-x") is the most popular web server on the internet, powering over a third of all websites. It is used by Netflix, Cloudflare, WordPress.com, and countless others. It is fast, lightweight, and incredibly versatile -- it can serve static files, act as a reverse proxy, terminate TLS, and load-balance traffic.

This chapter takes you from installing Nginx to having a production-ready configuration. By the end, you will understand its architecture, know how to write server blocks, serve static content, and configure it securely.


Try This Right Now

On any Debian/Ubuntu system:

$ sudo apt update && sudo apt install -y nginx
$ sudo systemctl start nginx
$ curl -I http://localhost

You should see:

HTTP/1.1 200 OK
Server: nginx/1.24.0
Content-Type: text/html
...

Congratulations. You have a running web server. Now let us understand everything behind that simple response.

Distro Note: On RHEL/CentOS/Fedora, use sudo dnf install -y nginx. On Arch, use sudo pacman -S nginx. The package is called nginx everywhere, but the default configuration paths differ (we will cover this).


Nginx Architecture

Most traditional web servers (like Apache's prefork MPM) spawn a new process or thread for every connection. At 10,000 concurrent connections, you have 10,000 processes eating memory. Nginx takes a fundamentally different approach.

Master and Worker Processes

┌──────────────────────────────────────────────────────────┐
│                    Nginx Process Model                    │
│                                                          │
│  ┌──────────────────┐                                    │
│  │  Master Process   │  (runs as root)                   │
│  │  - Reads config   │  - PID 1234                       │
│  │  - Manages workers│  - Binds to ports 80/443          │
│  │  - Handles signals│  - Does NOT serve requests        │
│  └───────┬──────────┘                                    │
│          │                                               │
│    ┌─────┼──────────────────┐                            │
│    │     │                  │                             │
│  ┌─┴──┐ ┌┴───┐ ┌────┐ ┌────┐                            │
│  │ W1 │ │ W2 │ │ W3 │ │ W4 │  (run as www-data/nginx)   │
│  └────┘ └────┘ └────┘ └────┘                             │
│  Worker processes handle ALL connections                  │
│  Each worker uses an event loop (epoll/kqueue)            │
│  One worker can handle thousands of connections            │
└──────────────────────────────────────────────────────────┘
  • The master process runs as root (it needs to bind to ports 80 and 443). It reads the configuration, creates worker processes, and handles signals (reload, stop).
  • Worker processes do the actual work. They run as an unprivileged user (www-data on Debian, nginx on RHEL). Each worker uses an event-driven, non-blocking model with epoll (Linux) or kqueue (BSD).

The key insight: a single worker process can handle thousands of concurrent connections because it never blocks waiting for I/O. It uses the kernel's event notification system to efficiently multiplex connections.

Hands-On: See the Processes

# View master and worker processes
$ ps aux | grep nginx
root       1234  ...  nginx: master process /usr/sbin/nginx
www-data   1235  ...  nginx: worker process
www-data   1236  ...  nginx: worker process
www-data   1237  ...  nginx: worker process
www-data   1238  ...  nginx: worker process

# Count worker processes (should match CPU cores by default)
$ ps aux | grep 'nginx: worker' | grep -v grep | wc -l
4

# Check how many CPU cores you have
$ nproc
4

By default, Nginx creates one worker per CPU core. This is optimal -- no context-switching overhead between workers.

Think About It: Why does the master process run as root while workers run as an unprivileged user? What is the security benefit? (Hint: think about what happens if a worker is compromised.)


Configuration File Structure

Nginx configuration lives in /etc/nginx/. Understanding the file layout is essential.

Debian/Ubuntu Layout

/etc/nginx/
├── nginx.conf              # Main config (global settings)
├── sites-available/        # All site configs (available but not active)
│   └── default             # Default site config
├── sites-enabled/          # Symlinks to active site configs
│   └── default -> ../sites-available/default
├── conf.d/                 # Additional config fragments
├── snippets/               # Reusable config snippets
├── modules-available/      # Dynamic module configs
├── modules-enabled/        # Active module symlinks
├── mime.types              # Maps file extensions to MIME types
└── fastcgi_params          # FastCGI parameter defaults

RHEL/CentOS/Fedora Layout

/etc/nginx/
├── nginx.conf              # Main config (includes everything)
├── conf.d/                 # Site configs go here (*.conf auto-loaded)
│   └── default.conf        # Default site
├── default.d/              # Additional defaults
└── mime.types              # MIME type mappings

Distro Note: RHEL-based distributions do not use sites-available/sites-enabled. Instead, they drop .conf files directly into conf.d/. Both approaches work. The Debian style gives you a way to have configs "available but not enabled" without deleting them.

The Main Configuration File

$ cat /etc/nginx/nginx.conf

Here is a typical nginx.conf with annotations:

# --- Global Context ---
user www-data;                      # Worker process user
worker_processes auto;              # One worker per CPU core
pid /run/nginx.pid;                 # PID file location
error_log /var/log/nginx/error.log; # Global error log

# --- Events Context ---
events {
    worker_connections 1024;        # Max connections per worker
    # Total max connections = worker_processes x worker_connections
    # With 4 workers: 4 x 1024 = 4096 simultaneous connections
}

# --- HTTP Context ---
http {
    # MIME types
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # Logging
    access_log /var/log/nginx/access.log;

    # Performance
    sendfile on;                    # Efficient file serving (kernel-level copy)
    tcp_nopush on;                  # Send headers and file data together
    tcp_nodelay on;                 # Disable Nagle's algorithm
    keepalive_timeout 65;           # Keep connections alive for 65 seconds

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;

    # Include site configs
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

The hierarchy of contexts:

Main context (global)
├── events { }          # Connection handling settings
└── http { }            # All HTTP-related settings
    ├── upstream { }    # Backend server pools
    └── server { }      # Virtual host (one per site)
        └── location { }  # URL pattern matching rules

Server Blocks (Virtual Hosts)

A server block is Nginx's equivalent of Apache's VirtualHost. It defines how to handle requests for a specific domain name (or IP/port combination).

Your First Server Block

Create a new site configuration:

$ sudo nano /etc/nginx/sites-available/mysite
server {
    listen 80;                          # Listen on port 80
    server_name mysite.example.com;     # Respond to this domain

    root /var/www/mysite;               # Document root
    index index.html index.htm;         # Default files to serve

    location / {
        try_files $uri $uri/ =404;      # Try file, then directory, then 404
    }
}

Enable it and create the content:

# Create the document root
$ sudo mkdir -p /var/www/mysite

# Create a simple page
$ echo '<h1>Hello from mysite!</h1>' | sudo tee /var/www/mysite/index.html

# Enable the site (Debian/Ubuntu)
$ sudo ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/

# Test the configuration
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

# Reload Nginx (no downtime!)
$ sudo systemctl reload nginx

# Test it
$ curl -H "Host: mysite.example.com" http://localhost
<h1>Hello from mysite!</h1>

Distro Note: On RHEL/CentOS/Fedora, skip the symlink step. Instead, save your config directly to /etc/nginx/conf.d/mysite.conf (must end in .conf).

Multiple Sites on One Server

# /etc/nginx/sites-available/blog
server {
    listen 80;
    server_name blog.example.com;
    root /var/www/blog;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

# /etc/nginx/sites-available/api
server {
    listen 80;
    server_name api.example.com;
    root /var/www/api;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Nginx uses the Host header (from Chapter 43) to determine which server block handles each request. If no server_name matches, Nginx uses the default server -- the first server block it encounters, or one explicitly marked with default_server:

server {
    listen 80 default_server;
    server_name _;              # Underscore = catch-all / invalid name
    return 444;                 # Close connection without response
}

Location Blocks

Location blocks control what happens when a request matches a specific URL pattern. They are the core routing mechanism in Nginx.

Location Matching Rules

# Exact match (highest priority)
location = /health {
    return 200 "OK\n";
}

# Prefix match
location /images/ {
    root /var/www/static;       # Serves /var/www/static/images/
}

# Regular expression match (case-sensitive)
location ~ \.php$ {
    # Handle PHP files
}

# Regular expression match (case-insensitive)
location ~* \.(jpg|jpeg|png|gif)$ {
    expires 30d;                # Cache images for 30 days
}

# Preferential prefix match (like prefix but beats regex)
location ^~ /static/ {
    root /var/www;
}

Location Priority Order

Nginx evaluates locations in this order:

1.  = /exact/path          (exact match -- checked first, stops immediately)
2.  ^~ /prefix/path        (preferential prefix -- stops, skips regex)
3.  ~ or ~* regex          (regex -- first match wins)
4.  /prefix/path           (regular prefix -- longest match wins)

Hands-On: Understanding Location Matching

server {
    listen 80;
    server_name test.example.com;

    # Matches ONLY /
    location = / {
        return 200 "exact root\n";
    }

    # Matches /api, /api/, /api/anything
    location /api/ {
        return 200 "prefix api\n";
    }

    # Matches any .json file
    location ~* \.json$ {
        return 200 "regex json\n";
    }

    # Matches /static/ and below, skips regex check
    location ^~ /static/ {
        return 200 "preferential static\n";
    }

    # Catch-all
    location / {
        return 200 "default catch-all\n";
    }
}

Test it:

$ curl http://localhost/              # "exact root"
$ curl http://localhost/api/users     # "prefix api"
$ curl http://localhost/data.json     # "regex json"
$ curl http://localhost/api/data.json # "prefix api" (prefix matched longer)
$ curl http://localhost/static/x.json # "preferential static" (^~ beats regex)
$ curl http://localhost/anything      # "default catch-all"

Think About It: What happens if you request /api/data.json? The /api/ prefix matches, and the .json regex also matches. Which wins? (Answer: the longer prefix /api/ wins because prefix match length is compared first; the regex is only tried if no preferential prefix matches.)


The try_files Directive

try_files is one of the most used directives. It tells Nginx to try several options in order:

location / {
    try_files $uri $uri/ /index.html;
}

This means:

  1. Try to serve the file at $uri (the requested path)
  2. If not found, try it as a directory $uri/ (and serve its index file)
  3. If still not found, serve /index.html (a fallback -- perfect for SPAs)
# For a traditional site (return 404 if not found)
location / {
    try_files $uri $uri/ =404;
}

# For a single-page application (always serve index.html)
location / {
    try_files $uri $uri/ /index.html;
}

# For a PHP application (pass to PHP-FPM if not a static file)
location / {
    try_files $uri $uri/ /index.php?$query_string;
}

Serving Static Files

Nginx excels at serving static files. Here is a production-ready static file configuration:

server {
    listen 80;
    server_name www.example.com;
    root /var/www/example;

    # Homepage
    location / {
        try_files $uri $uri/ =404;
    }

    # Static assets with long cache
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;         # Don't log static file requests
    }

    # Deny access to hidden files
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Hands-On: Build and Serve a Static Site

# Create site structure
$ sudo mkdir -p /var/www/demo/{css,js,images}

# Create HTML
$ sudo tee /var/www/demo/index.html > /dev/null << 'EOF'
<!DOCTYPE html>
<html>
<head>
    <title>Nginx Demo</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <h1>Nginx is serving this page!</h1>
    <p>Served at: <span id="time"></span></p>
    <script src="/js/app.js"></script>
</body>
</html>
EOF

# Create CSS
$ sudo tee /var/www/demo/css/style.css > /dev/null << 'EOF'
body { font-family: sans-serif; max-width: 800px; margin: 50px auto; }
h1 { color: #2d8cf0; }
EOF

# Create JS
$ sudo tee /var/www/demo/js/app.js > /dev/null << 'EOF'
document.getElementById('time').textContent = new Date().toLocaleString();
EOF

# Set ownership
$ sudo chown -R www-data:www-data /var/www/demo

# Create Nginx config
$ sudo tee /etc/nginx/sites-available/demo > /dev/null << 'EOF'
server {
    listen 80;
    server_name demo.local;
    root /var/www/demo;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~* \.(css|js|png|jpg|gif|ico)$ {
        expires 7d;
        add_header Cache-Control "public";
    }
}
EOF

# Enable and reload
$ sudo ln -sf /etc/nginx/sites-available/demo /etc/nginx/sites-enabled/
$ sudo nginx -t && sudo systemctl reload nginx

# Test
$ curl -H "Host: demo.local" http://localhost
$ curl -I -H "Host: demo.local" http://localhost/css/style.css

Access and Error Logs

Nginx has excellent logging. Understanding the logs is critical for debugging and monitoring.

Access Log

Every request is logged to the access log (default: /var/log/nginx/access.log):

93.184.216.34 - - [15/Jan/2025:10:30:15 +0000] "GET /api/users HTTP/1.1" 200 1234 "https://example.com/" "Mozilla/5.0..."

Fields: remote_addr - remote_user [time] "request" status body_bytes_sent "referer" "user_agent"

Custom Log Formats

http {
    # Define a custom log format
    log_format detailed '$remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_referer" "$http_user_agent" '
                        'rt=$request_time urt=$upstream_response_time';

    # Use it in a server block
    server {
        access_log /var/log/nginx/mysite-access.log detailed;
    }
}

The $request_time and $upstream_response_time fields are gold for performance debugging:

  • $request_time -- total time Nginx spent handling the request
  • $upstream_response_time -- how long the backend took to respond

Error Log

The error log captures warnings, errors, and debugging information:

# View recent errors
$ sudo tail -20 /var/log/nginx/error.log

# Watch errors in real time
$ sudo tail -f /var/log/nginx/error.log

You can control the error log verbosity:

error_log /var/log/nginx/error.log warn;    # warn, error, crit, alert, emerg
error_log /var/log/nginx/debug.log debug;   # Very verbose, for troubleshooting

Per-Site Logging

server {
    server_name api.example.com;
    access_log /var/log/nginx/api-access.log;
    error_log /var/log/nginx/api-error.log;
}

Hands-On: Analyzing Logs

# Top 10 most requested URLs
$ awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# Top 10 client IPs
$ awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# Count of each status code
$ awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

# All 5xx errors
$ awk '$9 >= 500' /var/log/nginx/access.log

# Requests per minute (rough)
$ awk '{print $4}' /var/log/nginx/access.log | cut -d: -f1-3 | uniq -c | tail -10

Testing Configuration and Reload vs Restart

Always Test Before Reloading

# Test configuration syntax
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

# If there is an error:
$ sudo nginx -t
nginx: [emerg] unknown directive "sevrer" in /etc/nginx/sites-enabled/demo:1
nginx: configuration file /etc/nginx/nginx.conf test failed

Always run nginx -t before reload. A bad config on reload will be rejected, but it is better to catch it explicitly.

Reload vs Restart

ActionCommandDowntime?What Happens
Reloadsudo systemctl reload nginxNoMaster re-reads config, spawns new workers, old workers finish existing requests, then exit
Restartsudo systemctl restart nginxBriefFull stop then start. All connections dropped

In production, always use reload. Restart is only needed when changing fundamental settings like the user directive or loading new binary modules.

# The production workflow:
$ sudo nano /etc/nginx/sites-available/mysite    # Edit config
$ sudo nginx -t                                   # Test
$ sudo systemctl reload nginx                     # Apply (zero downtime)

Basic Security Headers

A production Nginx configuration should include security headers:

server {
    listen 80;
    server_name www.example.com;

    # Prevent clickjacking
    add_header X-Frame-Options "SAMEORIGIN" always;

    # Prevent MIME type sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # Enable XSS protection
    add_header X-XSS-Protection "1; mode=block" always;

    # Referrer policy
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Content Security Policy (customize per site)
    add_header Content-Security-Policy "default-src 'self';" always;

    # Hide Nginx version number
    server_tokens off;

    # Prevent access to hidden files (.git, .env, etc.)
    location ~ /\. {
        deny all;
        return 404;
    }

    # ... rest of config
}

Test the headers:

$ curl -I http://localhost
HTTP/1.1 200 OK
Server: nginx                          # No version number (server_tokens off)
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block

Safety Warning: Never expose .git directories, .env files, or backup files through your web server. The location ~ /\. block above prevents this. Always test with curl http://yoursite/.git/config to verify.


Debug This

You have configured a new server block, reloaded Nginx, but all requests are returning the default welcome page instead of your site.

Your config:

server {
    listen 80;
    server_name myapp.example.com;
    root /var/www/myapp;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Debugging steps:

# 1. Is the config actually loaded?
$ sudo nginx -t
# If this fails, the config file is not being included.
# Check: Is the file in sites-enabled (symlinked)?
$ ls -la /etc/nginx/sites-enabled/

# 2. Is the Host header correct?
$ curl -H "Host: myapp.example.com" http://localhost
# If this works, DNS is the problem (the domain isn't pointing to your server)

# 3. Is there a default_server catching everything?
$ grep -r "default_server" /etc/nginx/sites-enabled/

# 4. Does the document root exist and have correct permissions?
$ ls -la /var/www/myapp/
$ ls -la /var/www/myapp/index.html

# 5. Check the error log
$ sudo tail -20 /var/log/nginx/error.log

Common causes:

  • Missing symlink in sites-enabled
  • Config file does not end in .conf (for RHEL) so it is not included
  • The default site has default_server and is catching the request
  • Document root has wrong permissions (www-data cannot read it)

What Just Happened?

┌──────────────────────────────────────────────────────────┐
│                   Chapter 44 Recap                        │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Nginx uses a master/worker process architecture with     │
│  event-driven, non-blocking I/O. This lets it handle      │
│  thousands of connections with minimal memory.             │
│                                                          │
│  Configuration hierarchy:                                 │
│    nginx.conf -> http { } -> server { } -> location { }  │
│                                                          │
│  Key concepts:                                            │
│  - Server blocks = virtual hosts (matched by Host header) │
│  - Location blocks = URL routing (exact > prefix > regex) │
│  - try_files = graceful fallback chain                    │
│  - Always: nginx -t before reload                         │
│  - Always: reload, not restart (zero downtime)            │
│                                                          │
│  Files:                                                   │
│  - Config: /etc/nginx/ (nginx.conf, sites-available/)    │
│  - Logs:   /var/log/nginx/ (access.log, error.log)       │
│  - Webroot: /var/www/                                     │
│                                                          │
└──────────────────────────────────────────────────────────┘

Try This

Exercise 1: Multiple Virtual Hosts

Set up three server blocks on one Nginx instance, each serving different content. Use curl -H "Host: ..." to test each one. Add per-site access logs and verify they log to separate files.

Exercise 2: Custom Error Pages

Create custom 404 and 500 error pages. Configure Nginx to use them:

error_page 404 /custom_404.html;
error_page 500 502 503 504 /custom_50x.html;

location = /custom_404.html {
    root /var/www/errors;
    internal;
}

Exercise 3: Directory Listing

Enable directory listing for a /files/ path using the autoindex module:

location /files/ {
    alias /var/www/shared-files/;
    autoindex on;
    autoindex_exact_size off;
    autoindex_localtime on;
}

Put some files in the directory and browse the listing.

Exercise 4: Log Analysis

Generate some traffic with a loop, then use awk to answer: What is the most requested URL? What is the average response time? How many 404s occurred?

# Generate traffic
$ for i in $(seq 1 100); do
    curl -s http://localhost/ > /dev/null
    curl -s http://localhost/nonexistent > /dev/null
  done

Bonus Challenge

Set up Nginx with HTTPS using a self-signed certificate. Configure it to redirect all HTTP traffic to HTTPS. (Hint: you will need ssl_certificate, ssl_certificate_key, and a return 301 https://... block.)


What Comes Next

You now know how to make Nginx serve static content. But most real applications live behind Nginx as a reverse proxy. In the next chapter, we will configure Nginx to proxy traffic to backend applications, load-balance across multiple servers, and cache responses -- the configuration patterns used in virtually every production deployment.