Nginx on Docker: Complete Guide with Defaults, Formulas, and Real Calculations
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:
- What it does — in plain language
- Default value — what Nginx uses if you write nothing
- What goes wrong — if you leave the default or set it wrong
- How to calculate — step-by-step with real numbers
We'll use a concrete example server throughout:
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.
Project structure and docker-compose.yml
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/
services:
nginx:
image: nginx:1.27-alpine
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
ulimits:
nofile:
soft: 65535
hard: 65535
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
web:
name: web
volumes:
nginx_logs:
Full nginx.conf (with comments showing our calculated values)
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
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:
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:
- Count expected concurrent users: 200
- Connections per user (browser opens ~6 per page): 6
- Raw client connections: 200 × 6 = 1,200
- Add 20% headroom for spikes: 1,200 × 1.2 = 1,440
- Divide by worker_processes: 1,440 ÷ 4 = 360 per worker
- Round up to a clean number: 512 or 1024 or 2048
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:
The ×2 accounts for: one FD for client side + one FD for backend side of each proxied request.
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
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:
- Find the largest file users can upload: 50 MB
- Add 10% margin: 50 × 1.1 = 55 MB
- Set:
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:
(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:
- Measure total header size of your largest request (browser DevTools → Network → request headers size)
- Typical web app: 2–8 KB. Heavy auth cookies: 10–20 KB.
- Set: count × size ≥ measured_size × 1.5
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
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:
- Static sites / APIs: 60–75s (default is OK)
- Very high connection count, limited RAM: lower to 30s (frees connections faster)
- Do NOT set above 120s without reason — idle connections eat worker_connections slots
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:
In practice: 100–1000. Lower values (100–500) help prevent memory leaks in buggy clients. We set 500.
Part 4 — Proxy buffers (reverse proxy)
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.
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:
- Measure largest response body from your backend (browser DevTools → response size, or curl -w '%{size_download}')
- Our largest API response: 400 KB
- Add 20% margin: 400 × 1.2 = 480 KB
- Pick count and size: 32 × 16k = 512 KB ≥ 480 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:
Max allowed = 512k − 16k = 496k
We set 48k (= 16k × 3, well under the limit) ✓
Part 5 — Proxy timeouts
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.
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:
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:
- Find slowest backend endpoint response time: 45 seconds (report export)
- Add 10 second margin: 45 + 10 = 55 seconds
- Set:
proxy_read_timeout 55s;
Report generation 45s → 55s
WebSocket (long-lived) → 3600s (1 hour)
Part 6 — Rate limiting
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:
zone=general:1m is enoughMedium 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:
- Measure normal peak: e.g. 100 requests/second total across all users
- Estimate users at peak: 200
- Per-user fair share: 100 ÷ 200 = 0.5 req/s per user
- Set limit above fair share to avoid blocking normal users: 0.5 × 10 = 5–20 req/s per IP
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 = 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:
Prevents one IP from opening 500 connections and exhausting worker_connections
Part 7 — Gzip compression
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):
- Level 1: ~60% compression, very fast
- Level 5: ~72% compression, moderate CPU (our choice)
- Level 9: ~75% compression, 3× more CPU than level 5 for only 3% better compression
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
upstream backend {
server api:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
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:
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:
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
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:
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).
Site config — conf.d/default.conf
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;
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 / {
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
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
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
Don't guess — measure, then plug into the formulas above.
# 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
Start, test, reload
docker compose up -d docker compose exec nginx nginx -t # always test first docker compose exec nginx nginx -s reload # apply without downtime
Troubleshooting — error → cause → fix
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
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.