Nginx is a high-performance HTTP and reverse proxy server, and also an IMAP/POP3/SMTP proxy server, known for its high-concurrency handling and low resource usage. Installing Nginx takes only a few commands and a few minutes to get running; but what actually determines a site’s real behavior — how it dispatches requests, how it proxies to backends, how it handles old links — is all written in the configuration file. This article only briefly covers installation, spending most of its length on configuration: how the config file is organized, what priority order location matching follows, how to write a reverse proxy, how to cache static assets, and how to migrate old URLs. The final section gives the actual production configuration this site runs, as a complete example you can read side by side with the rest.

1. Installing Nginx

For everyday use, the package-manager version is enough:

sudo apt install nginx && sudo systemctl enable --now nginx   # Debian/Ubuntu
sudo dnf install nginx && sudo systemctl enable --now nginx   # CentOS/Fedora

The main config file is at /etc/nginx/nginx.conf, and site-specific configs usually live in /etc/nginx/conf.d/*.conf (RPM-based) or sites-available/ + sites-enabled/ (Debian-based). Building from source is only worth it when you need a specific version, or a module the packaged version wasn’t compiled with: install the build dependencies, then ./configure with the modules you want enabled, followed by make && make install — the ./configure flags only take effect at compile time, so adding a module later means recompiling the entire binary:

sudo apt install build-essential libpcre3-dev zlib1g-dev libssl-dev
wget http://nginx.org/download/nginx-<version>.tar.gz && tar -zxvf nginx-<version>.tar.gz
cd nginx-<version> && ./configure --prefix=/usr/local/nginx --with-http_ssl_module && make && sudo make install

2. How the Configuration File Is Organized

Nginx’s configuration is built from a series of directives and contexts (blocks), nested inside one another — an inner block inherits the outer block’s settings and can also override them. The core layers are:

main (global)
 ├─ events { ... }   connection handling, e.g. worker_connections
 ├─ http { ... }      overall config for the HTTP service
 │   ├─ server { ... }        one virtual host
 │   │   └─ location { ... }  one URL matching rule
 │   └─ server { ... }        another virtual host
 ├─ mail { ... }      mail proxying (less commonly used)
 └─ stream { ... }    TCP/UDP layer-4 proxying
  • Directives at the main level are written directly at the top level of the file, e.g. worker_processes (the number of worker processes, usually set to auto so Nginx picks it based on CPU core count) and pid (the location of the master process’s PID file).
  • The events block only concerns connection-level parameters; the one most commonly tuned is worker_connections, the maximum number of simultaneous connections a single worker can hold.
  • The http block is where the vast majority of configuration actually happens: log format, gzip, timeouts, and every server block hung underneath it.
  • One http block can hold multiple server blocks, distinguished by listen and server_name into different virtual hosts; one server block can hold multiple location blocks, distinguished by URL path into different handling rules.

In real projects, the http block usually doesn’t have everything written into a single file — instead, include is used to split the config apart:

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

This lets each site, each feature, be maintained in its own file, so changes don’t interfere with one another, and it also makes it easy to include standalone mapping tables as needed (used in section 6 below).

3. The server Block: What a Virtual Host Looks Like

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

    location / {
        root   /var/www/html;
        index  index.html index.htm;
    }

    error_page  404              /404.html;
    location = /404.html {
        root   /var/www/html;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /var/www/html;
    }
}

listen determines the port (and optional IP) it listens on, and server_name determines which domain names this block responds to — the same port can have many server blocks with different server_name values hanging off it, and Nginx matches the incoming Host header to the right one. root specifies this virtual host’s file root directory, and index specifies the default files to try in order for a directory request. error_page rewrites the response for a given status code to a specified path, commonly used for unified 404/50x pages.

4. location Matching Rules and Priority

location determines “how this URL should be handled.” Nginx supports several matching syntaxes, and understanding their priority order matters more than memorizing the syntax itself:

  1. location = /path — exact match; only hits when the URL exactly equals /path, the highest priority.
  2. location ^~ /prefix — prefix match; once it matches, no further regex checks happen, giving it higher priority than a plain regex.
  3. location ~ /pattern / location ~* /pattern — regex match (~ case-sensitive, ~* case-insensitive), tried in the order they appear in the config.
  4. location /prefix — plain prefix match; if nothing higher-priority matched, the longest matching prefix wins.

In other words, Nginx doesn’t use “the first match from top to bottom” — it filters through these four categories in priority order, and only falls through to regex matching once neither an exact match nor a ^~ prefix has hit. The easiest mistake when writing multiple location blocks is assuming they take effect in the order they’re written — in reality, only same-type regex blocks are evaluated in written order; across different types, priority order wins, not writing order.

location = /healthz {
    return 200 "ok\n";
}

location ~* \.(?:jpg|jpeg|png|svg|webp)$ {
    expires 30d;
    add_header Cache-Control "public";
}

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

try_files is a directive commonly used inside location: it tries the listed paths in order, using the first one that exists to respond, and falling back to the last one if none exist (here, returning 404). A common pattern for single-page applications is try_files $uri $uri/ /index.html; — falling back uniformly to the entry page when no static file is found, and letting the frontend router take over.

5. Reverse Proxying and upstream

Putting Nginx in front of backend services as a reverse proxy centers on the proxy_pass directive:

upstream app_backend {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

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;
    }
}

The upstream block defines a group of backends, round-robining between them by default; you can also add weights (server 127.0.0.1:3000 weight=3;) or switch to a strategy like least_conn. When forwarding requests, the backend by default only sees the connection coming from Nginx, not the real client’s information, so proxy_set_header is used to pass through the original Host, the client IP (X-Real-IP/X-Forwarded-For), and the protocol (X-Forwarded-Proto) — the backend application needs to read these headers to get the real visitor’s information, especially for access logging or protocol-based redirects.

TLS termination is also usually placed at this reverse-proxy layer: certificates are configured on the publicly-facing server block (listen 443 ssl;, ssl_certificate, ssl_certificate_key), and Nginx can continue talking to the backend over plain HTTP, reducing the burden of certificate management on the backend.

6. Static Assets, Compression, and Caching

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;

location ~* ^/(?:_astro|assets)/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
}

The gzip family of directives compress the response body by type, and gzip_min_length avoids pointlessly compressing responses that are already small. For static assets whose filenames carry a hash (a common build-output pattern), it’s safe to set a very long expires along with immutable: since any content change is guaranteed to change the filename, the browser caching the old filename long-term will never end up serving stale content. Assets without a hash can’t be configured this way, or users may end up seeing an old version for a long time after an update.

7. Redirects and Legacy URL Migration

After a site redesign or URL structure change, old descriptive slugs need to be migrated to new ones. The approach is to generate an “old path → new path” mapping table from the build output’s canonical URLs, and have Nginx uniformly return a 301. This site’s canonical-paths.map also handles trailing slashes, index.html, language suffixes, and deprecated listing paths:

map $request_uri $request_path {
    ~^([^?]*) $1;
}

map $uri $canonical_path {
    default $request_path;
    include /etc/nginx/canonical-paths.map;
}

server {
    ...
    if ($request_path != $canonical_path) {
        return 301 https://aoi.ai$canonical_path$is_args$args;
    }
}

map itself doesn’t redirect anything — it just computes the canonical path based on the request path; the actual redirect happens in the return 301 line after it. The mapping table is generated automatically from the actual built HTML, so adding a new page doesn’t require hand-writing a location. $is_args$args preserves the original query string, and the absolute HTTPS apex target simultaneously normalizes protocol, hostname, and path, avoiding redirect chains.

8. A Complete Production Configuration

Putting the pieces from the sections above together gives this site’s actual production configuration (certificate-related settings omitted):

map $request_uri $request_path {
    ~^([^?]*) $1;
}

map $uri $canonical_path {
    default $request_path;
    include /etc/nginx/canonical-paths.map;
}

server {
    listen 8000;
    listen [::]:8000;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;
    charset utf-8;
    absolute_redirect off;
    server_tokens off;

    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml application/javascript application/json application/rss+xml image/svg+xml;

    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    if ($request_path != $canonical_path) {
        return 301 https://aoi.ai$canonical_path$is_args$args;
    }

    location ~* ^/(?:_astro|article-assets)/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    location = /healthz {
        access_log off;
        default_type text/plain;
        return 200 "ok\n";
    }

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

A few details worth calling out individually:

  • server_tokens off; keeps error pages and response headers from exposing Nginx’s specific version number, slightly reducing the information surface available to targeted scanning.
  • The always in add_header ... always; means this header should be attached even on error pages (4xx/5xx) — by default, add_header gets skipped on some error responses.
  • /healthz has access_log turned off separately, since health checks are typically high-frequency requests every few seconds, and logging all of them would just drown the log in noise.
  • The final try_files $uri $uri/ $uri/index.html =404; is a common pattern for static sites: first look for an exact file, then a directory, then an index.html under that directory, only returning 404 if none of those are found — this is to accommodate the about/index.html-style directory structure produced by static site generators like Astro.

9. Validating and Reloading

Don’t restart directly after changing the config — validate the syntax first:

sudo nginx -t

Once you’ve confirmed there are no errors, use reload, not restart:

sudo systemctl reload nginx
# or
sudo nginx -s reload

reload lets Nginx smoothly start new worker processes with the new config, letting the old workers finish handling their current connections before exiting — no requests are dropped in between. restart, on the other hand, kills the process first and then starts it again, causing a brief service interruption. Routine config changes, certificate swaps, and timeout adjustments should all use reload.