Run urgentry behind nginx, Caddy, and Traefik.
urgentry listens on HTTP out of the box. To reach it from the public internet with TLS, to share a host with other services, or to get proper IP attribution for rate limiting, you put a reverse proxy in front. This guide covers all three dominant proxies in 2026 with production-grade configs that handle TLS, payload sizing, upstream timeouts, and forwarding headers. Pick the one that fits your stack.
20 seconds. For a solo operator on a single VPS, Caddy is the right answer: two lines of config, automatic Let’s Encrypt certificates, HTTP/2 by default, and no manual certificate renewal to think about. nginx is the right answer if it is already in your toolbox. Traefik is the right answer if you already run a Docker Compose environment and want label-based routing.
60 seconds. The non-obvious urgentry-specific requirements for any proxy: set the maximum request body to at least 20 MB (Sentry envelopes that include source maps or attachments can be multi-megabyte), forward X-Forwarded-For so urgentry sees the real client IP, and do not decompress gzip-encoded request bodies before forwarding them. The SDK sends compressed payloads and urgentry reads them that way.
urgentry has no persistent WebSocket connections and no long-poll endpoints in the ingest path. Standard upstream timeouts of 60–90 seconds cover every request type the SDK sends. The one exception is if you expose the live issues feed in the UI, which uses server-sent events; that connection can stay open for minutes and needs a higher proxy_read_timeout in nginx or an equivalent setting in your proxy of choice.
Why a reverse proxy at all
urgentry serves plain HTTP on port 8000 (configurable). That is intentional: TLS termination in a single binary means bundling certificate management logic, renewal scheduling, ACME client code, and key storage into the binary itself. Moving that concern to a dedicated proxy that already does it well is the cleaner split.
Beyond TLS, a proxy gives you three things that matter for a public-facing error tracker:
- Multi-service routing. If the same host runs urgentry alongside another service, the proxy routes traffic to each one by hostname or path. Without a proxy, each service needs its own public port.
- Rate limiting at the edge. urgentry’s rate limiting uses the client IP. Without a proxy, the client IP is whatever connects to port 8000 directly. With a proxy, the real IP arrives in
X-Forwarded-Forand urgentry can enforce per-project ingest limits correctly. - Request body enforcement. Proxies can enforce a maximum request body size before the body reaches urgentry. Sentry clients that malfunction and send multi-hundred-megabyte payloads get rejected at the proxy, not after the binary has buffered them.
None of those are theoretical concerns. SDK clients do send malformed envelopes. Hosted services on a shared IP do need routing. Rate limiting without real IP data is security theater. The proxy is not optional for production.
nginx
nginx is in every Linux package repository, familiar to every ops team, and has a decade of production use under any conceivable traffic pattern. It is heavier to configure than Caddy — the TLS block alone is eight or ten directives — but every directive does exactly one documented thing. For teams that already maintain nginx configs, adding urgentry is a block copy.
The config below is a complete server block for a single urgentry domain. Drop it into /etc/nginx/sites-available/urgentry and symlink to sites-enabled.
# /etc/nginx/sites-available/urgentry
# Replace errors.example.com with your actual domain.
# Assumes certificates are already provisioned by Certbot.
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name errors.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server block
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name errors.example.com;
# --- TLS ---
ssl_certificate /etc/letsencrypt/live/errors.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/errors.example.com/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
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;
# HSTS (6 months; add preload once you are sure of the setup)
add_header Strict-Transport-Security "max-age=15768000" always;
# --- Request body limit ---
# Sentry envelopes with source maps or attachments can be multi-MB.
# 20m is the practical floor; raise to 50m if you ingest minidumps.
client_max_body_size 20m;
# --- Upstream timeouts ---
# Ingest endpoints respond quickly. The higher values cover server-sent
# event connections from the urgentry UI (issues live feed).
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 120s;
# --- Main proxy location ---
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
# Required for HTTP keep-alive and WebSocket upgrade (if needed)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Pass the real client IP so urgentry rate limits correctly
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# Do not buffer large response bodies (event list, source maps)
proxy_buffering off;
# Pass gzip-encoded request bodies through unchanged.
# The Sentry SDK sends compressed envelopes. Do NOT gunzip here.
gunzip off;
}
# --- Server-sent events: live issues feed ---
# This location keeps connections open; bump read timeout accordingly.
location /api/0/organizations/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 600s;
proxy_buffering off;
proxy_cache off;
}
}
Directive-by-directive notes
http2 on enables HTTP/2 on that listener. The Sentry SDK uses HTTP/1.1 for ingest, but the urgentry web UI benefits from HTTP/2 for asset loading. This directive requires nginx 1.25.1 or later; on older releases use listen 443 ssl http2 instead.
client_max_body_size 20m is the most important urgentry-specific directive. The nginx default is 1 MB. A minified JS bundle attached to a source map upload exceeds that easily. Set this to 20 MB at minimum. If your application sends native crash minidumps (which can be 50 MB or more), raise it further.
gunzip off on the ingest location tells nginx not to decompress incoming request bodies before forwarding them. The Sentry SDK sends Content-Encoding: gzip on envelope requests. urgentry reads and decompresses those itself. If nginx decompresses first and strips the header, urgentry receives an unexpected uncompressed body and returns 400.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for appends the client IP to any existing X-Forwarded-For chain. This is the correct form when you have a single proxy tier. If you have multiple proxy layers (CDN in front of nginx in front of urgentry), use $http_x_forwarded_for carefully to avoid header spoofing, or configure real_ip_header.
proxy_buffering off on the SSE location is important. nginx buffers upstream responses by default, which breaks server-sent events because the browser never receives incremental chunks. Turning buffering off on this location lets the stream through.
Certificate provisioning with Certbot
apt install certbot python3-certbot-nginx
certbot --nginx -d errors.example.com
# Certbot rewrites the ssl_certificate directives automatically.
# Renewal runs via a systemd timer; verify with:
systemctl status certbot.timer
Caddy
Caddy is genuinely simpler for solo operators. The full config for urgentry behind Caddy is four lines. Automatic HTTPS is not a feature you opt into; it is the default. Let’s Encrypt certificate provisioning, renewal, and OCSP stapling all happen without any configuration beyond a domain name. HTTP/2 and HTTP/3 are on by default. The operational surface is a fraction of what nginx requires.
The tradeoff is documentation density. When something goes wrong, nginx has fifteen years of Stack Overflow answers and a directive-by-directive manual that is exhaustive. Caddy’s documentation is good and improving, but the community breadth is thinner. For solo operators who want things to work without ongoing configuration maintenance, that tradeoff clearly favors Caddy.
# /etc/caddy/Caddyfile
# Replace errors.example.com with your actual domain.
# Caddy provisions and renews the certificate automatically.
errors.example.com {
# Forward all traffic to urgentry
reverse_proxy localhost:8000 {
# Pass real client IP
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Upstream timeouts
transport http {
dial_timeout 10s
response_header_timeout 120s
}
}
# Enforce maximum request body size (matches nginx recommendation)
request_body {
max_size 20MB
}
# Encode response bodies (not request bodies -- Caddy does not
# decompress incoming gzip-encoded bodies by default, which is correct)
encode zstd gzip
# Security headers
header {
Strict-Transport-Security "max-age=15768000"
X-Content-Type-Options nosniff
X-Frame-Options DENY
-Server
}
}
Directive-by-directive notes
reverse_proxy localhost:8000 is the core directive. Caddy sets X-Forwarded-For, X-Forwarded-Proto, and X-Real-IP by default on proxied requests. The explicit header_up directives above make those values explicit rather than relying on defaults, which is good practice when the config is the documentation.
request_body max_size 20MB enforces the upstream body limit. Caddy does not have a global default for this in the way nginx has client_max_body_size; without this directive, Caddy passes request bodies of any size to the upstream. Set it.
encode zstd gzip enables response compression. This compresses what urgentry sends back to the browser. It does not touch incoming request bodies, which means the SDK’s compressed envelopes pass through to urgentry correctly. Caddy does not have a gunzip-on-ingest feature to disable; this is the correct default behavior.
response_header_timeout 120s covers the server-sent event case. Caddy’s transport http block gives per-upstream transport settings. The response_header_timeout is the time to wait for the upstream to begin sending response headers; once the SSE connection is established, that clock resets. For the live issues feed, you may want to raise this further or add a keep_alive block to the transport.
Running Caddy as a systemd service
apt install caddy
# Or install from caddy.community/download for the latest build
systemctl enable --now caddy
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
Caddy stores ACME state (certificates, account keys) in /var/lib/caddy/.local/share/caddy by default. Include that path in any backup scheme.
Traefik
Traefik is the natural choice when you already run urgentry under Docker Compose and want routing without a separate proxy config file. Traefik reads Docker labels from your Compose services and builds its routing table dynamically. Add a label, restart the container, the route appears. Remove the label, the route disappears. There is no nginx config to edit, no Caddy file to reload manually.
The tradeoff: Traefik configuration spans two surfaces. The static configuration (entrypoints, certificate resolvers, Docker provider) lives in a file or environment variable block. The dynamic configuration (routing rules, middlewares, service definitions) lives in container labels. Debugging requires reading both together, which is less immediate than reading a single Caddyfile.
The following docker-compose.yml runs urgentry and Traefik together. It assumes a Docker network named proxy already exists (docker network create proxy).
# docker-compose.yml
# Traefik + urgentry on a single host with automatic Let's Encrypt TLS.
# Run: docker network create proxy (once, before first up)
services:
traefik:
image: traefik:v3.3
restart: unless-stopped
command:
- "--api.insecure=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=proxy"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Redirect HTTP to HTTPS globally
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt via ACME TLS challenge
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "--certificatesresolvers.le.acme.email=you@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "letsencrypt:/letsencrypt"
networks:
- proxy
urgentry:
image: urgentry/urgentry:latest
restart: unless-stopped
environment:
URGENTRY_BASE_URL: "https://errors.example.com"
volumes:
- urgentry-data:/data
networks:
- proxy
labels:
# Enable Traefik for this container
- "traefik.enable=true"
- "traefik.docker.network=proxy"
# Router: match by hostname, use HTTPS entrypoint
- "traefik.http.routers.urgentry.rule=Host(`errors.example.com`)"
- "traefik.http.routers.urgentry.entrypoints=websecure"
- "traefik.http.routers.urgentry.tls=true"
- "traefik.http.routers.urgentry.tls.certresolver=le"
# Service: upstream port
- "traefik.http.services.urgentry.loadbalancer.server.port=8000"
# Middleware: request body limit (20 MB)
- "traefik.http.middlewares.urgentry-body.buffering.maxRequestBodyBytes=20971520"
- "traefik.http.middlewares.urgentry-body.buffering.memRequestBodyBytes=5242880"
- "traefik.http.routers.urgentry.middlewares=urgentry-body"
# Pass real client IP (Traefik sets X-Forwarded-For by default;
# this label configures trusted IP depth for multi-proxy setups)
- "traefik.http.middlewares.urgentry-ip.ipallowlist.sourcerange=0.0.0.0/0"
# Upstream timeout (covers SSE connections in the UI)
- "traefik.http.services.urgentry.loadbalancer.responseForwarding.flushInterval=100ms"
volumes:
urgentry-data:
letsencrypt:
networks:
proxy:
external: true
Directive-by-directive notes
providers.docker.exposedbydefault=false means Traefik ignores containers that do not have traefik.enable=true. This is the correct default for production; without it, every container on the Docker network gets a Traefik route.
buffering.maxRequestBodyBytes=20971520 sets the 20 MB body limit. The value is in bytes: 20 * 1024 * 1024. memRequestBodyBytes sets how much of that Traefik holds in memory before spilling to disk; 5 MB is a reasonable split for most event workloads.
responseForwarding.flushInterval=100ms controls how often Traefik flushes buffered upstream response data to the client. For server-sent events, this needs to be low or set to -1 (flush immediately). The 100ms value works for most UI responsiveness needs without generating excessive flushes on static responses.
Traefik sets X-Forwarded-For automatically from the connecting client IP. If you have a CDN or another proxy in front of Traefik, configure the entrypoints.websecure.forwardedHeaders.trustedIPs static option to prevent IP spoofing via a crafted X-Forwarded-For header.
Verifying the Traefik setup
docker compose up -d
# Check Traefik sees urgentry:
docker compose logs traefik | grep urgentry
# Or enable the dashboard temporarily for debugging:
# Add --api.insecure=true and publish port 8080
The shared gotchas
Three problems show up across all three proxies and are worth calling out explicitly.
Payload size limits
The Sentry envelope format allows multiple items in a single HTTP request: error event, breadcrumbs, user context, attachments, source maps, session data. A source map for a large production JavaScript bundle can run 5–15 MB by itself. An envelope with a source map attachment can be 20 MB. A native crash minidump can exceed 50 MB.
Every proxy covered in this guide has a default body limit that is below what you need. nginx defaults to 1 MB. Caddy has no default limit but you should set one. Traefik’s buffering middleware must be configured explicitly. Set 20 MB as your floor. If you ingest native crashes, raise to 100 MB and monitor for abuse.
The failure mode when the limit is too low is subtle: nginx returns 413, but the SDK’s retry logic may not surface this as an obvious error in your application logs. You will notice it as missing events, particularly missing source map uploads, not as visible errors.
Gzip on request bodies
The Sentry SDK sends event envelopes compressed with gzip by default. The Content-Encoding: gzip header tells the server that the body is compressed. urgentry decompresses it before parsing.
The problem arises if your proxy has a module or plugin that decompresses incoming request bodies before forwarding them. nginx has gunzip on; if you enable that directive on the ingest location, nginx decompresses the body, strips the Content-Encoding header, and urgentry receives an uncompressed body it expects to be compressed. The result is a 400 on every SDK envelope.
The fix: leave gunzip off on ingest locations. Response compression (gzip for browser-bound responses) is fine and encouraged. Request body decompression is the one to avoid.
X-Forwarded-For and rate limiting
urgentry’s per-project ingest rate limiting uses the client IP. Without a proxy, the client IP is the connecting socket’s remote address. With a proxy, the real client IP is in X-Forwarded-For. For rate limiting to work correctly, urgentry needs to read from that header, and the proxy needs to set it accurately.
All three configs above set X-Forwarded-For. The subtlety is trust. If your urgentry instance is accessible directly on a public port as well as through the proxy, a client can spoof the header by adding it to a direct request. The safe setup: bind urgentry to a loopback or private network interface and ensure all public traffic goes through the proxy. Then urgentry can trust the header because only the proxy can set it.
In a Docker setup, bind urgentry to the Docker internal network and do not publish its port to the host. The Traefik config above does this; urgentry is not in the ports section.
TLS: Let’s Encrypt vs automatic
All three proxies can use Let’s Encrypt certificates. The difference is how much you see of that process.
With nginx, you manage certificates explicitly. Certbot provisions them, writes the paths into your nginx config, and renews them on a timer. You see every step. If renewal fails, you get an alert from your monitoring (you are monitoring certificate expiry, right?) and you fix it manually. The upside is complete visibility; the downside is that visibility is a maintenance burden.
With Caddy, certificate management is invisible by design. Caddy runs an ACME client internally. It provisions a certificate on first startup, schedules renewal before expiry, and handles OCSP stapling. You do not see any of this unless something goes wrong, at which point Caddy logs the error. The downside is that the invisible failure mode is harder to catch without log monitoring; the upside is that most operators never have to think about certificate renewal again.
With Traefik, the ACME client is also built in, configured via the static config block (the --certificatesresolvers.le.* flags above). Certificates are stored in the volume-mounted acme.json file. Back that file up; losing it means Traefik has to re-provision, which Let’s Encrypt rate-limits to five failures per domain per week.
One practical note: Let’s Encrypt requires the domain to resolve to a public IP before it can issue a certificate. Test dig +short errors.example.com from outside your network before the first proxy startup. All three proxies will fail silently (or log an error that is easy to miss) if DNS does not resolve correctly.
When to skip a reverse proxy
There are cases where running urgentry without a proxy is the right call.
Local development is the obvious one. If urgentry is running on your laptop for testing a DSN integration, binding to localhost on port 8000 and having your SDK point at http://localhost:8000 is the fast path. No TLS, no proxy, no configuration overhead.
An internal VPN-only deployment is another. If urgentry is accessible exclusively through a VPN and all traffic is encrypted at the VPN layer, TLS termination at urgentry itself may be unnecessary. The binary does not currently handle TLS natively, so the options are: a proxy on the same host, a hardware load balancer or network appliance that does TLS, or no TLS if the VPN transport is trusted.
A managed platform that provides its own proxy layer (Railway, Fly.io, Render) may expose urgentry on HTTPS automatically. On Fly.io, for example, the platform terminates TLS at its edge and forwards to your app over HTTP. The platform sets X-Forwarded-For and handles certificate renewal. In that case, you do not run nginx, Caddy, or Traefik at all; the platform is your proxy.
For any other shape — a VPS with a public IP, a colocation host, a home server with a dynamic DNS record — run a proxy.
Frequently asked questions
Does urgentry need a reverse proxy at all?
For local use, no. For anything public-facing, yes. urgentry binds to HTTP on port 8000 by default. TLS, multi-service routing, and IP attribution for rate limiting all require a proxy.
What client_max_body_size should I set in nginx for urgentry?
At least 20 MB. Source map attachments in Sentry envelopes can reach that easily. If you ingest native crash minidumps, raise to 50 MB or higher. The nginx default of 1 MB will drop uploads silently with a 413.
Does Caddy need any special configuration for urgentry?
Almost none. Add request_body { max_size 20MB } and the forwarding headers. Everything else — TLS, HTTP/2, certificate renewal — works out of the box.
How does Traefik discover urgentry in Docker Compose?
Via container labels. Traefik watches the Docker socket and reads traefik.* labels from running containers. Add the labels to the urgentry service in your Compose file and Traefik picks them up automatically on next container start.
Will gzip break Sentry SDK event ingestion?
It can, if your proxy decompresses incoming request bodies before forwarding. The SDK sends Content-Encoding: gzip envelopes. urgentry decompresses them itself. Keep proxy-side request decompression off on ingest endpoints. Response compression is fine.
Sources and further reading
- nginx proxy module documentation — canonical reference for
proxy_pass,proxy_set_header,proxy_buffering, and related directives. - nginx
client_max_body_sizedirective — default value (1m) and syntax. - Caddy
reverse_proxydirective — full options including transport and header manipulation. - Caddy
request_bodydirective —max_sizeand related limits. - Traefik Docker provider documentation — label format, routing rules, and middleware configuration.
- Traefik buffering middleware —
maxRequestBodyBytesandmemRequestBodyBytessemantics. - Traefik ACME / Let’s Encrypt documentation — certificate resolver configuration and storage.
- Sentry envelope protocol specification — format, compression, and size expectations for ingest payloads.
Ready to run urgentry behind your proxy?
urgentry installs as a single binary on any Linux host. Pick the proxy config that fits your stack above, swap in your domain name, and you have a production-grade error tracker with TLS in under fifteen minutes.