Skip to content

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

Install the distribution package:

sudo apt update
sudo apt install -y nginx

Install the distribution package:

sudo dnf install -y nginx

If the package is not available on an older RHEL-family system, enable EPEL and try again:

sudo dnf install -y epel-release
sudo dnf clean all
sudo dnf install -y nginx

After installation, check the version:

nginx -v

Start and enable the service:

sudo systemctl enable --now nginx

Verify that it answers locally:

curl -I http://127.0.0.1

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:

sudo systemctl reload nginx

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:

/etc/nginx/
  nginx.conf
  conf.d/
/usr/share/nginx/html/
/var/log/nginx/
  access.log
  error.log

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:

ps -ef | grep '[n]ginx'

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:

main
  events
  http
    upstream
    server
      location

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

sudo mkdir -p /var/www/example.com/public

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:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com

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:

sudo nginx -t

If the test passes:

sudo systemctl reload nginx

Test locally with a host header:

curl -H "Host: example.com" http://127.0.0.1/

If DNS already points to the server:

curl -I http://example.com/

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:

sudo nginx -t
sudo systemctl reload nginx

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:

location ~ /\.(?!well-known(?:/|$)) {
    deny all;
}

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.

sudo apt install -y apache2-utils
sudo dnf install -y httpd-tools

Create a password file outside the web root:

sudo mkdir -p /etc/nginx/auth
sudo htpasswd -c /etc/nginx/auth/admin.htpasswd ryan

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:

127.0.0.1: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:

http {
    include /etc/nginx/mime.types;
    include /etc/nginx/conf.d/*.conf;
}

For reusable snippets:

/etc/nginx/snippets/
  ssl-params.conf
  proxy-headers.conf
  security-headers.conf

Example include:

location / {
    include /etc/nginx/snippets/proxy-headers.conf;
    proxy_pass http://127.0.0.1:3000;
}

Always Test Before Reloading

Make this muscle memory:

sudo nginx -t
sudo systemctl reload nginx

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:

ls /etc/logrotate.d/nginx

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:

grep -n '^user' /etc/nginx/nginx.conf

Check file permissions:

namei -l /var/www/example.com/public/index.html

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:

location /api/ {
    proxy_pass http://127.0.0.1:3000/;
}

This usually passes the original request URI upstream as-is:

location /api/ {
    proxy_pass http://127.0.0.1:3000;
}

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.

curl -i http://example.com/api/health

Syntax Errors

Nginx configuration is strict. Missing semicolons and mismatched braces will break validation.

Bad:

server {
    listen 80
    server_name example.com;
}

Good:

server {
    listen 80;
    server_name example.com;
}

Always run:

sudo nginx -t

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:

sudo nginx -T | less

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:

  1. Store configuration templates in Git.
  2. Render environment-specific values during deployment.
  3. Copy config to /etc/nginx/conf.d/ or sites-available.
  4. Run nginx -t.
  5. Reload only after validation passes.

If the config is invalid, the deploy should fail before reload:

sudo nginx -t
sudo systemctl reload nginx

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.

References