If you've done any self-hosting - a homelab, internal services, a small team's own tooling - you've probably been through this ritual:
A service is running, you want to put a domain and HTTPS in front of it. So you install nginx, write a reverse-proxy config, install certbot, request a Let's Encrypt cert, and wire up a cron job to renew it. Three months later, the day the cert expires, you discover the cron path was wrong, or certbot's webroot mode is fighting your nginx config, and the site is a wall of red.
The third time I went through this, I decided to replace the whole thing. The short version: for self-hosted HTTPS, Caddy + DNS-01 gets you to "set it once, never touch it again." Here's the part that's actually worth knowing.
1. What Caddy's automatic HTTPS actually saves you
Caddy's most underrated feature is that the entire ACME flow is built in. No separate certbot, no renewal cron. A whole Caddyfile can be this short:
app.example.com {
reverse_proxy localhost:8080
}
Three lines. On startup Caddy will, on its own:
- Request a cert for
app.example.comfrom Let's Encrypt; - Store it locally and renew it automatically before expiry;
- Redirect port 80 to 443 with sane modern TLS.
Compared to nginx + certbot, what you delete is: the cert-request script, the renewal cron, the redirect rules, the TLS tuning. nginx can do all of this - it just makes you assemble every piece by hand, while Caddy ships "the correct defaults" as factory settings.
2. The real killer: DNS-01 gives certs to internal services too
The above is HTTP-01 validation: Let's Encrypt calls back to your port 80 to confirm you own the domain. The problem - what if your service is on a private network? The public internet can't reach your port 80, so HTTP-01 is simply out. This is the most common wall in self-hosting: a NAS, an internal admin panel, a homelab box - you want HTTPS but you're stuck at validation.
The fix is DNS-01 validation: instead of checking a port, it checks whether you can edit the domain's DNS records. Caddy uses your DNS provider's API (Cloudflare, Alibaba DNS, DNSPod, etc.) to write a TXT record automatically and complete validation. No inbound port ever has to be exposed to the public internet.
Better still, DNS-01 supports wildcard certs (*.example.com). Get one wildcard cert and every internal subdomain - grafana.lab.example.com, nas.lab.example.com, pad.lab.example.com - is covered by a single cert; new services don't need a fresh request:
*.lab.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@grafana host grafana.lab.example.com
handle @grafana {
reverse_proxy localhost:3000
}
@nas host nas.lab.example.com
handle @nas {
reverse_proxy localhost:5000
}
}
Note the official Caddy binary does not ship DNS plugins - you have to rebuild Caddy with the plugin for your provider (xcaddy build --with github.com/caddy-dns/cloudflare). This is where a lot of people get stuck: the official image throws "provider not found" on DNS-01, and you need to xcaddy your own build or use an image that bundles the plugin.
3. The things that will bite you
Once it's running, a few things you only learn by stepping on them:
1. Wildcard cert scope
*.example.com only covers one level of subdomain, not a.b.example.com (two levels). For two levels you need a separate *.b.example.com. Plan your subdomain hierarchy with this in mind.
2. DNS API token scope
The DNS token you hand Caddy should be scoped to edit TXT records on that one zone only - don't take the lazy path and give it an account-wide token. That token is partial control of your domain; leaked, it can be used to sign certs for you.
3. Let's Encrypt rate limits
50 certs per registered domain per week. Normal use won't hit it, but if you script container rebuilds that re-request a cert every time, you can easily get throttled until next week. Always persist the cert directory (mount a volume) so it isn't re-requested on every restart.
4. Don't forget WebSocket / gRPC
Reverse-proxying plain HTTP is fine, but if you later put a WebSocket service behind it (lots of admin panels stream live logs over WS), make sure the proxy forwards the Upgrade header correctly. Caddy's reverse_proxy does this by default, but it's a classic faceplant if you're hand-writing nginx config.
4. And then I packaged this too
I understood all of it, but rebuilding a plugin-enabled Caddy, writing a Caddyfile, configuring a DNS token, and tracking which subdomain points to which backend - for every new machine and every new service - still got old. So I folded it, along with the CI builds, multi-host deploys, and container management from before, into my single-binary Go deploy tool, Pipewright:
- A bundled self-built Caddy image (with the major DNS plugins), so DNS-01 works out of the box;
- Bind a domain, set up a wildcard cert, configure path routing - all from a few clicks in the web panel, no hand-written Caddyfile;
- A certificate overview panel: every domain's cert status and expiry at a glance, no more
openssl s_clientone at a time; - And the feature I personally wanted most: per-PR preview environments - every PR spins up its own environment on a dedicated subdomain, torn down automatically when it's merged or closed. The "every PR gets a preview link" Vercel experience, self-hosted.
It's basically the "Caddy auto-HTTPS + DNS-01 wildcard" from this post, productized to skip the hand-rolling. MIT-licensed, single binary, no runtime deps (frontend baked in with embed.FS, SQLite by default), aimed at individual developers and small teams.
Repo: https://github.com/huangchengsir/pipewright
But even if you never touch it, the core of this post stands: stop hand-rolling nginx + certbot for self-hosted HTTPS. Caddy + DNS-01 + a wildcard cert - configure once and forget it exists.
Issues and pushback welcome.
Top comments (0)