Nginx on Docker: Complete Guide with Defaults, Formulas, and Real Calculations

25 min read

Published

No vague advice. Every directive: what it does, the default if you omit it, and exactly how to calculate the right value — with numbers.

Most Nginx guides say things like "set worker_connections to something reasonable." That's not helpful when your server crashes at 2 a.m.

This guide is different. For every important setting you'll get four things:

  1. What it does — in plain language
  2. Default value — what Nginx uses if you write nothing
  3. What goes wrong — if you leave the default or set it wrong
  4. How to calculate — step-by-step with real numbers

We'll use a concrete example server throughout:

Example server (our reference case):
4 vCPU · 8 GB RAM · 200 concurrent users · max upload 50 MB · largest API response 400 KB · backend response time up to 45 seconds (report exports)

Adjust the numbers for your server. The method stays the same.

How to read this guide (start here)

Every code block has a colored label. Do only what the label says.

Follow parts in order unless a part says you can skip it. Change example IPs, passwords, and paths to match your lab.

Suggested path: follow parts in order from first container to TLS and hardening.

Project structure and docker-compose.yml

What this part is for: cover: Project structure and docker-compose.yml.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
Pre-flight (Senior DevOps): Know CPU/RAM, max upload size, slowest backend timeout, TLS cert paths, and backend hostname before copying configs. Stock nginx:alpine has no wget — this stack uses a tiny Dockerfile with curl for healthchecks.
READ — reference table (not a terminal command).
nginx-stack/
├── docker-compose.yml
├── Dockerfile              (optional)
├── nginx/
│   ├── nginx.conf
│   ├── conf.d/default.conf
│   ├── snippets/ (ssl-params.conf, gzip.conf, security-headers.conf)
│   └── ssl/
└── html/
COPY FILE — save as Dockerfile (adds curl for healthcheck; pin base image).
FROM nginx:1.27-alpine
RUN apk add --no-cache curl
COPY FILE — save as docker-compose.yml (edit paths, ports, and image tags).
services:
  nginx:
    build: .
    image: nginx-stack:1.27
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/snippets:/etc/nginx/snippets:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./html:/usr/share/nginx/html:ro
      - nginx_logs:/var/log/nginx
    networks:
      - web
    depends_on:
      api:
        condition: service_started
    ulimits:
      nofile:
        soft: 65535
        hard: 65535
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s

  api:
    image: hashicorp/http-echo:1.0.0
    container_name: api
    command: ["-text=ok", "-listen=:8080"]
    networks:
      - web
    restart: unless-stopped

networks:
  web:
    name: web

volumes:
  nginx_logs:

Full nginx.conf (with comments showing our calculated values)

What this part is for: cover: Full nginx.conf (with comments showing our calculated values).
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
COPY FILE — save as nginx/nginx.conf (values shown are calculated examples).
user nginx;
worker_processes 4;              # calculated: = CPU cores
worker_rlimit_nofile 65535;      # calculated: ≥ worker_connections × 2

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 2048;     # calculated: see below
    multi_accept on;
    use epoll;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format main '$remote_addr [$time_local] "$request" $status '
                    'rt=$request_time urt="$upstream_response_time"';

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

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;

    keepalive_timeout   65;
    keepalive_requests  500;

    server_tokens off;

    client_max_body_size        55m;    # calculated: 50 MB upload + 10%
    client_body_buffer_size     128k;
    client_header_buffer_size   2k;
    large_client_header_buffers 4 16k;

    limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

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

    proxy_connect_timeout  10s;
    proxy_send_timeout     60s;
    proxy_read_timeout     55s;       # calculated: 45s backend + 10s

    proxy_buffer_size      16k;
    proxy_buffers          32 16k;    # calculated: ≥ 400 KB response
    proxy_busy_buffers_size 48k;

    include /etc/nginx/snippets/gzip.conf;
    include /etc/nginx/conf.d/*.conf;
}

Part 1 — Worker and connection settings

What this part is for: cover: Worker and connection settings.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

worker_processes

What it does: Number of Nginx worker processes. Each worker is a separate OS process that handles connections. Nginx does not use threads for connections — one worker = one process.

Default if omitted: 1 (only one worker, even on a 16-core server)

If you leave default: On a 4-core server, 3 cores sit idle. Max throughput is roughly 25% of what your CPU can handle.

How to calculate:

worker_processes = number of CPU cores (or "auto")
Our server has 4 vCPU → worker_processes 4
Check cores: nproc inside container → returns 4

worker_connections

What it does: Maximum simultaneous connections per worker. Includes: client connections + all proxy connections to backends + idle keepalive connections. One browser tab can hold multiple connections.

Default if omitted: 512

If you leave default: Max total connections = 1 worker × 512 = 512. With 200 concurrent users × ~6 connections each = 1,200 needed. You get connection refused errors and slow loading.

How to calculate — step by step:

  1. Count expected concurrent users: 200
  2. Connections per user (browser opens ~6 per page): 6
  3. Raw client connections: 200 × 6 = 1,200
  4. Add 20% headroom for spikes: 1,200 × 1.2 = 1,440
  5. Divide by worker_processes: 1,440 ÷ 4 = 360 per worker
  6. Round up to a clean number: 512 or 1024 or 2048
worker_connections = (concurrent_users × connections_per_user × 1.2) ÷ worker_processes
(200 × 6 × 1.2) ÷ 4 = 360 → we pick 2048 for future growth
Verify max capacity: 4 × 2048 = 8,192 total connections (enough for ~1,300 users at 6 conn each)

worker_rlimit_nofile

What it does: Sets the maximum number of open file descriptors (FDs) each worker process can use. Every TCP connection = 1 FD. Every open file (log, cache, static file) = 1 FD.

Default if omitted: OS default, often 1024 inside Docker containers

If you leave default: Even if worker_connections = 2048, the worker crashes at ~1024 open files with error: "too many open files (24)"

How to calculate:

worker_rlimit_nofile ≥ worker_connections × 2

The ×2 accounts for: one FD for client side + one FD for backend side of each proxied request.

worker_connections = 2048 → minimum = 2048 × 2 = 4096
We set 65535 (common production value, avoids recalculating later)
Also set Docker ulimits.nofile to 65535 in docker-compose (shown above)

multi_accept

What it does: When on, a worker accepts all new connections waiting in the queue at once. When off, accepts one at a time.

Default if omitted: off

If you leave default: Under high load, connection acceptance is slightly slower. Noticeable only above ~1,000 req/s.

Recommendation: on for production. No calculation needed.

use epoll

What it does: Tells Nginx which OS event mechanism to use for checking thousands of connections efficiently.

Default if omitted: Nginx auto-selects the best for your OS (epoll on Linux, kqueue on BSD/Mac)

If you leave default: Usually fine on Linux. Explicitly setting epoll is documentation, not a performance fix.

Recommendation: use epoll; on Linux Docker hosts. Remove on Windows/Mac Docker.

Part 2 — Client request limits

What this part is for: cover: Client request limits.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

client_max_body_size

What it does: Maximum size of the request body (file uploads, large POST/PUT payloads). If a request exceeds this, Nginx returns 413 Request Entity Too Large immediately — the request never reaches your backend.

Default if omitted: 1m (1 megabyte)

If you leave default: Any upload over 1 MB fails with 413. This is the #1 cause of "upload works locally but not in production."

How to calculate — step by step:

  1. Find the largest file users can upload: 50 MB
  2. Add 10% margin: 50 × 1.1 = 55 MB
  3. Set: client_max_body_size 55m;
client_max_body_size = max_upload_size_MB × 1.1 → write as "Xm"
Upload limit 50 MB → client_max_body_size 55m;
JSON-only API, no uploads → client_max_body_size 2m; is enough
Video platform, 2 GB uploads → client_max_body_size 2200m;

client_body_buffer_size

What it does: Size of memory buffer for reading the request body. If the body fits in this buffer, it stays in RAM (fast). If larger, Nginx writes the body to a temp file on disk (slow — disk I/O per upload).

Default if omitted: 8k on older Nginx, 16k on Nginx 1.21+

If you leave default: A 50 MB upload always goes to disk temp file. Works, but uses disk and is slower. For a 2 KB JSON POST, 16k is fine.

How to calculate:

If most requests < 128 KB → set 128k (keeps typical requests in RAM)
If uploads are always large → keep 128k–512k (large files go to disk anyway; bigger buffer only helps medium-sized bodies)
Mixed API + 50 MB uploads → client_body_buffer_size 128k;
(50 MB uploads still spill to disk — that's normal and expected)

client_header_buffer_size

What it does: Buffer for reading the request header (URL, cookies, Authorization token, User-Agent, etc.). Most requests fit here.

Default if omitted: 1k (1024 bytes)

If you leave default: Fine for 99% of requests. Fails only with unusually large single headers.

Recommendation: 2k if you use large JWT tokens in cookies. Calculation: measure your largest Cookie header byte size + 20% margin.

large_client_header_buffers

What it does: Fallback buffers when the request header exceeds client_header_buffer_size. Format: count size — up to count buffers, each size bytes.

Default if omitted: 4 8k → total 4 × 8 KB = 32 KB max header size

If you leave default: Headers over 32 KB → 400 Bad Request error: "Request Header Or Cookie Too Large"

How to calculate:

  1. Measure total header size of your largest request (browser DevTools → Network → request headers size)
  2. Typical web app: 2–8 KB. Heavy auth cookies: 10–20 KB.
  3. Set: count × size ≥ measured_size × 1.5
Largest measured header = 12 KB → 12 × 1.5 = 18 KB needed
Default 4 × 8k = 32 KB → default is enough, no change needed
If headers reach 40 KB → set large_client_header_buffers 8 16k; (= 128 KB total)

Part 3 — Keepalive settings

What this part is for: cover: Keepalive settings.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

keepalive_timeout

What it does: How many seconds Nginx keeps an idle client TCP connection open before closing it. While open, the same connection can serve the next HTTP request without a new TCP + TLS handshake.

Default if omitted: 75 seconds

If you leave default: Usually fine. 75s is a reasonable default.

How to choose:

We set 65s — slightly below default, frees slots a bit faster with 200 concurrent users.

keepalive_requests

What it does: Maximum number of HTTP requests on one keepalive connection before Nginx closes it (forces a fresh connection).

Default if omitted: 1000 (since Nginx 1.19.10; older versions: 100)

If you leave default: Fine for most cases.

How to calculate:

keepalive_requests = peak_requests_per_second_per_connection × keepalive_timeout

In practice: 100–1000. Lower values (100–500) help prevent memory leaks in buggy clients. We set 500.

Part 4 — Proxy buffers (reverse proxy)

What this part is for: cover: Proxy buffers (reverse proxy).
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

When Nginx proxies a request to your backend, the backend's response flows through these buffers in Nginx's memory before being sent to the client.

proxy_buffer_size

What it does: Buffer for reading the response headers from the backend (status line, Content-Type, Set-Cookie, etc.).

Default if omitted: 4k or 8k (depends on platform)

If you leave default: If backend sends very large response headers (many Set-Cookie, long redirects), you get: "upstream sent too big header while reading response header"

How to calculate: Measure backend response header size. Usually 1–4 KB. Set 8k–16k to be safe.

We set 16k — safe default when unsure.

proxy_buffers

What it does: Buffers for reading the response body from the backend. Format: count size. Total memory = count × size per request.

Default if omitted: 8 4k or 8 8k → total 32 KB or 64 KB

If you leave default: Backend response body over 32–64 KB gets written to a temp file on disk (slower). Response over buffer + temp file limits can cause errors.

How to calculate — step by step:

  1. Measure largest response body from your backend (browser DevTools → response size, or curl -w '%{size_download}')
  2. Our largest API response: 400 KB
  3. Add 20% margin: 400 × 1.2 = 480 KB
  4. Pick count and size: 32 × 16k = 512 KB ≥ 480 KB ✓
proxy_buffers count size → count × size ≥ largest_response_bytes × 1.2
Response 400 KB → proxy_buffers 32 16k; (512 KB total)
Response 2 MB → proxy_buffers 64 32k; (2048 KB = 2 MB total)
Large file downloads → use proxy_buffering off; instead (stream directly, no buffer limit)

Memory impact: 200 concurrent proxied requests × 512 KB = 102 MB RAM for proxy buffers alone. Check your server has headroom.

proxy_busy_buffers_size

What it does: Maximum size of buffers that can be busy sending data to the client while still reading from the backend.

Default if omitted: Usually 8k or 16k, or auto-calculated as min(2×proxy_buffer_size, proxy_buffers_total − one_buffer)

If you set wrong: Nginx refuses to start: "proxy_busy_buffers_size must be less than the size of all proxy_buffers minus one buffer"

How to calculate:

proxy_busy_buffers_size = proxy_buffer_size × 2 (common rule)
Must be < (proxy_buffers count × size) − proxy_buffer_size
proxy_buffer_size = 16k, proxy_buffers = 32 × 16k = 512k
Max allowed = 512k − 16k = 496k
We set 48k (= 16k × 3, well under the limit) ✓

Part 5 — Proxy timeouts

What this part is for: cover: Proxy timeouts.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

proxy_connect_timeout

What it does: Max seconds to establish a TCP connection to the backend. Starts when Nginx tries to connect, ends when TCP handshake completes.

Default if omitted: 60s

If you leave default: A dead backend hangs for 60 seconds before Nginx returns 502. Too long for user-facing apps.

How to calculate: Backend on same Docker network connects in <1ms. Set 5–10s.

Same-host Docker network → proxy_connect_timeout 10s; (if no connection in 10s, backend is down)

proxy_send_timeout

What it does: Max seconds between two successive write operations when sending the request to the backend. If your upload to the backend stalls, this fires.

Default if omitted: 60s

How to calculate:

proxy_send_timeout ≥ (max_upload_size_MB ÷ upload_speed_MB_per_sec) + 10s
50 MB upload at 5 MB/s = 10 seconds → set 60s (default is fine)
500 MB upload at 1 MB/s = 500 seconds → set 600s

proxy_read_timeout

What it does: Max seconds of silence from the backend while Nginx waits for the response. Timer resets on each byte received. If backend stops sending for longer than this → 504 Gateway Timeout.

Default if omitted: 60s

If you leave default: Backend that takes 45 seconds to generate a report works. Backend that takes 90 seconds → 504 error.

How to calculate — step by step:

  1. Find slowest backend endpoint response time: 45 seconds (report export)
  2. Add 10 second margin: 45 + 10 = 55 seconds
  3. Set: proxy_read_timeout 55s;
proxy_read_timeout = slowest_backend_response_seconds + 10
Fast API only (all endpoints < 2s) → 30s is enough
Report generation 45s → 55s
WebSocket (long-lived) → 3600s (1 hour)

Part 6 — Rate limiting

What this part is for: cover: Rate limiting.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

limit_req_zone

What it does: Creates a shared memory zone that tracks request rates per IP (or other key). Must be defined in http {} block, then applied in server/location with limit_req.

Default if omitted: No rate limiting at all. Unlimited requests per IP.

Format: limit_req_zone $binary_remote_addr zone=NAME:SIZE rate=RATE;

SIZE (zone memory) — how to calculate:

1 MB zone stores ~16,000 unique IP addresses
10m zone stores ~160,000 unique IP addresses
Small site (<10K visitors/day) → zone=general:1m is enough
Medium site → zone=general:10m (our choice)
If zone memory fills, oldest entries are evicted (rate limit becomes inaccurate for those IPs)

RATE — how to calculate:

  1. Measure normal peak: e.g. 100 requests/second total across all users
  2. Estimate users at peak: 200
  3. Per-user fair share: 100 ÷ 200 = 0.5 req/s per user
  4. Set limit above fair share to avoid blocking normal users: 0.5 × 10 = 5–20 req/s per IP
We set rate=20r/s — allows 20 requests per second per IP. A normal user won't hit this. A bot doing 100 req/s gets throttled.

limit_req burst

What it does: Allows temporary spikes above the rate limit. Applied in location block: limit_req zone=general burst=N nodelay;

Default if omitted: burst=0 — strict rate, no spikes allowed. Excess requests get 503 immediately.

How to calculate:

burst = max_spike_requests_in_one_second − rate
rate = 20r/s. User opens page that loads 25 assets simultaneously.
burst = 25 − 20 = 5 (minimum). We set burst=20 for comfortable headroom.

nodelay means burst requests are processed immediately (not queued with delay).
Without nodelay: excess requests wait in queue (slower but smoother).

limit_conn

What it does: Limits how many simultaneous connections one IP can open to Nginx. Different from limit_req (which limits requests per second).

Default if omitted: No connection limit per IP.

How to calculate:

limit_conn = connections_per_user × 1.5
Normal browser: ~6 connections per page → set limit_conn 20 per IP
Prevents one IP from opening 500 connections and exhausting worker_connections

Part 7 — Gzip compression

What this part is for: cover: Gzip compression.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
COPY FILE — save as nginx/snippets/gzip.conf (include from nginx.conf).
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript;

gzip

What it does: Compresses response bodies before sending to the client. Reduces bandwidth, increases CPU usage.

Default if omitted: off — no compression, responses sent as-is

If you leave default: A 500 KB JSON response sends as 500 KB instead of ~50 KB compressed. Slower for users on mobile.

gzip_comp_level

What it does: Compression effort. Range 1 (fastest, weakest) to 9 (slowest, strongest).

Default if omitted: 1

How to choose (real numbers):

Set 5 — best balance. Only use 9 for static pre-compressed assets built at deploy time.

gzip_min_length

What it does: Don't compress responses smaller than this (compression header overhead isn't worth it for tiny files).

Default if omitted: 20 bytes

Recommendation: 256 or 1024 bytes. Compressing a 100-byte response can make it larger.

gzip_types

What it does: Which MIME types to compress. Only listed types are compressed.

Default if omitted: text/html only

If you leave default: JSON, CSS, JavaScript are NOT compressed. Your API responses stay uncompressed.

Recommendation: Always explicitly list text/plain, text/css, application/json, application/javascript, image/svg+xml.

Part 8 — Upstream and load balancing

What this part is for: choose an Nginx load-balancing method and write a real upstream block.
What you should do: read the methods, then COPY only the example that matches your topology.

An upstream block is a named pool of backends. proxy_pass http://backend; sends requests to that pool. Open-source Nginx (the Docker image in this guide) supports these methods — pick one per upstream:

MethodDirectiveWhen to useWhen not to
Round-robin (default) (none — omit method) Identical backends, similar request cost One replica is slower or overloaded
Least connections least_conn; Long-lived or uneven request duration (APIs, WebSocket-ish HTTP) All requests are tiny and finish in milliseconds — little benefit
IP hash ip_hash; Sticky sessions by client IP (in-memory sessions on the app) Clients behind a shared NAT / CDN — many users look like one IP
Generic hash hash $key [consistent]; Stick on a header, cookie, or URI (cache-friendly sharding) Key has low cardinality (everyone hashes to one server)
Random random; or random two least_conn; Large pools; optional two-choice + least_conn is often better than naive random You need strict session affinity
Nginx Plus only: least_time, queue, and active health checks (health_check in location) are commercial. Open-source Nginx uses max_fails / fail_timeout (passive). Do not copy Plus directives into the official nginx Docker image — they will not load.

Round-robin (default) + keepalive

If you write no method, Nginx walks the server list in order, then repeats. weight still applies: a server with weight=3 is chosen three times as often as weight=1.

COPY FILE — save as nginx/conf.d/default.conf (single API, or equal replicas).
upstream backend {
    server api:8080 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

weight — real config (unequal replicas)

weight is not a separate load-balancing method. It is a per-server multiplier on whatever method you chose. Default is weight=1.

share of requests ≈ server weight ÷ sum of all weights
Example: weights 3 + 1 = 4 → 75% / 25%
COPY FILE — upstream with weights (app1 has ~3× CPU/RAM of app2).
upstream backend {
    # Method: round-robin (default). Weights change how often each server is picked.
    server app1:8080 weight=3 max_fails=3 fail_timeout=30s;
    server app2:8080 weight=1 max_fails=3 fail_timeout=30s;
    keepalive 32;
}
400 requests: expect roughly 300 → app1, 100 → app2. This is statistical, not a hard quota per second. Use weights when hardware differs — not as a substitute for autoscaling.

Other useful per-server flags:

COPY FILE — weights + backup replica (lab / canary).
upstream backend {
    server app1:8080 weight=3 max_fails=3 fail_timeout=30s max_conns=200;
    server app2:8080 weight=1 max_fails=3 fail_timeout=30s max_conns=80;
    server app-spare:8080 backup;   # traffic only if app1 and app2 are failed
    keepalive 32;
}

least_conn

Nginx sends the next request to the backend with the fewest active connections. Combined with weight, it prefers the least-loaded server after weighting. This is usually the right default for APIs where some requests take 50ms and others take 2s.

COPY FILE — least_conn upstream (recommended for mixed-latency APIs).
upstream backend {
    least_conn;
    server app1:8080 weight=3 max_fails=3 fail_timeout=30s;
    server app2:8080 weight=1 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

ip_hash (session stickiness by client IP)

The same client IP is mapped to the same backend (until that server is down). Use only if the application stores session in process memory and you cannot use Redis/DB sessions yet.

IPv6 / NAT: ip_hash uses the first three octets of IPv4 (historically) and is weak behind CGNAT or a reverse proxy that presents one source IP. Prefer a cookie hash (below) or shared session store.
COPY FILE — ip_hash (legacy sticky sessions).
upstream backend {
    ip_hash;
    server app1:8080;
    server app2:8080;
    # keepalive still works; keep proxy_http_version 1.1 in the location
}

hash (cookie or URI)

hash lets you choose the key. consistent reduces reshuffling when you add/remove a server (ketama-style).

COPY FILE — sticky on session cookie, or shard by URI.
upstream backend_cookie {
    hash $cookie_PHPSESSID consistent;
    server app1:8080;
    server app2:8080;
}

upstream backend_uri {
    hash $request_uri consistent;
    server cache1:8080;
    server cache2:8080;
}

If the cookie is missing, Nginx hashes an empty string — all those requests land on the same server. That is acceptable for anonymous traffic; it is a problem if 90% of users have no cookie.

random

random two least_conn picks two backends at random, then chooses the one with fewer connections (power of two choices). Useful with many replicas.

COPY FILE — random two-choice (many identical replicas).
upstream backend {
    random two least_conn;
    server app1:8080;
    server app2:8080;
    server app3:8080;
    keepalive 64;
}

keepalive (inside upstream block)

What it does: Keeps idle TCP connections open to the backend for reuse. Without it, every request opens a new TCP connection (slow — 1–3ms overhead each).

Default if omitted: 0 (disabled — new connection per request)

How to calculate:

upstream keepalive = concurrent proxied requests to this backend
200 users, 50% of requests go to API, ~3 concurrent API requests per user at peak...
Rough estimate: 200 × 0.5 × 0.3 ≈ 30 concurrent → set keepalive 32

Requires in http {} block:
proxy_http_version 1.1;
proxy_set_header Connection "";

max_fails and fail_timeout

What they do: If a backend fails max_fails times within fail_timeout seconds, Nginx marks it unavailable for fail_timeout seconds.

Default if omitted: max_fails=1, fail_timeout=10s

Example: max_fails=3 fail_timeout=30s → 3 failures in 30s → backend removed from rotation for 30s.

weight (load balancing)

What it does: Distributes traffic proportionally across servers.

Default if omitted: weight=1 for all servers (equal distribution)

How to calculate:

server traffic % = server weight ÷ sum of all weights
server app1 weight=3, server app2 weight=1
Total weight = 4
app1 gets 3/4 = 75% of traffic
app2 gets 1/4 = 25% of traffic
Use when app1 has 3× the CPU/RAM of app2

Part 9 — SSL settings

What this part is for: cover: SSL settings.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

ssl_session_cache

What it does: Caches SSL/TLS session data so returning visitors skip the full TLS handshake (saves 1 RTT ≈ 50–200ms).

Default if omitted: none in server block (no shared cache between workers)

How to calculate zone size:

1 MB shared cache ≈ 4,000 SSL sessions
10m cache ≈ 40,000 sessions
200 concurrent users, sessions last 1 day → need ~200 active sessions cached
1m is enough. We use 10m for growth.

ssl_session_timeout

What it does: How long a cached SSL session stays valid before requiring a full handshake again.

Default if omitted: 5m (5 minutes)

Recommendation: 1d (1 day) for better performance. Trade-off: slightly less secure if session keys are compromised (rare).

COPY FILE — save as nginx/snippets/ssl-params.conf.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
COPY FILE — save as nginx/snippets/security-headers.conf (include from server block).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
RUN — generate self-signed certs for lab (replace with Let's Encrypt in production).
mkdir -p nginx/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout nginx/ssl/privkey.pem -out nginx/ssl/fullchain.pem \
  -subj "/CN=example.com"

On success, OpenSSL may print key-generation progress but no stable result fields; exit code 0 and no stderr error mean both PEM files were created. A permission or missing-directory error means Nginx will later fail to load the certificate.

Site config — conf.d/default.conf

What this part is for: get a ready template for: Site config — conf.d/default.conf.
What you should do: COPY files; edit for your host; RUN apply/restart as labeled.
COPY FILE — create the config file named in the section (edit values first).
upstream backend {
    server api:8080 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;
    location /health { return 200 "ok\n"; add_header Content-Type text/plain; }
    location / { return 301 https://$host$request_uri; }
}

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

    root /usr/share/nginx/html;
    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    include /etc/nginx/snippets/ssl-params.conf;
    include /etc/nginx/snippets/security-headers.conf;

    limit_req zone=general burst=20 nodelay;
    limit_conn conn_limit 20;

    location = /health {
        return 200 "ok\n";
        add_header Content-Type text/plain;
    }

    location /nginx_status {
        stub_status on;
        allow 127.0.0.1;
        deny all;
    }

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

    location /api/ {
        proxy_pass http://backend;
        proxy_read_timeout 55s;
        proxy_buffer_size  16k;
        proxy_buffers      32 16k;
    }

    location ~ /\. { deny all; }
}

Worked example — full calculation summary for our server

What this part is for: understand: Worked example — full calculation summary for our server.
What you should do: read this section unless a labeled block says otherwise.
Input: 4 vCPU · 8 GB RAM · 200 users · 6 conn/user · 50 MB uploads · 400 KB max response · 45s slowest backend

worker_processes = 4 (CPU cores)
worker_connections = (200 × 6 × 1.2) ÷ 4 = 360 → set 2048
Max capacity = 4 × 2048 = 8,192 connections
worker_rlimit_nofile = 2048 × 2 = 4096 → set 65535
client_max_body_size = 50 × 1.1 = 55m
proxy_buffers = 400 KB × 1.2 = 480 KB → 32 × 16k = 512 KB
proxy_read_timeout = 45 + 10 = 55s
proxy_busy_buffers_size = 16k × 2 = 32k → set 48k
upstream keepalive ≈ 30 concurrent API requests → set 32
limit_req rate = 20r/s per IP, burst=20
limit_conn = 6 conn × 1.5 ≈ 20
RAM for proxy buffers at peak = 30 concurrent × 512 KB = ~15 MB (fine on 8 GB server)

Master defaults table — quick lookup

What this part is for: cover: Master defaults table — quick lookup.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — reference table (not a terminal command).
Directive                  │ Default (if omitted)  │ Our value   │ Why we changed
───────────────────────────┼───────────────────────┼─────────────┼──────────────────────
worker_processes           │ 1                     │ 4           │ Match CPU cores
worker_connections         │ 512                   │ 2048        │ 200 users × 6 conn
worker_rlimit_nofile       │ OS (~1024 in Docker)  │ 65535       │ Avoid "too many open files"
client_max_body_size       │ 1m                    │ 55m         │ 50 MB uploads
client_body_buffer_size    │ 16k                   │ 128k        │ Keep medium bodies in RAM
client_header_buffer_size  │ 1k                    │ 2k          │ Large auth cookies
large_client_header_buffers│ 4 8k (=32k total)     │ 4 16k       │ More headroom
keepalive_timeout          │ 75s                   │ 65s         │ Free connections sooner
keepalive_requests         │ 1000                  │ 500         │ Slightly more aggressive recycle
proxy_connect_timeout      │ 60s                   │ 10s         │ Fail fast on dead backend
proxy_send_timeout         │ 60s                   │ 60s         │ Default OK for 50 MB uploads
proxy_read_timeout         │ 60s                   │ 55s         │ 45s backend + 10s margin
proxy_buffer_size          │ 4k–8k                 │ 16k         │ Safe for large headers
proxy_buffers              │ 8 4k–8k (=32–64k)     │ 32 16k      │ 400 KB responses
proxy_busy_buffers_size    │ 8k–16k                │ 48k         │ 16k × 3
gzip                       │ off                   │ on          │ Compress JSON/CSS/JS
gzip_comp_level            │ 1                     │ 5           │ Better compression
gzip_min_length            │ 20 bytes              │ 256 bytes   │ Skip tiny responses
gzip_types                 │ text/html only        │ explicit    │ Include JSON, CSS, JS
upstream keepalive         │ 0 (disabled)          │ 32          │ Reuse backend connections
server_tokens              │ on (shows version)    │ off         │ Security
limit_req                  │ none                  │ 20r/s       │ Anti-abuse

How to measure your own numbers

What this part is for: cover: How to measure your own numbers.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Don't guess — measure, then plug into the formulas above.

RUN — paste into a shell on the host this section describes.
# CPU cores
nproc

# Open file limit inside container
ulimit -n

# Largest response body from your backend
curl -o /dev/null -s -w '%{size_download} bytes\n' https://example.com/api/biggest-endpoint

# Backend response time (p99 = slowest 1%)
# Use your monitoring tool, or:
curl -o /dev/null -s -w 'time_total: %{time_total}s\n' https://example.com/api/slow-endpoint

# Concurrent connections right now
docker compose exec nginx cat /proc/net/sockstat | grep TCP

# Nginx active connections
curl http://localhost/nginx_status   # requires stub_status module

Sample output — capacity and endpoint measurements (excerpt)

4
65535
409684 bytes
time_total: 1.842317s
TCP: inuse 38 orphan 0 tw 91 alloc 142 mem 12
Active connections: 37
server accepts handled requests
 8421 8421 19340
Reading: 0 Writing: 3 Waiting: 34

Meaning: nproc=4 sizes workers; ulimit=65535 is the FD ceiling; curl reports a 409,684-byte body and 1.84-second total time; sockstat shows TCP sockets in use/time-wait/allocated; stub_status shows 37 active connections and cumulative accepts/handled/requests, then current reading/writing/keepalive-waiting states. A 1024 FD limit constrains concurrency, curl status/timeouts invalidate sizing data, and 404/403 on /nginx_status means the location or allowlist is wrong.

Start, test, reload

What this part is for: cover: Start, test, reload.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
RUN — paste into a shell on the host this section describes.
docker compose up -d
docker compose exec nginx nginx -t          # always test first
docker compose exec nginx nginx -s reload   # apply without downtime

Sample output — nginx -t (success)

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Meaning: the config parsed. Safe to reload. If you see nginx: [emerg] ... instead, do not reload — the running process still has the old config; fix the file first. A common fail is a missing ; or an upstream name that does not match proxy_pass.

Sample output — docker compose up and nginx -s reload

[+] Running 3/3
 ✔ Container api    Started
 ✔ Container nginx  Healthy
2026/08/30 15:22:10 [notice] 31#31: signal process started

Meaning: Compose started the services, the healthcheck passed, and the reload signal reached the Nginx master. unhealthy means inspect container logs/health output; open() ... failed or host not found in upstream means mounts or Docker DNS are wrong. A successful signal does not replace nginx -t; always require the syntax-success block above first.

Troubleshooting — error → cause → fix

What this part is for: cover: Troubleshooting — error → cause → fix.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.
READ — reference table (not a terminal command).
Error                              │ Likely cause                    │ Fix
───────────────────────────────────┼─────────────────────────────────┼────────────────────────────
413 Request Entity Too Large       │ client_max_body_size too small  │ Increase (default is only 1m)
504 Gateway Timeout                │ proxy_read_timeout too small    │ Increase (default 60s)
502 Bad Gateway                    │ Backend down or wrong address   │ Check proxy_pass + Docker network
upstream sent too big header       │ proxy_buffer_size too small     │ Set 16k or 32k (default 4–8k)
too many open files (24)           │ worker_rlimit_nofile too low    │ Set 65535 + Docker ulimits
400 Request Header Too Large       │ Header > 32k default           │ Increase large_client_header_buffers
503 Service Temporarily Unavailable│ Rate limit burst exceeded       │ Increase burst or rate
Changes not applied                │ Forgot reload                   │ nginx -t then nginx -s reload

Wrapping up

What this part is for: cover: Wrapping up.
What you should do: read the explanation, then do only blocks labeled COPY / RUN / SQL.

Every Nginx setting has a default, a purpose, and a formula. The defaults are conservative and often wrong for production — especially client_max_body_size (1m), worker_processes (1), gzip (off), and upstream keepalive (disabled).

Start with the worked example numbers, run your server, measure real traffic and response sizes, then recalculate. The method doesn't change — only the inputs do.

Have a specific error or directive you want calculated for your server specs? Drop your numbers in the comments.