Use Caddy with Cloudflare
In the previous part,
I set up TLS certificate using tailscale cert and a Caddy reverse proxy. This
setup works well enough on its own, but soon I ran into problems trying to serve
various services under subpaths such as /backrest since many services are not
built to be hosted under a subpath, but rather a subdomain like
backrest.thinkcentre.taileeXXXX.ts.net. Among these services not supporting
subpath is Immich, which I consider to be an essential service, hence the need
to re-visit the problem of TLS certificates.
Unfortunately, Tailscale does support generating certificates for other domains
than the fully-qualified domain of the Tailscale node itself (e.g. I cannot
use Tailscale to generate certificates for *.thinkcentre.taileeXXXX.ts.net).
After some initial research, I concluded that my best course of action is to
use Cloudflare, with whom I already purchased a domain name. The idea is to
configure DNS records on Cloudflare so that my domain name resolves to the
private IP address within my Tailscale network, then configure Caddy on my
homelab to obtain TLS credentials from Cloudflare.
Configure DNS record on Cloudflare
I want a collection of subdomain names to resolve to the private domain name or
private IP address on my Tailscale network. Go to Cloudflare’s dashboard, go to
“Domains”, click the desired domain name, click “DNS Records”, then click
“Add record”. I will add a CNAME record that says *.thinkcentre.mydomain.com
is an alias for thinkcentre.taileeXXXX.ts.net. After saving the record, I can
use dig foo.thinkcentre.mydomain.com to verify:
dig foo.thinkcentre.mydomain.com
My output is:
; <<>> DiG 9.10.6 <<>> foo.thinkcentre.mydomain.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 55842
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 1, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1220
;; QUESTION SECTION:
;foo.thinkcentre.mydomain.com. IN A
;; ANSWER SECTION:
foo.thinkcentre.mydomain.com. 300 IN CNAME thinkcentre.taileeXXXX.ts.net.
;; AUTHORITY SECTION:
[REDACTED]
;; Query time: 41 msec
;; SERVER: 100.100.100.100#53(100.100.100.100)
;; WHEN: Sun Jun 07 00:26:39 EDT 2026
;; MSG SIZE rcvd: 169
The answer section indicates the correct result.
Since we are on Cloudflare, we can also create an API key that we will later use
with Caddy so Caddy can automatically obtain TLS certificates from Cloudflare
using DNS challenge. I will store the API key in an .env file that is sourced
by docker compose using the --env-file flag, then the API key will be passed
to the Caddy container as an environment variable CADDY_CLOUDFLARE_TOKEN.
# The project-level .env file for docker compose
CADDY_CLOUDFLARE_TOKEN="..."
# Pass the environment variable to Caddy
services:
caddy:
environment:
- CADDY_CLOUDFLARE_TOKEN=${CADDY_CLOUDFLARE_TOKEN}
# Start docker compose with specified environment file
docker compose --env-file ~/Shared/SelfHosting/.env up
The Caddyfile can reference environment variable with
{env.CADDY_CLOUDFLARE_TOKEN}. For validation, I defined a directive that serves
the token under some subpath. After restarting Caddy, the API token should be
visible from that subpath.
thinkcentre.tailee7580.ts.net/cf {
respond {env.CADDY_CLOUDFLARE_TOKEN}
import tailscalecert
}
Build Caddy with the Cloudflare Module
To use ACME DNS challenge with Cloudflare, we will use the following snippet.
Note that resolvers 1.1.1.1 is necessary in my Tailscale networking setup.
(cloudflare) {
tls {
dns cloudflare {env.CADDY_CLOUDFLARE_TOKEN}
resolvers 1.1.1.1
}
}
For each reverse proxy section, import the cloudflare section. However, the vanilla Caddy image does not include the plugin for interfacing with Cloudflare, and the Caddy container will exit upon reading an unsupported module:
Error: adapting config using caddyfile: parsing caddyfile tokens for ‘tls’: getting module named ‘dns.providers.cloudflare’: module not registered: dns.providers.cloudflare, at /etc/caddy/Caddyfile:7 import chain [‘/etc/caddy/Caddyfile:13 (import cloudflare)’]
We need to extend the base Docker image to include the appropriate plugin. This
can be done by using a custom Dockerfile that builds the caddy binary with the
desired plugin:
FROM caddy:2.11.3-builder-alpine AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:2.11.3-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
We can validate that this Dockerfile builds correctly with:
docker build -t caddy:dev -f caddy.Dockerfile .
On my home server this takes roughly one minute to complete. Finally, We will
modify the docker-compose.yaml file so that Docker Compose will use the image
built from my Dockerfile instead of a pre-built image from DockerHub.
services:
caddy:
build:
context: .
dockerfile: caddy.Dockerfile
After docker compose --env-file <path> up, the Caddy container no longer
crashes. We can validate the existing routes that uses Tailscale certificates.
Now we can add our first route that uses the Cloudflare DNS challenge for certificates:
status.thinkcentre.crustaceanlab.com {
respond "Ok"
import cloudflare
}
Restart the containers and observe that Caddy will take a few seconds to solve the DNS challenge:
{"msg":"trying to solve challenge",,"challenge_type":"dns-01","ca":"REDACT"}
{"msg":"authorization finalized","identifier":"REDACTED","authz_status":"valid"}
{"msg":"validations succeeded; finalizing order","order":"REDACTED"}
{"msg":"got renewal info","names":["REDACTED"]}
{"msg":"got renewal info","names":["REDACTED"]}
{"msg":"got renewal info","names":["REDACTED"]}
{"msg":"successfully downloaded available certificate chains"}
{"msg":"certificate obtained successfully","issuer":"REDACTED"}
{"msg":"releasing lock","identifier":"REDACTED"}
Now navigate to https://status.thinkcentre.crustaceanlab.com and inspect the
certificate, which should have “subject name” set to
status.thinkcentre.crustaceanlab.com. Finally, we can clean up the Caddyfile
and servce all services under subdomains instead of subpaths. I will also add
a directive *.thinkcentre.crustaceanlab.com so that Caddy only needs to solve
the DNS challenge once to obtain a wildcard certificate that can be served for
all subdomains.
(tailscalecert) {
tls /ssl/thinkcentre.cert /ssl/thinkcentre.key
}
(cloudflare) {
tls {
dns cloudflare {env.CADDY_CLOUDFLARE_TOKEN}
resolvers 1.1.1.1
}
}
thinkcentre.tailee7580.ts.net {
handle_path /health {
respond "Ok"
}
import tailscalecert
}
*.thinkcentre.crustaceanlab.com {
import cloudflare
}
status.thinkcentre.crustaceanlab.com {
respond "Ok"
import cloudflare
}
audiobooks.thinkcentre.crustaceanlab.com {
reverse_proxy audiobookshelf:80
import cloudflare
}