Caddy’s auto-HTTPS for an internal homelab: setting up Caddy with DNS-01 against Cloudflare for a *.local domain

You have a few internal services on your homelab — a Home Assistant box, a Plex server, a TrueNAS UI, an Uptime Kuma dashboard. They all live on 192.168.1.x with random ports. You want to give them clean URLs (ha.lab.example.com, plex.lab.example.com) with real TLS, but they’re never reachable from the internet, so HTTP-01 ACME challenges don’t work. Self-signed certs make every browser scream. Plain HTTP works but feels wrong.

The clean answer in 2026: Caddy with the DNS-01 challenge against Cloudflare, on a real domain you own (the lab.example.com subdomain), with the actual records pointing to RFC1918 private IPs that are only reachable inside your network. Public DNS, real TLS, no exposure. Here’s the setup.

Why DNS-01, not HTTP-01

Let’s Encrypt’s HTTP-01 challenge requires the world to be able to reach http://yourdomain/.well-known/acme-challenge/.... For an internal service, that’s not possible — the IP is private. DNS-01 instead asks you to prove ownership by writing a TXT record at your DNS provider; Let’s Encrypt fetches that TXT and issues the cert. No public-IP requirement at all.

Caddy’s DNS-01 plugin for Cloudflare means: install the plugin, give Caddy an API token, and it handles the TXT-record dance automatically. Renewals, retries, all of it.

Step 1 — Cloudflare API token (scoped narrowly)

Don’t use your global API key for this. Generate a scoped token at cloudflare.com → Profile → API Tokens → Create Token → Edit zone DNS:

  • Permissions: Zone → DNS → Edit
  • Zone Resources: Include → Specific zone → example.com (only the zone you’ll use for the lab)
  • TTL: leave default

The token can only edit DNS records on the chosen zone. If it leaks, the blast radius is limited.

Step 2 — build Caddy with the Cloudflare plugin

The DNS plugins for Caddy are not in the default binary; you build a custom one with xcaddy. Or use the Docker image that bundles it.

# option A: xcaddy on the host
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build --with github.com/caddy-dns/cloudflare
sudo mv caddy /usr/local/bin/caddy

# option B: docker (simpler)
# /opt/caddy/docker-compose.yml
services:
  caddy:
    image: ghcr.io/caddybuilds/caddy-cloudflare:latest
    restart: unless-stopped
    network_mode: host          # for LAN reach
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./data:/data
      - ./config:/config
    environment:
      CLOUDFLARE_API_TOKEN: "your-scoped-token"

Step 3 — the Caddyfile

# /etc/caddy/Caddyfile
{
    # Use Let's Encrypt staging while testing — switch to prod after success
    # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
    email you@example.com
}

ha.lab.example.com {
    reverse_proxy 192.168.1.20:8123
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
}

plex.lab.example.com {
    reverse_proxy 192.168.1.30:32400
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
}

nas.lab.example.com {
    reverse_proxy https://192.168.1.40 {
        transport http {
            tls_insecure_skip_verify     # NAS UIs often have self-signed back-ends
        }
    }
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
}

Bring it up. Caddy will:

  1. Hit Cloudflare’s API to create a TXT record at _acme-challenge.ha.lab.example.com.
  2. Wait ~30 seconds for DNS propagation.
  3. Tell Let’s Encrypt to verify; LE checks the TXT record from public DNS; cert issues.
  4. Caddy writes the cert to /data/caddy/certificates/ and starts serving HTTPS.
  5. Cleans up the TXT record.

Renewal happens automatically every ~60 days, via the same dance.

Step 4 — the DNS records (the bit nobody explains)

For your devices to actually reach ha.lab.example.com and have it resolve to your local Caddy box, you have two options:

  • Option A — public DNS, private IP. Create A records on Cloudflare like ha.lab.example.com → 192.168.1.10 (your Caddy box). The whole world can resolve it, but only devices on your LAN can route to 192.168.1.10. This is the simplest setup. The “downside” is that your private IP is in public DNS; that’s not actually a security issue (knowing the IP doesn’t grant access), but some people object on principle.
  • Option B — split-horizon DNS. Run AdGuard Home / Pi-hole / dnsmasq on your network as the LAN’s DNS resolver, return private IPs for *.lab.example.com only on the LAN. Public DNS at Cloudflare doesn’t have these A records at all; only the TXT records for ACME exist publicly. More secure-feeling. Slightly more setup.

I run option B. The split-horizon setup is one extra config block in AdGuard:

# AdGuard Home → Filters → DNS rewrites:
*.lab.example.com  →  192.168.1.10

Now ha.lab.example.com resolves to 192.168.1.10 on the LAN, doesn’t resolve at all from the public internet, and Let’s Encrypt still issues the cert because it doesn’t need an A record — only a TXT.

The catches

  • DNS propagation delays. Cloudflare’s API is fast, but Let’s Encrypt sometimes checks before propagation completes. Caddy handles this with retries; if you see “challenge timed out” repeatedly, it’s usually a TXT-record TTL issue. Set the propagation timeout in the Caddyfile: tls { propagation_delay 60s }.
  • Wildcard certs are allowed but read the small print. If you use *.lab.example.com in your Caddyfile, you get one cert that works for any subdomain. Convenient. But Let’s Encrypt rate-limits wildcard issuance (5 per week per registered domain). Don’t accidentally regenerate it 10 times.
  • Cloudflare account compromise. If someone steals your scoped API token, they can edit DNS records on that zone and potentially issue new certs. Rotate it annually. Set a calendar reminder.
  • HTTP/3 / QUIC needs UDP open. If you’re running this behind a router with strict outbound rules, allow UDP 443 outbound from the Caddy box. Not strictly required — HTTP/2 over TCP works fine — but Caddy will use HTTP/3 if it can.

End-state: every internal service has a clean URL with a real TLS cert, browsers don’t complain, the certs renew themselves forever, and nothing on your LAN is exposed to the public internet. The whole setup is one Caddyfile and a 30-day-rotating cron-free renewal job. Worth the evening to set up.

Photo: Coiled Ethernet cable by Markus Spiske on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.