# Caddy Dual TLS + Cloudflare Tunnel Setup This architecture provides **End-to-End Dual TLS Encryption**: 1. **Edge TLS (Cloudflare Edge Certificate):** Cloudflare terminates HTTPS for public visitors on the internet. 2. **Origin TLS (Let's Encrypt / ACME Certificate):** Caddy automatically requests and renews valid SSL/TLS certificates using the Cloudflare DNS-01 ACME challenge plugin. 3. **Tunnel Transport:** Cloudflare Tunnel (`cloudflared`) connects to Caddy over **`HTTPS://caddy:443`** with 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: ```text 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 with `github.com/caddy-dns/cloudflare` using `xcaddy`. - `docker-compose.yml`: Runs `caddy`, the infra tunnel `cloudflared`, and the preview tunnel `cloudflared-preview`. Caddy and `cloudflared-preview` join the external `edge` network shared with project instances; `cloudflared-preview` joins nothing else. - `Caddyfile`: Global options (ACME email, client-IP trust), the `cloudflare_tls` snippet, standalone sites, and `import sites/*.caddy`. - `sites/`: One file per project, mounted read-only at `/etc/caddy/sites`. - `gig-platform.caddy`: gig-platform instances — `main` at `gig-api.kucial.com` (public name) and `gig-platform.dev.kucial.com` (LAN alias), `e2e` / `pr-` by hostname label, and main's permission gates on `studio.` / `objects.` / `horizon.`. - `gig-app.caddy`: gig-app web instances — `main` at `gig-web.kucial.com` and `gig-app.dev.kucial.com`, `pr-` by label. - `.env`: Stores `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_TUNNEL_TOKEN` and `CLOUDFLARE_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--nginx`, `gig-app--web`. Two things reach those aliases: Caddy, for LAN clients, through a site file: ```caddy *.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.4` is 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`: ```bash 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/`. --- ## 🚀 Setup & Cloudflare Dashboard Configuration ### 1. In [`.env`](file:///Users/kongkx/services/caddy/.env) - `CLOUDFLARE_API_TOKEN`: Token with `Zone:DNS:Edit` permission (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 `cloudflared` which 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:9001` or anything else. The gates enforce the platform permission on every request. - Never publish `*.gig-platform.dev.kucial.com` or `*.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 ```bash docker compose up -d --build ``` ### 4. Zero-Downtime Reload ```bash 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.