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, usesudo pacman -S nginx. The package is callednginxeverywhere, 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-dataon Debian,nginxon RHEL). Each worker uses an event-driven, non-blocking model withepoll(Linux) orkqueue(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.conffiles directly intoconf.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.jsonregex 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:
- Try to serve the file at
$uri(the requested path) - If not found, try it as a directory
$uri/(and serve its index file) - 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
| Action | Command | Downtime? | What Happens |
|---|---|---|---|
| Reload | sudo systemctl reload nginx | No | Master re-reads config, spawns new workers, old workers finish existing requests, then exit |
| Restart | sudo systemctl restart nginx | Brief | Full 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
.gitdirectories,.envfiles, or backup files through your web server. Thelocation ~ /\.block above prevents this. Always test withcurl http://yoursite/.git/configto 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
defaultsite hasdefault_serverand is catching the request - Document root has wrong permissions (
www-datacannot 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.