Fall 2026brianmars.comBuilt at EGBT Technologies
ReferenceFront door

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.

From the manual

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.

/srv/stacks/caddy/Dockerfile
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/caddy

Upstream: Automatic HTTPS.

Edited for this build

In this build

  1. 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.
  2. A token scoped to one zoneThe Edit zone DNS template, limited to tailnet.example. Never the global API key.
  3. Host networkingThe simplest way for Caddy to reach each service on 127.0.0.1.
  4. The token in a locked .envchmod 600, read by compose.
  5. One block per serviceSame shape each time: a hostname, the DNS challenge, a port.
Cloudflare DNS record
Type: A   Name: *.dev   Content: 100.x.y.z   Proxy status: DNS only
/srv/stacks/caddy/docker-compose.yml
services:
  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
/srv/stacks/caddy/Caddyfile
{
	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
}
Token and start
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 issued

After 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.

Where it breaks

Seams

Where this meets the rest of the build. Each one leads with what you will actually see.

Cloudflare's proxy meets a tailnet address

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.

The Caddyfile meets an inline brace

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.

Checks
docker compose logs caddy | grep -i "certificate obtained"
Corrections and updates
  1. 17 Oct 2026First written.