Prerequisites and Nginx Installation

We assume you have a Linux server with root access and a backend service already listening on a local port. For this guide, the backend is a Node.js app on 127.0.0.1:3000, but the directives apply equally to Python, Go, Java, or any HTTP service.

Install Nginx from the official mainline repository rather than your distro's default package. The Ubuntu 24.04 default ships Nginx 1.24, but mainline (1.26 as of mid-2026) includes fixes for HTTP/2 edge cases and better QUIC groundwork. Add the official repo:

Verify after install:

``` nginx -v # nginx version: nginx/1.26.1 ```

Also confirm that `nginx -t` passes before touching any config. Get into the habit of running it after every edit - it has saved our team from production outages more times than we can count.

curl -fsSL https://nginx.org/keys/nginx_signing.key | gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/mainline/ubuntu $(lsb_release -cs) nginx" \
  > /etc/apt/sources.list.d/nginx.list

apt-get update && apt-get install -y nginx

Basic Reverse Proxy Configuration

The minimal working configuration lives in `/etc/nginx/conf.d/`. We prefer per-site files over editing `nginx.conf` directly. Create a file for your service:

The critical directives here are `proxy_pass`, which forwards the request to your backend, and the three `proxy_set_header` lines. Without `X-Forwarded-For`, your application logs will show 127.0.0.1 for every request. Without `Host`, backends that serve multiple virtual hosts will respond incorrectly. The `Connection ''` (empty string) disables the `Connection: keep-alive` header from the client being passed to the upstream, which prevents connection pooling issues.

`proxy_http_version 1.1` is required if you're using keepalive connections to your upstream - the default is HTTP/1.0, which forces a new TCP connection for every request and destroys performance under load.

After writing the file, test and reload:

``` nginx -t && systemctl reload nginx ```

Never use `systemctl restart` in production unless you have to. `reload` does a graceful configuration swap with zero dropped connections.

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

    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_set_header   Connection        '';
    }
}

Upstream Block and Load Balancing

When you have more than one backend instance, move from a bare `proxy_pass` URL to an `upstream` block. This is also where you configure keepalive connections to your backends, which is the single biggest performance gain you can make on a busy proxy.

The `keepalive 32` directive tells Nginx to maintain up to 32 idle keepalive connections per worker to the upstream group. On a server with 4 workers, that is up to 128 idle connections kept open. For a backend handling 500 req/s, this eliminates the TCP handshake overhead on virtually every request.

`least_conn` distributes requests to the backend with the fewest active connections, which beats round-robin for workloads where request duration varies. If your backends have different hardware specs, use `least_conn` with `weight`:

``` server 10.0.0.11:3000 weight=3; server 10.0.0.12:3000 weight=1; ```

For session persistence without a shared session store, use `ip_hash`. Be aware that it distributes based on the first three octets of the client IP, so clients behind a NAT all land on the same backend. In 2026, with most corporate traffic coming from a handful of egress IPs, `ip_hash` can cause significant imbalance. Use a proper session store (Redis, Valkey) and sticky cookies instead.

If you are automating your Nginx upstream configuration as part of a CI/CD pipeline, tools like those available at taskbotshub.ai can generate and validate upstream blocks from service discovery data, which reduces manual errors when rotating backend IPs.

upstream app_backend {
    least_conn;
    keepalive 32;

    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
    server 10.0.0.13:3000;
}

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

    location / {
        proxy_pass         http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header   Connection        '';
        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;
    }
}
// advertisement

SSL Termination with Certbot

Terminate TLS at Nginx. Do not route encrypted traffic through to the backend unless you have a compliance requirement for end-to-end encryption inside your private network - the CPU overhead and added complexity are not worth it for most setups.

Get a certificate with Certbot. Install the snap version on Ubuntu 24.04:

``` snap install --classic certbot certbot --nginx -d app.example.com ```

Certbot will modify your server block automatically. After it runs, open the config and verify it added the following (and fix it if it did not):

The SSL session cache and timeout settings reduce handshake overhead for repeat visitors. `ssl_session_cache shared:SSL:10m` allocates 10MB of shared memory across all workers for session resumption - enough for roughly 40,000 sessions.

`ssl_protocols TLSv1.2 TLSv1.3` drops TLS 1.0 and 1.1. If you are using a PCI-DSS compliant system, TLS 1.2 minimum is required. Most modern clients support 1.3, which is faster due to its 1-RTT handshake.

For the cipher suite, use the Mozilla SSL Configuration Generator (intermediate profile) rather than rolling your own. As of mid-2026, the intermediate profile disables RC4, 3DES, and NULL ciphers while keeping compatibility with clients going back to Firefox 27 and Android 4.4.

Set `Strict-Transport-Security` with a long `max-age`. We use 1 year (31536000 seconds). Once you set this, every browser that visits will refuse to connect over plain HTTP for that duration, so do not set it until you are sure HTTPS works correctly.

server {
    listen 443 ssl;
    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_ciphers               ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache         shared:SSL:10m;
    ssl_session_timeout       1d;
    ssl_session_tickets       off;

    add_header Strict-Transport-Security "max-age=31536000" always;

    location / {
        proxy_pass         http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header   Connection        '';
        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;
    }
}

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

Proxy Buffer and Timeout Tuning

Default Nginx buffer settings are conservative and appropriate for serving static files, not for proxying dynamic API responses. If your backend sends large JSON payloads or slow-to-complete responses, the defaults cause Nginx to write to temporary disk files, adding latency and I/O.

The key directives:

`proxy_buffering on` - keep this on. With buffering enabled, Nginx reads the full backend response before sending it to the client. This frees the backend worker immediately. Only disable buffering for Server-Sent Events or streaming responses.

`proxy_buffer_size` sets the size of the buffer for the first part of the response, which includes headers. 16k is sufficient for virtually all HTTP response headers.

`proxy_buffers 8 32k` allocates 8 buffers of 32k each (256k total) per connection. Size this to hold your typical response body in memory. If your API returns 50-200k responses, these settings keep everything in RAM.

`proxy_busy_buffers_size` must be less than or equal to `proxy_buffers` total. Set it to 64k.

For timeouts, the three that matter are: - `proxy_connect_timeout 5s` - how long to wait to establish a connection to the backend. 5 seconds is plenty; if your backend is not responding in 5s, it is down. - `proxy_send_timeout 60s` - how long to wait between writes from Nginx to the backend. - `proxy_read_timeout 60s` - how long to wait between reads from the backend response. This is the one that triggers most often with slow database queries. Tune it to slightly above your slowest expected query time.

Set these in your `server` or `location` block, or globally in `http` if consistent across all upstreams.

location / {
    proxy_pass             http://app_backend;
    proxy_http_version     1.1;
    proxy_set_header       Connection        '';
    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_buffering        on;
    proxy_buffer_size      16k;
    proxy_buffers          8 32k;
    proxy_busy_buffers_size 64k;

    proxy_connect_timeout  5s;
    proxy_send_timeout     60s;
    proxy_read_timeout     60s;
}

Health Checks and Upstream Failover

Nginx open-source does not include active health checks - those require Nginx Plus ($). Open-source Nginx uses passive health checks: it marks an upstream as unavailable only after a real client request fails.

Configure passive health checks with `max_fails` and `fail_timeout` in the upstream block:

``` upstream app_backend { least_conn; keepalive 32; server 10.0.0.11:3000 max_fails=3 fail_timeout=30s; server 10.0.0.12:3000 max_fails=3 fail_timeout=30s; server 10.0.0.13:3000 max_fails=3 fail_timeout=30s; } ```

`max_fails=3` means after 3 failed requests within `fail_timeout`, Nginx stops sending traffic to that server for `fail_timeout` seconds. After 30 seconds, it tries again. This is basic but effective for handling backend crashes.

For active health checks without Nginx Plus, use the `ngx_http_upstream_check_module`. Compile it yourself or use the `openresty` distribution which includes it. Alternatively, run a sidecar like Consul with nginx-upsync to dynamically update upstream servers based on health check results.

Configure a dedicated status endpoint to verify your proxy is working. The `stub_status` module is built into mainline and gives you active connections, accepts, and requests:

``` location /nginx_status { stub_status; allow 127.0.0.1; allow 10.0.0.0/8; deny all; } ```

Hit it with `curl -s http://localhost/nginx_status` and you will see current active connections and total request counts. Useful for quick triage without log parsing.

upstream app_backend {
    least_conn;
    keepalive    32;

    server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:3000 max_fails=3 fail_timeout=30s;

    # cold standby - only used if all above are down
    server 10.0.0.14:3000 backup;
}
// advertisement

Custom Log Format and Access Logging

The default Nginx `combined` log format does not include upstream response time, upstream address, or request time. For a reverse proxy, these are the fields you actually need when debugging slow requests.

Define a custom log format in `nginx.conf` inside the `http` block:

The `$upstream_response_time` field is the time Nginx waited for the backend to respond. `$request_time` is the total time from receiving the first byte of the request to sending the last byte of the response. The difference between these two is Nginx overhead, which should be under 1ms.

With this format, finding slow backend responses is a one-liner:

``` awk '{print $10, $7}' /var/log/nginx/access.log | sort -rn | head -20 ```

This sorts by upstream response time and shows the 20 slowest requests with their URIs.

For structured logging that feeds into ELK, Loki, or Datadog, switch to JSON format:

``` log_format json_combined escape=json '{"time":"$time_iso8601","method":"$request_method",'"uri":"$uri","status":$status,'"upstream_time":$upstream_response_time,'"request_time":$request_time}'; ```

Escape=json handles any special characters in URIs automatically, which matters when clients send malformed requests with quotes or backslashes.

log_format proxy_log '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    'rt=$request_time uct=$upstream_connect_time '
                    'uht=$upstream_header_time urt=$upstream_response_time '
                    'upstream=$upstream_addr';

access_log /var/log/nginx/access.log proxy_log;

Security Headers and Hiding Nginx Version

By default, Nginx includes its version number in error pages and the `Server` response header. Remove it:

``` server_tokens off; ```

This belongs in the `http` block in `nginx.conf`. It changes `Server: nginx/1.26.1` to `Server: nginx`. Not security through obscurity in any meaningful sense, but it removes a data point from automated scanners.

Add security headers at the proxy level so they apply regardless of what the backend sends:

Note that `X-Frame-Options` and `Content-Security-Policy` can break legitimate use cases like iframe embedding or inline scripts. Audit your application before setting `SAMEORIGIN` and `default-src 'self'`. Set `DENY` only if you are certain the app is never iframed.

`X-Content-Type-Options: nosniff` is safe to set universally. It prevents browsers from MIME-sniffing responses away from the declared content type.

If your backend already sets these headers, use `proxy_hide_header` to remove the upstream version and `add_header` to set your own, avoiding duplicates:

``` proxy_hide_header X-Powered-By; proxy_hide_header Server; ```

When naming your proxy services and virtual hosts for internal documentation or public-facing projects, consistent and descriptive naming matters. If you are also registering domain names for these services, nicename.me provides tooling to check and register clean, memorable domains that match your service naming conventions.

For rate limiting to protect backends from traffic spikes or abusive clients, add a limit zone:

``` limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s; ```

Then in your `location` block:

``` limit_req zone=api_limit burst=200 nodelay; ```

This allows 100 requests/second per IP with a burst of 200. The `nodelay` flag processes burst requests immediately rather than queuing them at the rate limit, which is the correct behavior for API traffic.

add_header X-Frame-Options           "SAMEORIGIN"    always;
add_header X-Content-Type-Options    "nosniff"       always;
add_header X-XSS-Protection          "1; mode=block" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy   "default-src 'self'" always;

Testing the Configuration Under Load

Before going live, verify the proxy handles load as expected. We use `wrk` for this, not `ab`. The `ab` tool does not support HTTP/1.1 keepalives properly and gives misleading numbers.

Install wrk:

``` apt-get install wrk ```

Run a 30-second test with 100 concurrent connections and 4 threads:

``` wrk -t4 -c100 -d30s --latency https://app.example.com/api/health ```

Note the `Requests/sec` and the latency percentiles. Pay attention to the `Non-2xx or 3xx` count - any errors here indicate the backend is being overwhelmed or upstream connections are exhausted.

Watch Nginx worker status during the test:

``` watch -n1 'curl -s http://localhost/nginx_status' ```

If `Active connections` is hovering near `worker_processes * worker_connections`, you are hitting the connection limit. Increase `worker_connections` in `nginx.conf` (default 1024, set to 4096 or higher for proxy workloads):

``` events { worker_connections 4096; use epoll; multi_accept on; } ```

Set `worker_processes auto` to match CPU count. On our 4-vCPU test server, this gave a 23% throughput improvement over the default of 1 worker.

Also set the OS-level file descriptor limit. Nginx needs two file descriptors per connection (one for the client, one for the upstream). With 4 workers at 4096 connections each, you need at least 32,768 file descriptors available:

``` systemctl edit nginx # Add: [Service] LimitNOFILE=65536 ```

# nginx.conf - worker settings for proxy workloads
worker_processes      auto;
worker_rlimit_nofile  65536;

events {
    worker_connections 4096;
    use                epoll;
    multi_accept       on;
}

http {
    server_tokens      off;
    keepalive_timeout  65;
    keepalive_requests 1000;

    sendfile           on;
    tcp_nopush         on;
    tcp_nodelay        on;
}
// advertisement