Caddy Dual TLS + Cloudflare Tunnel Setup
This architecture provides End-to-End Dual TLS Encryption:
- Edge TLS (Cloudflare Edge Certificate): Cloudflare terminates HTTPS for public visitors on the internet.
- Origin TLS (Let's Encrypt / ACME Certificate): Caddy automatically requests and renews valid SSL/TLS certificates using the Cloudflare DNS-01 ACME challenge plugin.
- Tunnel Transport: Cloudflare Tunnel (
cloudflared) connects to Caddy overHTTPS://caddy:443with strict origin TLS verification.
That describes the infra tunnel, for this host's own services. Project preview instances use a second, preview tunnel that bypasses Caddy:
public, infra: Client → Cloudflare → cloudflared → Caddy :443 → service
public, preview: Client → Cloudflare → cloudflared-preview → (edge) instance container
LAN: Client → split DNS → Caddy :443 → (edge) instance container
📁 File Structure
Dockerfile: Builds Caddy withgithub.com/caddy-dns/cloudflareusingxcaddy.docker-compose.yml: Runscaddy, the infra tunnelcloudflared, and the preview tunnelcloudflared-preview. Caddy andcloudflared-previewjoin the externaledgenetwork shared with project instances;cloudflared-previewjoins nothing else.Caddyfile: Global options (ACME email, client-IP trust), thecloudflare_tlssnippet, standalone sites, andimport sites/*.caddy.sites/: One file per project, mounted read-only at/etc/caddy/sites.gig-platform.caddy: gig-platform instances —mainatgig-api.kucial.com(public name) andgig-platform.dev.kucial.com(LAN alias),e2e/pr-<N>by hostname label, and main's permission gates onstudio./objects./horizon..gig-app.caddy: gig-app web instances —mainatgig-web.kucial.comandgig-app.dev.kucial.com,pr-<N>by label.
.env: StoresCLOUDFLARE_API_TOKEN,CLOUDFLARE_TUNNEL_TOKENandCLOUDFLARE_PREVIEW_TUNNEL_TOKEN.data/: Persistent volume storing Caddy's certificates.
sites/gig-platform.caddy and sites/gig-app.caddy are copies of gig-platform's docs/deploy/caddy/; change them there and copy them over, so the two stay identical.
🧩 Project instances (edge network)
Projects deployed as instances (gig-platform, gig-app) publish no host ports. Each instance's entry container joins the external edge network under an instance-qualified alias — gig-platform-<instance>-nginx, gig-app-<instance>-web. Two things reach those aliases: Caddy, for LAN clients, through a site file:
*.gig-platform.dev.kucial.com {
reverse_proxy gig-platform-{http.request.host.labels.4}-nginx:80
}
- The apex needs its own site: labels count from the right, so
labels.4is empty there. - A hostname whose instance does not exist returns 502.
- Placeholder upstreams share health state across instances, so do not add health checks to those sites.
…and cloudflared-preview, for public clients, through the preview tunnel's public hostnames (below). It only ever reaches the named hostnames listed there, so an instance is public exactly when one of its hostnames is on that list.
Create the network once, before the first docker compose up:
docker network create edge
Split DNS on the router must resolve both the apex and the wildcard of each project domain to this host, e.g. dnsmasq address=/gig-platform.dev.kucial.com/<mac-mini-ip>.
🚀 Setup & Cloudflare Dashboard Configuration
1. In .env
CLOUDFLARE_API_TOKEN: Token withZone:DNS:Editpermission (allows Caddy to complete the DNS-01 challenge).CLOUDFLARE_TUNNEL_TOKEN: Token of the infra tunnel.CLOUDFLARE_PREVIEW_TUNNEL_TOKEN: Token of the preview tunnel. Create it as a separate tunnel in Zero Trust.
2. In Cloudflare Zero Trust Dashboard (Public Hostnames)
Infra tunnel — the host's own services, through Caddy:
| Public Hostname | Service Type | URL | Origin Server Name (under Additional Settings -> TLS) |
|---|---|---|---|
synotrip.kucial.com |
HTTPS |
caddy:443 |
synotrip.kucial.com |
blog.kucial.com |
HTTPS |
caddy:443 |
blog.kucial.com |
Note
Setting Origin Server Name tells
cloudflaredwhich SNI hostname to send to Caddy during the TLS handshake, allowing Caddy to serve the matching Let's Encrypt certificate.
Preview tunnel — project instances, straight to their edge aliases. Plain HTTP: the hop is inside the Docker network, and Cloudflare terminates TLS.
| Public Hostname | Service Type | URL |
|---|---|---|
gig-api.kucial.com |
HTTP |
gig-platform-main-nginx:80 |
gig-studio.kucial.com |
HTTP |
gig-platform-main-db-studio-gate:80 |
gig-objectstore.kucial.com |
HTTP |
gig-platform-main-objectstore-console-gate:80 |
gig-horizon.kucial.com |
HTTP |
gig-platform-main-horizon-gate:80 |
gig-web.kucial.com |
HTTP |
gig-app-main-web:80 |
One label each, and not by preference: the free plan's Universal SSL covers kucial.com and *.kucial.com only, so a published something.dev.kucial.com fails TLS at the edge however the tunnel is configured — that level needs Advanced Certificate Manager. The dev.kucial.com names stay LAN-only, where Caddy issues its own DNS-01 certificates and depth does not matter.
- Route the internal surfaces only to their gates, never to
db-studio,objectstore:9001or anything else. The gates enforce the platform permission on every request. - Never publish
*.gig-platform.dev.kucial.comor*.gig-app.dev.kucial.com: PR and e2e instances are LAN-only, reached through split DNS. - Enable WebSockets for the zone; gig-platform serves them at
/app/on the instance hostname. - A public hostname must not exist on both tunnels, or traffic goes to whichever connector wins.
3. Start Services
docker compose up -d --build
4. Zero-Downtime Reload
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
5. Reloading
A new or edited sites/*.caddy file only needs a reload. Changing the sites mount or the networks in docker-compose.yml needs docker compose up -d caddy. The preview tunnel's hostnames change in the dashboard only; cloudflared-preview picks them up without a restart.