Web Server

Nginx

Nginx is the event-driven web server that sits in front of almost every Node, Python and Go app in production. It terminates TLS, serves static files, balances load and shields your process from the internet.

intermediate15 min readUpdated Sep 16, 2026
sites-available/app
nginx
# /etc/nginx/sites-available/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;
  }
}
Released
2004
Model
Event-driven, async
Main roles
Web server, reverse proxy, load balancer
Config language
Declarative blocks
TLS
Terminated at Nginx
Typical upstreams
Node, Python, Go, PHP

Why it matters

What Nginx takes off your application's plate

One entry point for everything

A single public address fronts many services. Route by hostname or path to different apps, and move them without changing DNS or client code.

TLS and security at the edge

Nginx terminates HTTPS, redirects plain HTTP, adds security headers and rate limits abusive traffic before it ever reaches your process.

Fast static delivery and caching

Static files and cached responses are served from disk with sendfile and kernel caching, which is far cheaper than asking Node to do it.

The big picture

The three jobs Nginx does

It accepts connections from the internet, decides what to do with each request, and speaks TLS so your app does not have to.

The listener

Accept

Nginx binds the ports, performs the TLS handshake and reads the request. Your app never sees the raw socket.

The router

Match

server_name selects the virtual host, then location blocks match the path and decide to serve, proxy, cache or reject.

The upstream

Forward

proxy_pass hands the request to your application, adds forwarding headers and streams the response back to the client.

Nginx at a glance

The directives you will reach for

server blocks

One per site or hostname, selected by server_name and port.

location blocks

Match a path prefix or regex and choose how to handle it.

proxy_pass

Forward a request to an upstream app or another server.

TLS

Certificates, protocols and cipher settings live in the server block.

upstream

A pool of backends with a balancing method and health checks.

limit_req & proxy_cache

Rate limit abusers and cache responses to cut origin load.

Flow

A request through the reverse proxy

Every request follows the same path, whether it ends at a static file or at your application.

  1. 1

    The client connects

    A browser opens a TCP connection to port 443 on the server's public address and starts a TLS handshake.

  2. 2

    Nginx terminates TLS

    Nginx presents the certificate and decrypts the request, so the upstream application only ever deals with plain HTTP.

  3. 3

    The virtual host is chosen

    Nginx matches the Host header against server_name to pick the right server block for that domain.

  4. 4

    A location is matched

    The request path is matched against location blocks in order, and the most specific match decides the handler.

  5. 5

    The request is served or proxied

    Nginx returns a static file or forwards the request to the upstream defined by proxy_pass.

  6. 6

    Forwarding headers are added

    Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto tell the app the real client and scheme.

  7. 7

    Caching and limits apply

    proxy_cache may satisfy the request from disk, and limit_req may reject it, before anything reaches the upstream.

  8. 8

    The response returns

    Nginx streams the upstream response back to the client, optionally compressing and adding headers on the way.

The complete guide

Nginx: Everything you need to know

What Nginx actually is

Nginx (pronounced “engine-x”) is a web server built around an event-driven, asynchronous architecture. Instead of one thread or process per connection, a small number of worker processes handle thousands of connections concurrently by reacting to events. That design is why it can sit in front of a slow application without falling over, and why it became the default front door for the modern web.

It plays three roles that are easy to blur together. It is a static web server, happily returning files from disk. It is a reverse proxy, forwarding requests to another server and relaying the response. And it is a load balancer, spreading requests across a pool of backends. In front of a Node, Python, Go or PHP app, you will use all three. Nginx is not part of your application: it is a separate process with its own configuration, lifecycle and logs.

Why put it in front of your app

You could bind Node directly to port 80. People do it in tutorials, and it works until it does not. Putting Nginx in front buys you a set of capabilities that are tedious or dangerous to build into an application.

TLS termination. Nginx holds the certificate, performs the handshake and decrypts. Your app speaks plain HTTP on localhost and never manages certificate renewal. Certbot can even rewrite the config for you.

Static files, compression and caching. Nginx serves files with sendfile and kernel caching, compresses with gzip or brotli, and can replay a stored response without touching the upstream at all.

Rate limiting and request limits. Abuse is stopped at the edge, before it consumes a connection pool or a database query.

A single entry point. One public address and certificate can front many services, routed by hostname or path. You can move an app to another port or host without changing DNS.

Zero-downtime deploys. An upstream can drain one backend while it restarts, and a config reload is graceful. Directly exposed Node drops every in-flight connection on restart.

The configuration model

Nginx configuration is a tree of contexts and directives. A directive is a setting ending in a semicolon; a context is a block enclosed in braces that contains more directives. Directives are inherited downward, and the most specific context wins.

# /etc/nginx/nginx.conf (main context)
user  www-data;
worker_processes auto;
pid /run/nginx.pid;

events {
  worker_connections 1024;
}

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;
  sendfile      on;
  keepalive_timeout 65;

  gzip on;
  gzip_types text/css application/javascript application/json;

  # site configs are pulled in here
  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/sites-enabled/*;
}

The contexts you will work in are:

  • main — global settings such as user and worker_processes.
  • events — how connections are handled.
  • http — everything HTTP: MIME types, logging, compression, and the includes that pull in site files.
  • server — one virtual host, selected by listen and server_name.
  • location — a path inside a server, where requests are actually handled.

On Debian and Ubuntu the convention is to keep one file per site in /etc/nginx/sites-available/ and symlink the enabled ones into /etc/nginx/sites-enabled/. That gives you an easy way to disable a site without deleting it.

sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo rm /etc/nginx/sites-enabled/app

Directives are not a scripting language. There are no loops or variables in the usual sense — only map, if (used sparingly) and includes. If a problem feels like it needs control flow, the answer is usually a map or a change to the application.

Your first server block

A server block is a virtual host. It listens on a port, answers for one or more names, and contains location blocks.

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

  root /var/www/app/dist;
  index index.html;

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

server_name is matched against the Host header. The first server block for a port is the default server and catches requests whose host matches nothing else, so it is worth making that one an explicit catch-all that returns 444 or a maintenance page rather than leaking an unintended site.

Matching order matters and trips people up. location blocks are tried as exact matches (=), then the longest prefix match, then regexes (~ and ~*). A ^~ prefix tells Nginx to stop and use that prefix without checking regexes. When behaviour seems wrong, this ordering is usually why.

location = /favicon.ico { log_not_found off; access_log off; }
location ^~ /assets/    { expires 1y; }
location ~* \.(jpg|png|css|js)$ { expires 30d; }
location /              { try_files $uri /index.html; }

Proxying to a Node app

The core directive is proxy_pass. Point it at your application and Nginx becomes a reverse proxy.

upstream app {
  server 127.0.0.1:3000;
  keepalive 32;
}

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

  location / {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_read_timeout 30s;
    proxy_connect_timeout 5s;
  }
}

Defining an upstream block rather than inlining the address is not just for multiple servers. It is what lets you set keepalive, so Nginx reuses connections to the backend instead of opening a new TCP connection for every request. proxy_http_version 1.1 is required for keepalive and for WebSockets.

Timeouts deserve thought. proxy_connect_timeout bounds how long Nginx waits to establish the connection; proxy_read_timeout bounds how long it waits for the next byte from the upstream. The read timeout must exceed your slowest legitimate response, or Nginx will return a 504 while the app is still working.

The trailing-slash rule is the classic gotcha. proxy_pass http://app; forwards the full path unchanged. proxy_pass http://app/; replaces the matched location prefix with /. Inside location /api/, the first sends /api/users and the second sends /users. Pick deliberately and check the upstream log.

Forwarding headers and why your app cares

By default the upstream sees the request as coming from Nginx: the client address is 127.0.0.1, the scheme is http, and the Host header may be missing. That breaks logging, redirects, cookies and rate limiting inside the app.

location / {
  proxy_pass http://app;
  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_set_header X-Forwarded-Port  $server_port;
}

Each header earns its place:

  • Host preserves the original domain, so the app can build correct absolute URLs and virtual hosts.
  • X-Real-IP is the immediate client address.
  • X-Forwarded-For appends to a chain; $proxy_add_x_forwarded_for keeps any existing entries and adds the current client. Use the first entry as the original client, and never trust it blindly.
  • X-Forwarded-Proto tells the app whether the user connected over HTTPS, which fixes redirect loops and secure-cookie bugs.
  • X-Forwarded-Host and X-Forwarded-Port help when Nginx listens on a non-standard port.

Your framework must be told to trust this proxy. In Express, app.set("trust proxy", 1) makes req.ip and req.protocol read the forwarded values. Skip that and every request appears to come from localhost, which quietly breaks per-IP rate limiting and geolocation.

Serving static files and the SPA fallback

Nginx is excellent at static files, so let it do that job. A common split is to serve the built frontend from disk and proxy only the API.

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

  location /assets/ {
    try_files $uri =404;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  location /api/ {
    proxy_pass http://app;
    proxy_set_header Host $host;
  }

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

For a single-page app, try_files $uri /index.html is the fallback that makes client-side routing work: if the requested path is not a file, return index.html and let the router handle it. For an API, the same pattern with =404 is better, because returning HTML for a missing endpoint hides bugs.

Assets with content hashes in their names can be cached forever — immutable and a one-year expiry are safe because a change produces a new filename. index.html must never be cached that way, or users keep loading an old app.

TLS with Let’s Encrypt and certbot

Certbot obtains free certificates from Let’s Encrypt and can configure Nginx automatically. Install the plugin, run it once per domain, and it handles the rest.

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com -d www.app.example.com
sudo certbot renew --dry-run

Certbot writes the certificate paths into your server block and installs a renewal timer. Certificates last 90 days; renewal is automatic but only works if the renewal check can reach your server on port 80, so do not firewall it off entirely.

The resulting block looks like this, with an HTTP server that redirects and an HTTPS server that terminates.

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;
  ssl_protocols       TLSv1.2 TLSv1.3;
  ssl_session_cache   shared:SSL:10m;
  ssl_session_timeout 1d;

  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;

  location / {
    proxy_pass http://app;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

TLSv1.2 and TLSv1.3 are the only protocols worth enabling today. HSTS tells browsers to refuse plain HTTP for a year, so enable it only once you are confident HTTPS works everywhere — it is hard to undo.

HTTP/2 and HTTP/3

HTTP/2 multiplexes many requests over one connection and is enabled per server. On modern Nginx (1.25.1 and later) the directive is http2 on;. On older versions it is a flag on the listen line: listen 443 ssl http2;.

server {
  listen 443 ssl;
  http2 on;
  # ...
}

HTTP/3 runs over QUIC and UDP. It needs a build with the QUIC module, a separate listen 443 quic reuseport; line and an Alt-Svc: h3=":443"; ma=86400 header to advertise it. Treat it as an optimisation to add once HTTP/2 is solid, not a first step. Browsers negotiate both over TLS automatically, so there is nothing to change in your app.

Load balancing methods

An upstream block defines a pool, and the balancing method decides which server gets the next request. The default is round-robin.

upstream app {
  least_conn;
  server 10.0.0.11:3000 weight=2 max_fails=3 fail_timeout=15s;
  server 10.0.0.12:3000;
  server 10.0.0.13:3000 backup;
  keepalive 64;
}
  • Round-robin (default) cycles through servers. Simple and even when requests cost roughly the same.
  • least_conn sends to the server with the fewest active connections. Better when request durations vary widely, which is common for APIs.
  • ip_hash pins a client to one server by hashing the address. Useful for in-memory sessions, at the cost of uneven load and lost stickiness when a server fails.
  • weight biases traffic toward a stronger machine or during a canary.
  • backup marks a server that only receives traffic when the primaries are down.

max_fails and fail_timeout give you passive health checking: after max_fails failures within fail_timeout, Nginx marks the server down for that window. proxy_next_upstream then decides which failures are retried on another backend.

proxy_next_upstream error timeout http_502 http_503 http_504;

Retrying is powerful but not free: it only makes sense for idempotent requests. Do not blindly retry a POST that may have already changed state unless the upstream is idempotent by design.

Health checks and failure handling

Nginx OSS has passive checks; active health checks (health_check with match) require Nginx Plus. Passive checks plus an application-level endpoint get most teams a long way: expose /healthz that checks the dependencies it needs — database, cache — and returns quickly, then point your platform’s liveness probe at it. Use it to gate deploys by starting a new backend, waiting until it is healthy, then removing the old one from the upstream.

Because config reloads are graceful, the zero-downtime pattern is to edit the upstream to point at the new instance, nginx -t, reload, then drain and stop the old one.

Caching with proxy_cache

proxy_cache stores upstream responses on disk and serves them without contacting the backend. For read-heavy endpoints that change slowly, this is the largest single win available at the edge.

proxy_cache_path /var/cache/nginx levels=1:2
                 keys_zone=app:10m max_size=1g inactive=60m;

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

  location /api/public/ {
    proxy_pass http://app;
    proxy_cache app;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_valid 200 302 10s;
    proxy_cache_valid 404 1m;
    proxy_cache_bypass $http_authorization $cookie_session;
    proxy_no_cache $http_authorization;
    add_header X-Cache-Status $upstream_cache_status;
  }
}

proxy_cache_valid sets how long each status code is cached. proxy_cache_bypass skips the cache for requests carrying a session or authorization header; proxy_no_cache prevents storing their responses, so one user’s private data is never served to another. The X-Cache-Status header (HIT, MISS, BYPASS, EXPIRED) is the fastest way to prove caching works.

Two cautions. Cache only responses that are safe to share — public, non-personalised data — and make sure the upstream sends a Cache-Control header your config agrees with. A cache key that ignores a variant (a language header, a query parameter) will happily serve the wrong content.

Rate limiting with limit_req

Rate limiting protects login endpoints, search and public APIs from abuse and accidental floods. It is defined in the http context and applied in a location.

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_status 429;

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

  location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://app;
  }

  location = /api/login {
    limit_req zone=login burst=5 nodelay;
    proxy_pass http://app;
  }
}

The zone key is usually $binary_remote_addr (the client IP), and the rate is requests per second or per minute. burst allows short spikes, and nodelay processes the burst immediately rather than spacing it out — which is what you want for interactive endpoints. Without nodelay, excess requests are delayed, which can make a UI feel sluggish instead of simply returning 429.

Behind another proxy or a CDN, $binary_remote_addr may be the proxy’s address, so the limiter treats everyone as one client. Fix that by configuring set_real_ip_from and real_ip_header so Nginx sees the real client. Also return 429 rather than the default 503 so clients can distinguish rate limiting from an outage.

WebSocket upgrade

A WebSocket connection starts as an HTTP request with an Upgrade header, then becomes a long-lived bidirectional stream. Proxying it needs the upgrade headers and a Connection value that depends on the request, so a map is used.

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 443 ssl;
  http2 on;
  server_name app.example.com;

  location /ws/ {
    proxy_pass http://app;
    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;
  }
}

The long read timeout matters: an idle WebSocket with a short timeout is closed by Nginx, and the client sees a mysterious disconnect. Note that HTTP/2 does not carry WebSocket in the same way; browsers open a separate HTTP/1.1 connection for the upgrade, which Nginx handles automatically on the same port.

Running Nginx in Docker

The official nginx image is a convenient way to ship config and static files with your app. The one rule is that the container’s main process must stay in the foreground, so the command ends with daemon off;.

# Dockerfile
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/app.conf
COPY dist/ /usr/share/nginx/html/
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

In Compose, the app service is reachable by its service name, so the upstream host is simply app. Nginx starts before the app is ready, so either add a healthcheck and depend on it, or let Nginx retry.

# docker-compose.yml
services:
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/app.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      app:
        condition: service_healthy
  app:
    build: .
    expose:
      - "3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 5s
      retries: 10

Note expose rather than ports on the app: it should only be reachable inside the Compose network, never directly from the host. The nginx config’s upstream becomes server app:3000;.

Testing, reloading and zero-downtime config changes

Every config change should follow the same loop: validate, reload, verify. A reload is graceful; a restart is not.

sudo nginx -t                 # test the configuration
sudo nginx -T                 # test and dump the full effective config
sudo systemctl reload nginx   # graceful, no dropped connections
sudo systemctl status nginx
curl -I https://app.example.com

nginx -T is underused and extremely helpful: it prints the merged configuration exactly as Nginx sees it, which settles arguments about inheritance and includes. If -t fails, the error names the file and line.

Reloading keeps a shell open in a second session, so a bad config cannot lock you out. In a container, the equivalent is sending SIGHUP to the master process or running nginx -s reload. Never restart a busy production Nginx to apply a config change when a reload will do.

Best practices

  • Run nginx -t before every reload; never edit and restart blindly.
  • Keep one file per site and enable it with a symlink so changes are reversible.
  • Define an upstream block with keepalive instead of inlining the backend address.
  • Always forward Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto, and set trust proxy in the app.
  • Serve static assets and hashed bundles from disk with long, immutable cache headers.
  • Redirect HTTP to HTTPS and enable HSTS only after HTTPS is proven.
  • Cache only public, non-personalised responses, and never key a cache without its variants.
  • Rate limit login and public endpoints, and return 429 rather than 503.
  • Long timeouts for WebSocket locations, short ones for health checks.
  • Keep secrets and certificates out of images; mount them read-only at runtime.

Common mistakes

  • Skipping nginx -t and taking the site down with a syntax error.
  • Forgetting X-Forwarded-Proto, then debugging an infinite HTTPS redirect loop.
  • Not enabling trust proxy in the app, so every request looks like it came from localhost.
  • Confusing proxy_pass with and without a trailing slash and getting 404s.
  • Setting proxy_read_timeout shorter than a legitimate slow response and returning 504s.
  • Caching personalised responses and serving one user’s data to another.
  • Proxying WebSockets without the upgrade map, then seeing connections drop immediately.
  • Leaving the default server as the first site block and exposing an unintended app.
  • Enabling HSTS before HTTPS works everywhere and locking users out for a year.
  • Exposing the app service with ports in Compose instead of expose.

Where to go next

Nginx is the front door, and the HTTP / HTTPS guide explains the protocol it terminates and forwards. To run it beside your app reproducibly, read Docker & Deployment, and to keep the host it runs on healthy, revisit Linux. When you are ready to put it on the public internet with certificates, DNS and autoscaling, the Cloud Deployment guide ties the pieces together.

In practice

The four configs you will write most

Start with a proxy, add TLS, scale to a pool, then layer on caching and rate limiting.

sites-available/app
upstream app {
  server 127.0.0.1:3000;
  keepalive 32;
}

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

  location / {
    proxy_pass http://app;
    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 30s;
  }
}

Reverse proxy vs exposing the app directly

Running Node on port 80 gives up TLS, static caching, rate limiting and the ability to deploy without downtime. Nginx costs one process and buys all of them.

Prefer
server {
  listen 443 ssl;
  server_name app.example.com;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
  }
}
Avoid
# Node listening on :80 directly
# - no TLS termination
# - no static file cache
# - no rate limiting
# - restart drops connections
# - one process owns the port

ip_hash vs round-robin

Round-robin spreads load evenly. ip_hash pins a client to one backend, which is useful for sticky sessions but creates uneven load and loses that pin when a server dies.

Even load
upstream app {
  least_conn;
  server 10.0.0.11:3000;
  server 10.0.0.12:3000;
}
# Stateless apps and shared
# session stores scale cleanly.
Sticky sessions
upstream app {
  ip_hash;
  server 10.0.0.11:3000;
  server 10.0.0.12:3000;
}
# Pins clients, but a restart
# reshuffles their sessions.

Trade-offs

Is Nginx worth the extra process?

Nginx adds a configuration language and a second thing to deploy. For any public service the edge features repay that cost quickly.

Strengths

  • Security at the edge

    TLS, security headers, request size limits and rate limiting are enforced before a request reaches application code, so bugs are exposed to less.

  • Performance for free

    Static files, gzip and cached responses are served by an event-driven C server, freeing Node's single thread for actual work.

  • Deploys without downtime

    Reloading Nginx is graceful, and an upstream can drain a backend while it restarts, so users never see a connection refused.

Trade-offs

  • Another thing to learn and operate

    Config syntax, ordering rules and reload semantics are real surface area. A typo can take a site down if you skip nginx -t.

  • TLS termination moves the boundary

    Traffic between Nginx and the upstream is plain HTTP. On a shared host or network, encrypt or isolate that hop too.

  • It can hide application problems

    Caching and buffering mask slow origins, and forwarding headers are easy to forget, which leads to wrong client IPs and broken redirects.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Nginx / Reverse Proxy?

Our interactive tutorial walks you through Nginx / Reverse Proxy step by step — with quizzes and real code you can run in the browser.