Caddy to the Rescue and why I Stopped Fighting Nginx and Never Looked Back
The Problem I Was Actually Trying to Solve We were setting up a new internal dev environment for a microservices project. Multiple services, multiple ports, all running locally in Docker Compose, and the team needed a reverse proxy in front of them so we weren't juggling URLs like localhost:3001 , localhost:3002 , and so on. We also wanted HTTPS even locally, because we'd been burned before by behavior that only showed up in production where TLS was actually terminating. You know the story. My instinct was Nginx. I've used it forever. I started writing the config file and, look, it's fine. Nginx works. But I was 40 lines in and hadn't actually handled anything yet. All the usual ceremony: events block, http block, upstream block, gzip settings, headers, ssl_certificate pointing at a self-signed cert I'd have to generate manually. Not fun. Daniel Golcher leaned over and said "why aren't you using Caddy for this?" I didn't have a good answer. What Caddy Actually Is Caddy is a web server written in Go, open-source, actively maintained, and genuinely designed with a different philosophy than Nginx or Apache. The big thing, the thing that actually matters day-to-day, is automatic HTTPS. Not "here's how to configure Let's Encrypt if you follow these 15 steps." Automatic. As in, you point Caddy at a real domain and it handles certificate provisioning, renewal, and OCSP stapling without you doing anything else. For local dev, it integrates with its own CA (via the caddy trust command) to issue locally-trusted certs. No more clicking through browser warnings. No more maintaining a separate mkcert workflow. And the config format is genuinely readable. Here's a simple reverse proxy Caddyfile that took me about two minutes to write: api.localhost { reverse_proxy localhost:8080 } app.localhost { reverse_proxy localhost:3000 } admin.localhost { reverse_proxy localhost:4000 basicauth { admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWoDs3iYK } } That's it. That config gives you TLS on all three subdomains, a reverse proxy to three different services, and basic auth on the admin route. With Nginx, the equivalent would be at minimum 80-90 lines, and you'd still need to handle the cert files yourself. Setting It Up Locally Installation is straightforward. On macOS I use Homebrew: brew install caddy On Linux (Ubuntu/Debian): sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl 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 For local HTTPS with .localhost domains, run this once to install Caddy's local CA into your system trust store: caddy trust Then start it: caddy run --config Caddyfile That's genuinely all you need. The first time I ran this and opened https://api.localhost in Chrome and got a green lock with no extra steps, I sat there for a moment just kind of surprised. I've been conditioned to expect friction. The Docker Compose Setup In our actual project, we run Caddy as a container alongside the services. The compose file looks roughly like this: services: caddy: image: caddy:2.8-alpine restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config api: build: ./api expose: - "8080" app: build: ./app expose: - "3000" volumes: caddy_data: caddy_config: The Caddyfile inside the container references the service names directly: api.localhost { reverse_proxy api:8080 } app.localhost { reverse_proxy app:3000 } Docker's internal DNS handles the rest. And the caddy_data volume matters because it persists the certificate data across container restarts. Forget that volume and Caddy re-provisions certs on every restart, which is fine locally but would hit Let's Encrypt rate limits in production faster than you'd expect. Production Use I want to be honest here: I've used Caddy in production on smaller projects but not on anything doing serious volume. For a side project I run (a small SaaS tool for managing API keys, nothing huge) Caddy has been sitting on a single DigitalOcean droplet for about eight months with zero intervention. The certs renew automatically. I haven't touched the config since I deployed it. The Caddyfile for that setup is almost embarrassingly simple: keymaster.example.com { reverse_proxy localhost:8090 log { output file /var/log/caddy/access.log { roll_size 10mb roll_keep 5 } } header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" } } Security headers, logging with rotation, TLS. All of it. I haven't had to think about it since. For high-traffic production systems, I'd still evaluate carefully. Nginx has a proven track record at massive scale, and if you need very fine-grained control over connection handling, or if your team already has years of Nginx knowledge, that institutional stuff matters. But for the 90% of use cases most of us actually have, Caddy is more than enough. Locking It Down: Security You Should Actually Configure This is the section I wish someone had handed me when I first deployed Caddy to a public server. Getting TLS for free is great. Leaving everything else at defaults is how you end up in a post-mortem. Rate Limiting The most immediate thing you can do. Without rate limiting, any endpoint you expose is open to brute-force attempts, credential stuffing, and scanner bots that will find your server within hours of it going live (and yes, that timeline is accurate, it's genuinely that fast). Caddy doesn't have built-in rate limiting in the standard distribution, but the caddy-ext/ratelimit module by RussellLuo handles it well. You'll need to build a custom binary with xcaddy : xcaddy build --with github.com/RussellLuo/caddy-ext/ratelimit Then in your Caddyfile: api.example.com { rate_limit { zone dynamic { key {remote_host} events 100 window 1m } } reverse_proxy localhost:8080 } That config limits each IP to 100 requests per minute. Adjust the numbers based on what your API actually needs, but 100/min is a reasonable starting point for most endpoints that aren't serving high-frequency traffic. Blocking Bad Actors at the Request Level Caddy's request_header and respond directives let you drop traffic that looks like scanner noise before it ever reaches your upstream. I add this to most production configs now: example.com { @blocked { path /wp-admin* /wp-login* /.env /phpMyAdmin* /admin.php not path /api/* } respond @blocked 404 reverse_proxy localhost:8080 } Okay, I'm simplifying a bit here. This won't stop a determined attacker, but it does cut out a huge volume of automated scanner traffic that's just looking for low-hanging fruit. Check your access logs after a week and you'll see what I mean. IP Allowlisting for Sensitive Routes If you have admin routes or internal APIs that only specific IPs should ever reach, don't rely on authentication alone. Add an IP restriction in front of it: admin.example.com { @allowed_ips { remote_ip 203.0.113.10 198.51.100.0/24 } @not_allowed { not remote_ip 203.0.113.10 198.51.100.0/24 } respond @not_allowed 403 reverse_proxy @allowed_ips localhost:4000 } We did exactly this for an internal metrics dashboard after a security review flagged it as accessible from the public internet. Two lines of config fixed it. Should have done it from the start. Enforcing TLS Ve...