TL;DR: Nginx Proxy Manager ships with one goal: get a reverse proxy with Let's Encrypt certs running in under ten minutes. It delivers on that.
📖 Reading time: ~20 min
What's in this article
- Why the Default NPM Config Leaves Performance on the Table
- Getting NPM Running the Right Way in Docker
- Worker and Buffer Tuning Inside the Generated nginx.conf
- Proxy Host Advanced Settings That Actually Matter
- SSL Performance: Let's Encrypt, Wildcard Certs, and HSTS Pitfalls
- Monitoring NPM and Catching Problems Before Users Do
- When NPM Is the Wrong Tool
Why the Default NPM Config Leaves Performance on the Table
Nginx Proxy Manager ships with one goal: get a reverse proxy with Let's Encrypt certs running in under ten minutes. It delivers on that. What it doesn't deliver is a config that holds up under anything resembling real traffic. The defaults reflect shared-hosting assumptions — conservative buffer sizes, short keepalive windows, worker counts that don't account for your actual CPU topology. On a machine you own, those aren't safe defaults, they're just wrong defaults.
The symptoms are recognizable once you know what to look for. Upstream timeouts on long-running API responses — say, a local Ollama inference call or an OpenAI streaming response that takes 45 seconds — because proxy_read_timeout ships at 60s and NPM's UI doesn't expose it per-host without a custom config block. 502s during n8n webhook bursts because the upstream queue fills faster than the default buffer can drain. And SSL handshake latency that eats 80–150ms on every short request because session resumption isn't configured and the TLS ticket key rotation is left at defaults. That last one is invisible in synthetic benchmarks but shows up immediately when you're proxying lots of small API calls.
The underlying issue is that NPM wraps nginx in a management layer — which is genuinely useful — but it also abstracts away the knobs that matter. The generated nginx.conf in a stock Docker deployment looks roughly like this:
worker_processes auto;
# "auto" resolves to 1 on a 1-vCPU container — fine for a VPS,
# wrong for a 16-core workstation running in Docker with --cpus not set
worker_connections 1024;
# 1024 total across all workers; saturates fast under webhook fan-out
http {
# no proxy_cache_path defined — caching is entirely disabled
# keepalive_timeout 75s — upstream keepalives not configured at all
# client_max_body_size 1m — will silently drop file uploads
# gzip off — yes, off by default
}
What this article works through: the specific /data/nginx/custom override files that NPM actually reads, per-proxy-host advanced config blocks that survive container restarts, how to set proxy_read_timeout and proxy_send_timeout high enough for LLM API responses without opening yourself up to connection exhaustion, and how to wire up upstream keepalives so n8n webhook throughput stops degrading under burst load. Everything here runs on a Docker Compose stack — NPM 2.x on top of nginx 1.25 — so the file paths and config injection points are concrete and reproducible.
Getting NPM Running the Right Way in Docker
The volume mount decision trips up more NPM deployments than any config mistake. When you bind-mount /path/on/host/data directly on an ext4 filesystem and NPM starts hammering Let's Encrypt renewals plus proxy host writes simultaneously, you can hit inode exhaustion or contention-driven write stalls — especially on VPS images provisioned with small inode counts. Named volumes let Docker manage that I/O through its own storage driver layer, which sidesteps the problem entirely. The fix is one line in your compose file, and it costs nothing.
Here's a compose file that avoids the common traps:
services:
npm:
image: jc21/nginx-proxy-manager:2.11.3 # pinned — not latest
container_name: npm
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81"
environment:
DB_SQLITE_FILE: "/data/database.sqlite"
DISABLE_IPV6: "true" # drop this only if your LAN actually routes v6
X_FRAME_OPTIONS: "sameorigin" # default is DENY, which breaks iframe embeds
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:81/api"]
interval: 30s
timeout: 10s
retries: 5
start_period: 20s
volumes:
npm_data:
npm_letsencrypt:
Pin the image. Between 2.10.x and 2.11.x, upstream NPM changed how it stores custom Nginx config snippets in SQLite, and containers that got auto-pulled to a new minor broke existing advanced proxy host configs silently — the proxy kept running from the old rendered config on disk, but any edit triggered a regeneration that dropped custom directives. You won't catch that until you touch a host entry. 2.11.3 is the last version I've validated on my stack; check the release notes before bumping.
DISABLE_IPV6: "true" is worth explaining because the NPM docs barely surface it. If your Docker host has IPv6 disabled at the kernel level (net.ipv6.conf.all.disable_ipv6 = 1 in sysctl), Nginx will still try to bind [::]:80 and [::]:443 by default — and it will fail silently on startup in a way that leaves your plain HTTP proxy hosts unreachable while HTTPS ones work. The symptom looks like a routing problem, not a bind failure. Setting this env var removes the v6 listen directives from the generated config before Nginx ever starts. X_FRAME_OPTIONS is a different category of gotcha: NPM sets DENY by default as a security header, which is correct for most traffic, but if you're embedding Grafana, Homepage, or any self-hosted dashboard in an iframe through the proxy, every embed will be blocked. Override to sameorigin or your specific upstream value rather than disabling it wholesale.
Don't rely on Docker's built-in TCP healthcheck against port 81 to decide if NPM is ready for depends_on chains. The admin UI port binds before the proxy engine finishes loading its host configs and writing Nginx conf files. The correct check is against the API endpoint:
# Run this manually to confirm the proxy engine is actually up
docker exec npm curl -sf http://localhost:81/api
# Expected output when healthy — any non-empty 200 response:
# {"status":"OK"}
# If this returns nothing or a connection refused,
# Nginx hasn't finished initializing even if port 81 is open
That -sf flag combination is important: -s silences the progress output so only real failures produce noise, and -f makes curl return a non-zero exit code on HTTP 4xx/5xx — Docker's healthcheck mechanism requires a non-zero exit to register as unhealthy. The start_period: 20s in the compose healthcheck above gives NPM time to do its initial SQLite migration on first boot without immediately triggering restart loops on a slow host.
Worker and Buffer Tuning Inside the Generated nginx.conf
NPM regenerates /etc/nginx/nginx.conf on every container restart and every time you save a proxy host — the file is not yours to own. Edit it directly and your changes vanish the next time the UI touches anything. The correct injection points are the Custom Nginx Configuration textarea inside each proxy host's Advanced tab, or files dropped into /data/nginx/custom/ on the host. Files in that directory get included automatically; NPM ships with hooks for http_top.conf and events.conf that map to exactly the blocks you need to tune.
Worker and connection limits belong in /data/nginx/custom/http_top.conf. The generated config inherits worker_processes 4 regardless of your actual core count, and the default worker_connections ceiling will cap you well before your NIC does. Drop this in:
# /data/nginx/custom/http_top.conf
# worker_processes goes outside http{} — NPM's include point handles placement
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
multi_accept on; # accept all pending connections per epoll wakeup
use epoll; # explicit on Linux; don't leave it to autodetect
}
worker_rlimit_nofile matters more than most operators realize. Each proxied connection consumes two file descriptors — one client-side, one upstream-side — so a 4096 worker_connections ceiling with the default 1024 nofile limit will silently drop connections under load before nginx logs anything useful. Set the system ulimit too: add LimitNOFILE=65535 to the Docker service unit or your compose override.
Buffer tuning is where NPM setups running Ollama or large API backends fall apart. The defaults assume small web responses. When proxying Ollama's /api/chat streaming endpoint, nginx will start spilling response chunks to disk the moment they exceed the in-memory buffer allocation — and disk spill shows up as inexplicable latency spikes, not errors. Add this to the same custom file or to a specific proxy host's Advanced block:
# Prevents disk-spill on large/streaming API responses
proxy_buffer_size 16k; # holds the response headers + first chunk
proxy_buffers 8 16k; # 8 × 16k = 128k total in-memory budget per request
proxy_busy_buffers_size 32k; # max handed to client while rest is still being read
proxy_request_buffering off; # critical for streaming — don't buffer the full request body
For n8n webhook backends that get hit repeatedly by the same automation loop, the TCP handshake cost accumulates fast. Keepalive on the upstream block removes it:
upstream n8n_backend {
server 127.0.0.1:5678;
keepalive 32; # pool of 32 idle keepalive connections to the backend
}
server {
# inside your proxy host's advanced config
keepalive_timeout 75s;
keepalive_requests 1000; # before nginx forces a connection recycle
}
Gzip belongs in http_top.conf globally rather than per-host, because you want it covering JSON API responses from every backend — not just the ones you remembered to configure. Level 4 is the practical ceiling on modern x86 hardware; beyond it, CPU cost grows faster than byte savings shrink, and you gain nothing on already-small payloads that the gzip_min_length gate handles anyway:
gzip on;
gzip_comp_level 4;
gzip_min_length 1024; # don't bother compressing tiny responses
gzip_proxied any; # compress responses to reverse-proxied clients too
gzip_vary on; # tells CDNs the response varies by Accept-Encoding
gzip_types
text/plain
text/css
application/json
application/javascript
application/x-javascript
text/xml
application/xml;
One gotcha: gzip_proxied any is off by default and the NPM UI gives no hint it exists. Without it, responses to clients that came through another proxy layer — common when NPM sits behind Cloudflare — won't be compressed even if the client sends Accept-Encoding: gzip. The gzip_vary on line is equally important if a CDN or Varnish layer sits in front; without it, a compressed response can get cached and served to a client that never declared gzip support.
Proxy Host Advanced Settings That Actually Matter
The most underused field in the NPM UI is the Custom Nginx Configuration box on each proxy host's Advanced tab. Whatever you put there gets injected directly into the location / block of the generated config — and that's where you need to be for anything beyond basic reverse proxying. The one I add to every AI inference endpoint without exception: proxy_read_timeout 300s;. The default is 60 seconds. A locally-served LLM generating a 2,000-token response with a quantized 13B model on modest hardware will routinely take longer than that, and Nginx silently closes the connection and returns a 504 to the client mid-stream. No error in your app logs, just a truncated response and a confused user. Five seconds of config work eliminates the entire class of problem.
# Custom Nginx Configuration (per proxy host, Advanced tab)
# Drop this in for any LLM or long-poll backend
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 10s; # keep short — see note on health checks below
proxy_buffering off; # required for SSE / streaming token output
NPM's WebSocket toggle (the "Websockets Support" checkbox in the UI) does not emit proxy_http_version 1.1;. That's the silent killer. Without it, Nginx defaults to HTTP/1.0 for upstream connections, which doesn't support persistent connections. You'll see intermittent WebSocket drops, failed upgrade handshakes on certain backends, or keepalive connections that close immediately. The fix is to ignore the checkbox entirely and write the full block yourself in the Advanced tab:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
Rate limiting requires two separate config locations, which is the part NPM's UI makes awkward. The zone declaration — limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m; — must go in NPM's global custom config, which lives under Settings → Nginx. That config gets included at the http {} block level. Then on each individual proxy host that should enforce the limit, the Advanced tab gets limit_req zone=api burst=10 nodelay;. The nodelay flag matters here: without it, requests arriving within the burst window get queued and delayed rather than rejected immediately, which can cause a pile-up if a runaway n8n workflow or a misbehaving API client starts hammering a local Ollama endpoint. With nodelay, burst capacity is consumed instantly and anything over it gets a clean 503.
# Settings → Nginx (global custom config, http block level)
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=ui:10m rate=120r/m;
# Per-host Advanced tab (enforcement point)
limit_req zone=api burst=10 nodelay;
limit_req_status 429; # return proper 429 instead of default 503
The missing upstream health check is a real operational gap. The open-source NPM build has no equivalent of Nginx Plus's health_check directive or HAProxy's backend polling — when a container goes down, NPM keeps routing traffic to the dead upstream until the connection attempt times out. The default proxy_connect_timeout is 60 seconds, which means every request to a failed backend hangs for a full minute before the client gets an error. Setting proxy_connect_timeout 5s; per host cuts that failure surface down immediately. Pair it with a short proxy_read_timeout appropriate to the endpoint, and bad backends surface fast enough that monitoring (even a basic Uptime Kuma instance polling the proxied URL) will catch them before users notice a pattern.
SSL Performance: Let's Encrypt, Wildcard Certs, and HSTS Pitfalls
The wildcard cert via DNS challenge is one of those setups that feels like extra work until the third time you add a new subdomain and realize you didn't touch Certbot at all. With NPM's built-in Let's Encrypt integration, you configure the DNS provider once — Cloudflare or Route53 both have first-class support — drop in an API token, and every subdomain you spin up afterward is covered automatically. More practically for home labs: the DNS-01 challenge never requires port 80 to be reachable. If your ISP quietly blocks inbound port 80 (common on residential connections), the HTTP-01 challenge fails silently and you end up debugging NPM logs instead of realizing the port is the problem. DNS-01 sidesteps that entirely. Renewal happens on a schedule against the DNS API, not against your server's open ports.
OCSP stapling is disabled in NPM's default nginx template, and that's a consistent 100–200ms tax on every cold TLS handshake. The fix goes in NPM's "Advanced" custom config block for each proxy host:
ssl_stapling on;
ssl_stapling_verify on;
# resolver needs to be reachable from the nginx process — not just from the host OS
resolver 1.1.1.1 valid=60s;
resolver_timeout 5s;
The resolver directive is the one people skip and then wonder why stapling silently fails. Nginx needs to independently resolve the OCSP responder URL from within the container, and if you don't give it a working resolver, ssl_stapling logs a warning and falls back to no stapling. You can verify stapling is active with openssl s_client -connect your.domain:443 -status — look for OCSP Response Status: successful in the output. If you see no response sent, the resolver is the first thing to check.
The HSTS max-age default in NPM is 63072000 seconds — exactly two years. That number is fine for a stable production host, but if you toggle HSTS on during initial setup and your cert or config has any issue requiring a fallback to plain HTTP, every browser that visited during that window will refuse HTTP connections for two years. There's no server-side fix; the only recovery is a browser-by-browser manual HSTS cache clear. During testing, override the header in the custom config block:
# In NPM's Advanced tab — overrides the HSTS checkbox value
add_header Strict-Transport-Security "max-age=86400" always;
Promote to max-age=63072000; includeSubDomains; preload only after the host has been stable for at least a week with no certificate errors. The preload flag is a separate commitment — submitting to the HSTS preload list is effectively permanent and can't be reversed quickly, so don't add it to internal or experimental hosts.
HTTP/2 ships enabled in NPM's SSL config, but "enabled" and "working end-to-end" aren't the same thing. Some upstream services — particularly older Java apps, Ruby Rack apps, or anything running behind a second nginx with an incompatible config — cause NPM to silently negotiate HTTP/1.1 on the upstream connection while still advertising HTTP/2 to clients. That's usually fine, but occasionally it causes header handling issues. Check the client-facing protocol directly:
curl -I --http2 https://your.domain 2>&1 | head -5
# Healthy output starts with:
# HTTP/2 200
If you see HTTP/1.1 200 instead, NPM either isn't presenting HTTP/2 in the ALPN negotiation or something upstream is forcing a downgrade. Check the proxy host's SSL certificate status first — an expired or mismatched cert causes curl to fall back before ALPN happens. If the cert is valid and you're still getting HTTP/1.1, look at whether the upstream proxy_pass target is using https:// with a self-signed cert that nginx can't verify; that can stall the connection upgrade path in ways that don't surface as obvious errors in NPM's logs.
Monitoring NPM and Catching Problems Before Users Do
The most actionable signal NPM gives you is buried in a field most operators ignore: the upstream response time in the access log. By default, NPM writes logs to /data/logs/ — one subdirectory per proxy host, with access.log and error.log side by side. Mount that directory as a Docker volume and you have everything you need to catch degrading backends before a user files a ticket.
If you're already running a Loki/Grafana stack, the setup is straightforward: point Promtail at /data/logs/*/access.log with a pipeline stage that parses the upstream response time field, then build a Grafana alert on p95 latency per host. If you're not running Loki yet, the lowest-effort option that still beats nothing is a simple shell watcher on the host:
# tail all NPM access logs and flag anything over 2s upstream response
tail -F /data/logs/*/access.log | awk '
{
# NPM default log format puts upstream_response_time in $NF area
# adjust field index to match your actual log format
if ($0 ~ /upstream_response_time/) {
match($0, /upstream_response_time: ([0-9.]+)/, arr)
if (arr[1]+0 > 2.0) print "SLOW: " $0
}
}
'
Rough, but it fires immediately and requires no additional infrastructure. When you do move to Loki, the upstream_response_time field becomes a proper metric-from-log that tells you exactly which backend is dragging.
For uptime, I run this on a PM2 cron every 60 seconds across every service that matters. No external dependency, no ping fee, just a dead-simple shell one-liner that logs HTTP status and total time:
# In ecosystem.config.js, one entry per critical service
{
name: "healthcheck-myapp",
script: "bash",
args: "-c \"echo $(date -u +%FT%T) $(curl -o /dev/null -sw '%{http_code} %{time_total}s' https://myapp.internal/health) >> /var/log/healthchecks/myapp.log\"",
cron_restart: "* * * * *",
autorestart: false,
watch: false
}
Healthy services respond in under 100ms on a local network. Anything logging above 800ms consistently is either a backend problem or an NPM configuration issue — typically a missing keepalive or an oversized buffer setting forcing full response buffering before the first byte goes out.
The 502 vs 504 distinction is where most operators waste time. A 502 Bad Gateway from NPM means the upstream TCP connection was refused — the process isn't listening. The correct response is a restart, full stop. A 504 Gateway Timeout means the upstream accepted the connection but didn't respond within the timeout window — the process is alive but stalled, usually under load or stuck on a downstream dependency like a database lock. Restarting a 504 without understanding why it stalled just delays the same failure by a few minutes. Watch for the transition pattern in your error logs: if a host starts flipping 502 → 504 → 502 in a short window, the process is crash-looping under load, which is a different problem entirely from either one in isolation. Set NPM's proxy timeout (proxy_read_timeout in the advanced config block) to something deliberate — the default 60 seconds means a stalled upstream holds an NPM worker thread for a full minute before the 504 fires.
One area that gets skipped in almost every NPM hardening guide: the proxy layer's interaction with AI tooling and local model infrastructure. Streaming inference responses, long-polling completions, and auth header passthrough all have sharp edges at the reverse proxy level — especially when your local model setup expects specific timeout and buffer behaviors. The AI Coding Tools in 2026: Cloud Copilots vs Local Models guide covers how local-model setups interact with reverse proxy constraints like auth headers and streaming timeouts, which is worth reading before you wire an Ollama endpoint or OpenAI-compatible server through NPM for the first time.
When NPM Is the Wrong Tool
The 30-proxy-host threshold is roughly where NPM's SQLite backend starts working against you rather than for you. Below that, the GUI is a genuine time-saver. Above it, you're fighting drift: a host edited in the UI doesn't show up in Git, rollbacks mean restoring a SQLite file, and your "config" is effectively a database dump. If you're already running infrastructure-as-code for everything else — Terraform, Ansible, Docker Compose in a repo — NPM becomes the odd one out that you can't peer-review or diff. Caddy with a committed Caddyfile or Traefik driven by Docker labels both solve this cleanly. A git diff on a Caddyfile shows exactly what changed and when; NPM's export JSON does not.
# Caddy equivalent of a typical NPM reverse proxy entry — version-controllable, reviewable
app.example.com {
reverse_proxy localhost:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
tls your@email.com # ACME handled inline, no separate certbot process
}
The Streams tab in NPM exposes TCP/UDP proxying, and it works for basic cases — forwarding a Minecraft port or a WireGuard UDP endpoint is straightforward. What you don't get is any tuning surface: no proxy_timeout, no proxy_buffer_size, no proxy_connect_timeout, no way to set so_keepalive on the upstream socket. For a game server where a 200ms TCP timeout difference is felt by players, or a Postgres tunnel where you want fine-grained keepalive behavior, you need a raw nginx container with a hand-written stream {} block. That's not a workaround — it's the right architecture for the use case.
# stream block nginx config for a latency-sensitive TCP tunnel
# mount this as /etc/nginx/nginx.conf in a plain nginx:alpine container
stream {
upstream db_backend {
server 10.0.0.5:5432;
}
server {
listen 5432;
proxy_pass db_backend;
proxy_timeout 10s; # fail fast — don't let stale connections pile up
proxy_connect_timeout 2s;
proxy_buffer_size 16k; # default 16k is fine for PG, tune down for game protocols
}
}
The process count is the other honest liability. NPM runs nginx, a Node.js GUI server, SQLite, and a Certbot/openssl-backed certificate renewal loop. On a 4GB homelab VM that's background noise. On a device with 512MB or 768MB RAM — an older Raspberry Pi, an edge node, a cheap VPS — that overhead is measurable in available headroom for your actual workloads. Caddy is a single statically-linked binary that handles TLS automatically; bare nginx is even leaner. For edge nodes or constrained devices, install Caddy via its official package and skip the container stack entirely:
# Caddy on a Debian/Ubuntu edge node — no Docker, no secondary processes
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
# Binary is ~45MB, zero runtime dependencies, TLS built in
The honest summary: NPM earns its keep for solo operators managing a moderate number of HTTPS reverse proxies who want a visual cert dashboard and don't need repeatability guarantees. Push past that boundary in any direction — scale, IaC discipline, non-HTTP protocols, constrained hardware — and the abstraction layer NPM adds becomes friction rather than utility. Knowing which side of that line your setup sits on saves you from bolting on workarounds that never quite fit.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)