Caddy with Cloudflare DNS-01
One HTTPS front door with real certificates, for private service names that only lead somewhere from inside the tailnet.
Names and addresses are placeholders. The list says what each one stands for.
What it does here
Every web service binds to 127.0.0.1 and Caddy fronts them all, one subdomain each, with certificates from Let's Encrypt. The names point at the box's tailnet address, so they lead nowhere for anyone off the tailnet.
Canonical setup
The box isn't reachable from the internet, so the usual HTTP challenge can't work. Caddy proves you own the domain with a DNS-01 challenge instead, writing a TXT record through your DNS provider's API. The stock image doesn't include DNS provider modules, so build one that does.
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddyUpstream: Automatic HTTPS.
In this build
- A wildcard record for *.dev pointing at 100.x.y.zIn Cloudflare, DNS only (grey cloud). The name answers; the address is only reachable from the tailnet.
- A token scoped to one zoneThe Edit zone DNS template, limited to
tailnet.example. Never the global API key. - Host networkingThe simplest way for Caddy to reach each service on 127.0.0.1.
- The token in a locked .env
chmod 600, read by compose. - One block per serviceSame shape each time: a hostname, the DNS challenge, a port.
Type: A Name: *.dev Content: 100.x.y.z Proxy status: DNS onlyservices:
caddy:
build: .
container_name: caddy
restart: unless-stopped
network_mode: host
environment:
- CF_API_TOKEN=${CF_API_TOKEN}
volumes:
- /srv/stacks/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- /srv/data/caddy/data:/data
- /srv/data/caddy/config:/config{
email you@tailnet.example
}
git.dev.tailnet.example {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy 127.0.0.1:3000
}
home.dev.tailnet.example {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy 127.0.0.1:3002
}echo 'CF_API_TOKEN=your-token-here' > /srv/stacks/caddy/.env
chmod 600 /srv/stacks/caddy/.env
cd /srv/stacks/caddy && docker compose up -d --build
docker compose logs -f # watch each certificate get issuedAfter editing the Caddyfile, docker compose restart caddy. git over SSH on port 2222 doesn't go through Caddy; it reaches the box directly over the tailnet.
Without a domain, Tailscale Serve is the simpler route: sudo tailscale serve --bg --https=443 http://127.0.0.1:3002 gives a valid certificate on the MagicDNS name, one port per service. It doesn't need Tailscale SSH, so it doesn't affect mosh.
Seams
Where this meets the rest of the build. Each one leads with what you will actually see.
Symptomthe names stop working once the record is proxied
Cloudflare's proxy (orange cloud) can't reach a private 100.x address. Set the record to DNS only.
SymptomUnexpected next token after '{' on same line
An opening brace has to be the last thing on its line, so tls { dns cloudflare ... } on one line fails. Spread the block over three lines as above. The {env.CF_API_TOKEN} placeholder is fine inline.
Verify
From a laptop on the tailnet, https://home.dev.tailnet.example loads with a valid certificate. From a device off the tailnet, the name resolves but nothing answers.
docker compose logs caddy | grep -i "certificate obtained"- 17 Oct 2026First written.