{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"caddy-the-web-server-that-makes-https-boring-zye4w","url":"https://zyvop.com/caddy-the-web-server-that-makes-https-boring-zye4w","title":"Caddy: The Web Server That Makes HTTPS Boring","subtitle":"A practical field guide to the Go-powered server that turns certificates, proxies and config sprawl into a few readable lines.","tldr":"Caddy simplifies HTTPS and reverse proxying by handling certificate issuance and renewals automatically. Explore practical Caddyfile examples, load balancing, Docker deployments, security settings, and the trade-offs to consider before using Caddy in production.","keywords":["Caddy","DevOps","HTTPS","Web Servers","Reverse Proxy","Tutorial"],"entities":["Ankit Singh","Software Engineer","Caddy","DevOps","HTTPS","Web Servers","Reverse Proxy","Tutorial","ZyVOP"],"keyTakeaways":["Most of us have performed the ritual.","Add a cron job, or a systemd timer.","Write a deploy hook so nginx reloads after renewal."],"headings":["Why this matters more every year","What Caddy actually is","Automatic HTTPS, demystified","Local development gets real HTTPS too","On-demand TLS: certificates at request time","The Caddyfile: config you can read","A static site","A single-page app with an API behind it","Reusing config with snippets","Load balancing with health checks","Wildcard certificates with the DNS challenge","Quick one-liners","Zero-downtime config and the admin API","Extending Caddy with modules","Running it in Docker","Gotchas and honest trade-offs","A production checklist","When to choose Caddy","The bottom line"],"outboundLinks":[],"contentText":"Most of us have performed the ritual. Spin up a server. Install nginx. Install certbot. Add a cron job, or a systemd timer. Write a deploy hook so nginx reloads after renewal. Then, six months later at 2 a.m., discover the hook has been silently failing and your certificate expired an hour ago. Here is the Caddy version of that ritual: example.com { reverse_proxy localhost:3000 }That's the whole thing. You get a real, trusted TLS certificate, an automatic HTTP-to-HTTPS redirect, HTTP/2 and HTTP/3, background renewals, and OCSP stapling. No cron job, no hook, no second tool. This post is a tour of how Caddy pulls that off, the patterns that matter in real deployments, and the places where it will bite you if you aren't ready for it. Why this matters more every year TLS used to be something you did once and forgot about for a year or two. That era is ending. The industry has agreed to phase maximum public certificate lifetimes down over the next few years, toward just 47 days by 2029. When certificates live for weeks instead of years, manual renewal stops being an annoyance and becomes an outage waiting to happen. Caddy was built around the opposite assumption: that certificate management is the server's job. It was the first widely used web server to make HTTPS automatic and on by default, and everything else about its design follows from the same philosophy: sensible defaults, secure by default, minimal ceremony. What Caddy actually is Caddy is an open-source (Apache 2.0) web server and reverse proxy written in Go, created by Matt Holt and first released in 2015. A few properties define it: A single static binary. No runtime, no dependency tree, no package-manager archaeology. Copy it to a machine and run it. Memory-safe by construction. Being written in Go removes a whole class of vulnerabilities that C-based servers have to defend against. Modular to the core. Since the Caddy 2 rewrite in 2020, nearly everything (HTTP handlers, TLS issuers, log encoders, storage backends) is a module you can swap or extend. JSON underneath, Caddyfile on top. The native config is JSON, driven through a live API. The Caddyfile is a human-friendly adapter that compiles down to that JSON. That last point is the key to understanding Caddy. You get a config format a tired human can read at midnight, backed by a machine-friendly API you can automate when you outgrow hand-editing. Automatic HTTPS, demystified \"Automatic HTTPS\" sounds like marketing until you see what actually happens when Caddy starts with a hostname in its config: It notices a public domain name in your site address. It redirects HTTP to HTTPS on port 80 for that site. It obtains a certificate using the ACME protocol, from Let's Encrypt or ZeroSSL, and falls back to the other if one fails. It renews in the background, well before expiry, with no restart and no dropped connections. It staples OCSP responses and keeps them fresh. It stores everything in a data directory (or a shared storage backend, if you run a cluster). The requirements are modest: your domain's DNS must point at the server, and ports 80 and 443 must be reachable so the CA can validate you. If they can't be (a private network, a wildcard certificate), you use the DNS challenge instead, covered below. Local development gets real HTTPS too For localhost, *.localhost and IP addresses, Caddy can't get a public certificate, so it uses a built-in local certificate authority instead. Run caddy trust once to install its root certificate in your system trust store, and your dev environment serves genuine HTTPS with no browser warnings: myapp.localhost { reverse_proxy localhost:3000 }Parity between development and production is a quiet superpower. Cookie flags, mixed-content bugs, service workers and secure-context APIs all behave the way they will in production. On-demand TLS: certificates at request time If you run a SaaS where customers point their own domains at you, Caddy can issue a certificate the first time it sees a handshake for a new hostname: { on_demand_tls { ask http://localhost:5555/check } } https:// { tls { on_demand } reverse_proxy localhost:3000 }The ask endpoint is not optional in practice. It's how you tell Caddy \"yes, this domain belongs to one of our customers.\" Without it, anyone can point random domains at your server and burn through your CA rate limits. The Caddyfile: config you can read The Caddyfile's design principle is that common things should be short and obvious. Here are the recipes you'll reach for most. A static site example.com { root * /var/www/example encode zstd gzip file_server }root sets the directory, encode turns on compression, file_server serves files. Each line does one thing, and the names say what it is. A single-page app with an API behind it example.com { encode zstd gzip handle /api/* { reverse_proxy localhost:8080 } handle { root * /var/www/app try_files {path} /index.html file_server } }handle blocks are mutually exclusive, like an if/else chain: the first match wins. API traffic goes to your backend, and everything else falls through to the SPA with a client-side routing fallback. If your backend expects paths without the /api prefix, swap handle for handle_path, which strips the matched prefix before proxying. Reusing config with snippets As you add sites, repetition creeps in. Snippets fix that: (security) { header { Strict-Transport-Security max-age=31536000 X-Content-Type-Options nosniff Referrer-Policy strict-origin-when-cross-origin -Server } } app.example.com { import security reverse_proxy app:3000 } docs.example.com { import security root * /var/www/docs file_server }Note that Caddy does not add HSTS for you. That's a deliberate choice, since HSTS is hard to undo, so you opt in once you're sure. Load balancing with health checks api.example.com { reverse_proxy 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080 { lb_policy least_conn health_uri /healthz health_interval 10s health_timeout 2s } }List more than one upstream and Caddy load balances. You choose the policy (round robin, least connections, IP hash, and others), and active health checks pull unhealthy backends out of rotation until they recover. Caddy also does passive health tracking and retries on its own. Wildcard certificates with the DNS challenge Wildcards require proving control of the DNS zone, which means a DNS provider plugin: *.example.com { tls { dns cloudflare {env.CF_API_TOKEN} } @app host app.example.com handle @app { reverse_proxy localhost:3000 } @admin host admin.example.com handle @admin { reverse_proxy localhost:4000 } handle { abort } }Three things are worth noticing. The @name lines are named matchers, Caddy's way of saying \"only apply this to requests that look like that.\" {env.CF_API_TOKEN} pulls a secret from the environment instead of hard-coding it. And the final handle { abort } drops connections for any hostname you didn't explicitly list, which is a good default for a wildcard. Quick one-liners Sometimes you don't want a config file at all: # Serve the current directory with a file browser caddy file-server --browse # Put HTTPS in front of something on port 3000 caddy reverse-proxy --from example.com --to localhost:3000These are wonderfully handy for demos, quick internal tools and \"I just need this reachable right now\" moments. Zero-downtime config and the admin API Caddy listens on localhost:2019 for its admin API. You can read the live config, change it, and apply it without dropping a single connection: # Validate before you ship caddy validate --config /etc/caddy/Caddyfile # Apply a new config gracefully caddy reload --config /etc/caddy/Caddyfile # See the JSON your Caddyfile compiles to caddy adapt --config /etc/caddy/Caddyfile --pretty # Inspect the live config curl localhost:2019/config/ # Auto-format your Caddyfile caddy fmt --overwrite /etc/caddy/CaddyfileIf the new config is invalid, the reload is rejected and the old one keeps serving. That safety net is worth a lot on a busy server. The admin endpoint is bound to localhost by default, so leave it that way unless you have a good reason and proper access control. Extending Caddy with modules The standard download covers most needs, but things like DNS-provider integrations, extra caches, rate limiters and custom auth live in third-party modules. Because Caddy is a single static binary, you add them by building a custom one with xcaddy: xcaddy build --with github.com/caddy-dns/cloudflareFor containers, the official pattern is a multi-stage build: FROM caddy:builder AS builder RUN xcaddy build --with github.com/caddy-dns/cloudflare FROM caddy:latest COPY --from=builder /usr/bin/caddy /usr/bin/caddyIn practice, pin your versions in a real pipeline instead of using floating tags, so a rebuild next month doesn't surprise you. Running it in Docker services: caddy: image: caddy:2 restart: unless-stopped ports: - \"80:80\" - \"443:443\" - \"443:443/udp\" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config volumes: caddy_data: caddy_config:Two details here save real pain. The 443/udp mapping is what lets HTTP/3 work. And /data must be a persistent volume: it holds your certificates and ACME account. If it vanishes on every redeploy, Caddy re-requests certificates each time and you'll hit CA rate limits at the worst possible moment. Gotchas and honest trade-offs Caddy isn't magic, and a post that only sells you on it isn't worth much. Here's what to watch for. Directive order isn't file order. In a Caddyfile, directives are sorted into a fixed internal order, not executed top to bottom. That's usually what you want, but it can surprise you when you combine things like rewrite, redir and reverse_proxy. When you need explicit control, use handle blocks (mutually exclusive) or route (runs in the order written). Third-party modules mean a custom build. The mainstream distribution is lean on purpose. Rate limiting, advanced caching and some DNS providers require building your own binary. That's easy with xcaddy, but it's a step nginx users don't always have to take. The ecosystem is smaller. nginx has two decades of tutorials, blog posts and Stack Overflow answers for every odd corner case. Caddy's documentation is excellent and its community forum is responsive, but for exotic setups you'll sometimes be reading module docs rather than copy-pasting a snippet. Network prerequisites still apply. Automatic HTTPS can't work around a firewall that blocks ports 80 and 443, DNS that points somewhere else, or a CA rate limit. When it fails, the logs are clear about why, so read them before assuming it's a bug. Extreme tuning. For the overwhelming majority of sites, your proxy is not the bottleneck. If you operate at a scale where you're hand-tuning kernel parameters and worker models, you'll want to benchmark your own workload rather than trust anyone's blog post, including this one. A production checklist Point DNS first, then start Caddy, so the first certificate request succeeds. Persist the data directory (/data in Docker, or ~/.local/share/caddy by default). Run caddy validate in CI before every deploy. Use caddy reload, not a restart, for config changes. Add encode zstd gzip to anything serving text. Opt in to security headers (including HSTS) once you're certain every subdomain supports HTTPS. Keep the admin API on localhost. Send logs to stdout or a file and ship them somewhere searchable. Caddy emits structured JSON logs out of the box. Run it under systemd or a container orchestrator, with a non-root user and permission to bind low ports. When to choose Caddy Choose Caddy when you want HTTPS handled for you, when you're running small to medium services, side projects, internal tools or container fleets, when you value configs that read like intent, or when you're managing many domains and want automation without a pile of glue scripts. Think twice if you depend on a specific nginx module with no Caddy equivalent, if your team already has deep nginx expertise and tooling that works well, or if you need very particular behavior that only a mature, heavily documented ecosystem covers. For everyone else, the question isn't \"why Caddy?\" so much as \"why am I still maintaining certbot?\" The bottom line Good infrastructure disappears. You stop thinking about it because it just works: certificates renew, configs reload, redirects redirect. Caddy's real contribution isn't any one feature. It's that it moved the default from \"HTTPS is a project\" to \"HTTPS is already done.\" Try it on something small this week. Replace one proxy, or put it in front of a side project with the three-line config at the top of this post. If the first thing you notice is that there's nothing left to configure, that's the point.","contentHash":"sha256:b0cadbe514e559b71a82cdada1f0bc744abcb69edc381aefd377c17e152e4dab","authorName":"Ankit Singh","authorUrl":"https://zyvop.com/author/ankit","authorSameAs":[],"category":"Tutorial","tags":["Caddy","DevOps","HTTPS","Web Servers","Reverse Proxy"],"audience":"Software engineers and developers building applications with Tutorial","tone":"Professional, software engineer perspective","readingTimeMinutes":9,"wordCount":2040,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-10-16T15:08:00.006Z","updatedAt":"2026-10-16T13:08:54.514Z","canonicalUrl":"https://zyvop.com/caddy-the-web-server-that-makes-https-boring-zye4w"}