Nginx: A Practical Deep Dive
Nginx ends up in front of a surprising amount of web traffic. You might not think much about it until you have to troubleshoot a redirect loop, a broken certificate renewal, or an application that only fails once it sits behind a proxy.
Static sites, application servers, APIs, internal tools, container platforms, and CDN origins often have Nginx somewhere out front. People keep using it because it's fast, it's predictable, and it makes you declare what the server should do.
This guide walks through the part that matters in practice: what Nginx is good at, how its config model works, how to install it, how to serve a site, how to terminate TLS, how to replace .htaccess style behavior the Nginx way, and how to use it as a reverse proxy and a basic load balancer.
Nginx is a web server, reverse proxy, and load balancer. In a modern stack it usually does one or more of these jobs:
- Serves static files directly.
- Terminates HTTPS connections.
- Redirects HTTP to HTTPS.
- Proxies requests to application servers.
- Balances traffic across multiple backend services.
- Adds request and response headers.
- Buffers slow clients away from backend applications.
- Enforces simple access control rules.
- Caches upstream responses when configured to do so.
It's especially good at static assets, reverse proxying, and a lot of concurrent connections. It also works well as the boring edge layer in front of applications written in Node.js, Python, Go, Ruby, PHP, Java, or anything else that can listen on a port.
The simplest mental model:
graph TD
A[Client Browser] -->|HTTPS Request| B[Nginx]
B -->|HTTP Request to Local App| C[Backend Application] Nginx does not need to understand your application. It just needs to accept the request, apply the rules you gave it, and either serve the response or pass the request upstream.
Nginx became popular because it handles several common infrastructure problems without much drama:
- It handles many concurrent connections efficiently.
- It serves static files quickly.
- It's excellent as a reverse proxy.
- It's good at shielding backend apps from awkward client behavior.
- It centralizes TLS, redirects, headers, and access control.
- It can load balance traffic without a separate appliance.
- Its configuration is explicit and reviewable.
Nginx expects configuration in known files, not hidden per-directory overrides inside the document root. That makes behavior easier to inspect, version, test, and deploy.
Event-Driven vs. Process-Driven Architecture¶
Nginx uses an event-driven architecture. A small set of worker processes can deal with many connections by reacting to operating system events instead of blocking on each client.
That differs from older process-per-connection or thread-per-connection designs, where each client may tie up its own process or thread. Those designs are easy to picture. They also get expensive faster when concurrency climbs.
The practical difference:
| Model | How it Behaves |
|---|---|
| Event-driven | Workers handle many connections through nonblocking events |
| Process or thread driven | More connections usually mean more processes or threads |
This is why Nginx is often strong at:
- Many simultaneous idle keepalive connections.
- Static file serving.
- Reverse proxying to upstream apps.
- Handling slow clients without tying up backend workers.
The architecture helps, but it doesn't override bad configuration or a backend that is already struggling.
If you are coming from Apache or older shared-hosting habits, the biggest shift is .htaccess. Nginx does not use per-directory override files. Routing, redirects, authentication, and access rules live in the main configuration tree and take effect when Nginx starts or reloads.
That is one reason Nginx fits infrastructure-managed systems so well. The behavior lives in known files, can be reviewed in Git, and does not depend on hidden overrides scattered through the web root.
Installation and Setup¶
For production systems, the official Nginx repositories are worth a look because they give you clearer control over package freshness and whether you want stable or mainline. For a lab box or a first pass, the distribution package is usually fine.
Install Nginx¶
After installation, check the version:
Start and enable the service:
Verify that it answers locally:
Managing the Service¶
Common systemctl commands:
sudo systemctl status nginx
sudo systemctl start nginx
sudo systemctl stop nginx
sudo systemctl restart nginx
sudo systemctl reload nginx
sudo systemctl enable nginx
sudo systemctl disable nginx
Most configuration changes only require a reload:
Reload tells Nginx to validate and reopen its config, start workers with the new settings, and let old workers drain. If you find yourself restarting Nginx routinely, it's worth asking why.
Default Directory Structure¶
Common paths on Debian and Ubuntu:
/etc/nginx/
nginx.conf
conf.d/
sites-available/
sites-enabled/
/var/www/html/
/var/log/nginx/
access.log
error.log
Common paths on RHEL-like systems:
The sites-available and sites-enabled pattern is common on Debian and Ubuntu. RHEL-like systems usually use /etc/nginx/conf.d/*.conf directly.
Both layouts are common. Pick one and stick with it across a fleet.
Core Concepts: Ports and Listeners¶
Nginx receives traffic through listen directives inside server blocks.
HTTP usually listens on port 80:
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html;
}
HTTPS usually listens on port 443 with TLS enabled:
server {
listen 443 ssl;
http2 on;
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;
root /var/www/example.com/public;
index index.html;
}
listen tells Nginx where to accept connections. server_name tells it which virtual host should handle the request once that connection arrives.
Master and Worker Processes¶
Nginx starts one master process and one or more worker processes.
The master process:
- Reads and validates configuration.
- Opens listening sockets.
- Starts and supervises workers.
- Handles reload and shutdown signals.
Worker processes:
- Accept client connections.
- Process requests.
- Serve files.
- Proxy traffic.
- Write logs.
You will often see something like this:
Example shape:
root 1001 1 0 10:00 ? nginx: master process /usr/sbin/nginx
www-data 1002 1001 0 10:00 ? nginx: worker process
www-data 1003 1001 0 10:00 ? nginx: worker process
The master often runs as root so it can bind privileged ports like 80 and 443. Workers usually run as an unprivileged account such as www-data or nginx, depending on the distribution.
Configuration Hierarchy¶
Nginx configuration is built from directives and blocks. If you are new to it, think nested scopes, not scattered per-directory overrides.
Top-level directives live in the main context:
user www-data;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
server {
listen 80;
server_name example.com;
location / {
root /var/www/example.com/public;
}
}
}
The common contexts:
| Context | Purpose |
|---|---|
main | Global process settings |
events | Connection processing settings |
http | HTTP server-wide settings |
server | One virtual host or listener behavior |
location | Rules for matching request paths |
upstream | Backend server groups for proxying or load balancing |
The hierarchy matters:
Most day-to-day work happens inside server and location blocks.
Basic Usage Walkthrough¶
We will build a simple static site first. That gives us something concrete to test before moving into proxying and TLS.
Create the Web Root¶
Create a test page:
cat <<'EOF' | sudo tee /var/www/example.com/public/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello from Nginx</title>
</head>
<body>
<h1>Hello from Nginx</h1>
<p>This static page is served by Nginx.</p>
</body>
</html>
EOF
Set ownership:
sudo chown -R root:root /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
That keeps the content readable without turning the web root into something the worker process can write to.
Create a Server Block¶
On Debian or Ubuntu:
sudo tee /etc/nginx/sites-available/example.com >/dev/null <<'EOF'
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
location / {
try_files $uri $uri/ =404;
}
}
EOF
Enable it:
On RHEL-like systems:
sudo tee /etc/nginx/conf.d/example.com.conf >/dev/null <<'EOF'
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
location / {
try_files $uri $uri/ =404;
}
}
EOF
Test and Reload¶
Always test before reload:
If the test passes:
Test locally with a host header:
If DNS already points to the server:
SSL/TLS Deep Dive¶
HTTPS is table stakes now. Browsers expect it. Secure cookies depend on it. HTTP/2 and many modern app features assume it.
TLS gives you:
- Encryption between client and server.
- Server identity through certificates.
- Protection against many passive network attacks.
- Access to modern browser features.
If you're using HTTP-01 certificate validation, make sure your redirects and access rules still allow requests under /.well-known/acme-challenge/.
In Nginx, HTTPS needs at least a certificate and private key:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
Those paths are common when using Let's Encrypt with Certbot.
HTTPS Server Block¶
Newer Nginx releases support http2 on; as the current syntax. Older examples often fold http2 into the listen directive. Both show up in the wild, so it's worth recognizing both forms.
server {
listen 443 ssl;
http2 on;
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;
root /var/www/example.com/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Global HTTP to HTTPS Redirect¶
Keep port 80 simple. Redirect everything to HTTPS:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Then serve the real site on 443:
server {
listen 443 ssl;
http2 on;
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;
root /var/www/example.com/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Test and reload:
Security and Access Control¶
Nginx does not process .htaccess files. The Nginx way is to put access rules in server and location blocks and reload when you change them.
That tends to work better on systems managed like actual infrastructure:
- Configuration is centralized.
- Changes can be reviewed.
- Nginx does not need per-directory override checks.
- Syntax is tested before reload.
- Runtime behavior is easier to reason about.
Deny Access to Hidden Files¶
Block dotfiles:
That blocks paths like .git, .env, and .htpasswd, while allowing .well-known for ACME and other standards-based paths.
Restrict a Directory¶
location /admin/ {
allow 203.0.113.10;
allow 198.51.100.0/24;
deny all;
try_files $uri $uri/ =404;
}
The order matters. allow and deny are evaluated in sequence.
Basic Authentication¶
Install htpasswd.
Create a password file outside the web root:
Add authentication:
location /admin/ {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/auth/admin.htpasswd;
try_files $uri $uri/ =404;
}
Combine IP restrictions and Basic Authentication:
location /admin/ {
satisfy all;
allow 203.0.113.10;
deny all;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/auth/admin.htpasswd;
try_files $uri $uri/ =404;
}
This requires both an allowed IP and valid credentials.
Working With Nginx as a Reverse Proxy¶
Reverse proxying is where Nginx earns its keep in a lot of stacks.
Suppose a Node.js, Go, or Python application listens locally on port 3000:
Nginx can take public HTTPS traffic and forward the request to that app:
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
Those headers matter:
| Header | Why it Matters |
|---|---|
Host | Preserves the requested hostname |
X-Real-IP | Passes the direct client IP seen by Nginx |
X-Forwarded-For | Preserves the proxy chain of client IPs |
X-Forwarded-Proto | Tells the app whether the original request used HTTP or HTTPS |
Without these headers, backend apps often think every request came from 127.0.0.1 over plain HTTP. That breaks logs, redirects, and any code that cares about the original scheme or client IP.
Practical Example: Production-Ready Reverse Proxy¶
This example does the usual production edge work: redirect HTTP to HTTPS, proxy to a local app, pass the important headers, block hidden files, and write site-specific logs.
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
access_log /var/log/nginx/app.example.com.access.log;
error_log /var/log/nginx/app.example.com.error.log warn;
client_max_body_size 20m;
location ~ /\.(?!well-known(?:/|$)) {
deny all;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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_read_timeout 60s;
proxy_send_timeout 60s;
}
}
If your app uses WebSockets, add that handling only on the locations that need it. The mixed-traffic pattern from the Nginx docs looks like this:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
location /chat/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
}
}
That keeps Connection: upgrade tied to requests that actually asked for an upgrade.
Practical Example: Round-Robin Load Balancer¶
Nginx can load balance across an upstream group.
upstream app_backend {
server 10.0.10.11:3000;
server 10.0.10.12:3000;
server 10.0.10.13:3000;
}
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://app_backend;
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;
}
}
By default, this gives you round-robin balancing.
You can add simple failure handling:
upstream app_backend {
server 10.0.10.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.10.12:3000 max_fails=3 fail_timeout=30s;
server 10.0.10.13:3000 max_fails=3 fail_timeout=30s;
}
This is not a full service mesh, and open source Nginx does not ship every active health check feature that Nginx Plus does. For simple services, though, upstream balancing is often enough.
Best Practices¶
Modularize Configuration¶
Keep nginx.conf boring. Put site-specific config in included files. You want the top-level file to be readable at a glance.
Example:
For reusable snippets:
Example include:
Always Test Before Reloading¶
Make this muscle memory:
If syntax validation fails, do not reload.
Use Proper Logs¶
Use per-site logs when you run more than one site:
access_log /var/log/nginx/app.example.com.access.log;
error_log /var/log/nginx/app.example.com.error.log warn;
Most packages include logrotate configuration for Nginx logs. Verify it exists:
If you define unusual log paths, make sure they are included in rotation.
Keep the Web Root Read-Only to Nginx¶
For static sites, Nginx usually only needs read access.
sudo chown -R root:root /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
Do not make the web root writable by the Nginx worker user unless the application genuinely requires it.
Separate Application and Edge Concerns¶
Let Nginx handle:
- TLS.
- Redirects.
- Static assets.
- Request size limits.
- Reverse proxy headers.
- Basic IP restrictions.
- Basic authentication for simple protected areas.
Let the application handle:
- Business authorization.
- Sessions.
- User identity.
- Domain-specific routing.
- Application-level rate limits and policy.
Once business logic starts creeping into Nginx configuration, maintenance usually gets harder rather than easier.
Common Pitfalls¶
Permission Errors¶
The worker process must be able to read static files and traverse the parent directories above them.
Check the worker user:
Check file permissions:
One common failure is that the file itself is readable, but a parent directory is missing the execute bit for traversal.
The proxy_pass Trailing Slash Trap¶
Trailing slashes matter.
This replaces the matching /api/ prefix with / on the upstream side:
This usually passes the original request URI upstream as-is:
The rule is simple once you see it: if proxy_pass includes a URI part, Nginx rewrites the matching location prefix. If it does not, Nginx forwards the URI more directly.
Be deliberate. Test with curl.
Syntax Errors¶
Nginx configuration is strict. Missing semicolons and mismatched braces will break validation.
Bad:
Good:
Always run:
Wrong File on the Wrong Distribution¶
On Debian or Ubuntu, a server block in sites-available does nothing until it is linked into sites-enabled.
On RHEL-like systems, Nginx commonly reads /etc/nginx/conf.d/*.conf.
When in doubt, inspect the active includes:
nginx -T prints the full parsed configuration. It's one of the fastest ways to see what Nginx is actually reading. It can also dump paths and inline settings you may not want to paste into a ticket or chat room, so use some judgment before sharing the output.
Next Steps¶
Once the basics are solid, the next useful areas are automation and caching.
CI/CD Deployments¶
Treat Nginx config like application code:
- Store configuration templates in Git.
- Render environment-specific values during deployment.
- Copy config to
/etc/nginx/conf.d/orsites-available. - Run
nginx -t. - Reload only after validation passes.
If the config is invalid, the deploy should fail before reload:
With Ansible, validate first and then flush the reload handler:
- name: Install Nginx Configuration
ansible.builtin.template:
src: app.example.com.conf.j2
dest: /etc/nginx/conf.d/app.example.com.conf
mode: "0644"
notify: Reload nginx
- name: Validate Nginx Configuration
ansible.builtin.command: nginx -t
changed_when: false
- name: Reload Nginx
ansible.builtin.meta: flush_handlers
handlers:
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
Advanced Caching¶
Nginx can cache upstream responses with proxy_cache.
A minimal sketch:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m max_size=1g inactive=60m;
server {
listen 80;
server_name app.example.com;
location / {
proxy_cache app_cache;
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_pass http://127.0.0.1:3000;
}
}
The caching is powerful but it's also where mistakes get made. Be careful with authenticated pages, cookies, personalized responses, and cache invalidation.
Nginx remains popular because it handles a lot of routine infrastructure work reliably. It accepts traffic, terminates TLS, serves static files, forwards requests, and gives teams one clear place to define edge behavior.
The habits are simple: keep the config explicit, test before every reload, and resist the urge to push application decisions down into proxy rules. Do that, and Nginx stays readable.