--- name: expose-private-service description: Expose a private-network-only homelab service over HTTPS through an existing Traefik reverse proxy and Cloudflare DNS, using a trusted wildcard cert. Use when adding a new self-hosted service (Docker Compose or otherwise) that should be reachable only over a private network (Tailscale, WireGuard, plain LAN, a cloud VPC's private subnet, etc.), not the public internet. metadata: version: 1.1.1 --- # Expose a private-network-only service via Traefik + Cloudflare DNS This pattern works for a host that runs services bound directly to a private IP (never `0.0.0.0`), fronted by a single host-level Traefik instance that terminates HTTPS with a wildcard Let's Encrypt certificate for a domain like `*.home.example.com` (obtained via Cloudflare DNS-01 challenge — no port 80/443 validation needed, so the cert doesn't require public reachability). "Private IP" is intentionally generic: any address not routable from the public internet works — a Tailscale/WireGuard overlay address, a plain LAN IP, a cloud VPC's private subnet address, etc. The reference implementation below (`scripts/expose.sh`) is network-agnostic; it just takes the IP as a `BACKEND_IP` env var. Tailscale is used in examples purely because it's a common way to get a stable private IP for a homelab host — swap in whatever your network uses. Each service still needs its own explicit Cloudflare DNS `A` record (there is no wildcard DNS record, only a wildcard cert) and its own Traefik dynamic config file. Both records are public in the sense that anyone can query the DNS name or read the Traefik config — but the target IP is only reachable over your private network. So the service is DNS-visible but network-reachable only to hosts on that network. ## Files - `scripts/expose.sh` — idempotent helper that creates the Cloudflare DNS record and writes the Traefik dynamic config file. Invoked in Step 2 below. ## When to use this A new service is running (in Docker, bound to the host's private IP on some port, e.g. `${BACKEND_IP}:PORT:CONTAINER_PORT` in its compose file) and needs a friendly HTTPS hostname reachable from any device on that private network, matching the pattern of every other service on this host. ## Steps 1. **Bind the service to the private IP, not `0.0.0.0`.** If it's a Docker Compose service, the port mapping should look like: ```yaml ports: - "${BACKEND_IP}::" ``` Check `ss -tlnp` first to confirm `` isn't already taken by another local service on this host. 2. **Run the helper script** with a Cloudflare API token scoped to DNS edit on your zone, the backend's private IP, plus the three required domain env vars: ```bash ROOT_DOMAIN=example.com BASE_DOMAIN=home.example.com CERT_MAIN_DOMAIN=home.example.com \ CLOUDFLARE_API_TOKEN= BACKEND_IP= ./scripts/expose.sh ``` `BACKEND_IP` can come from wherever your private network gets its address — e.g. `BACKEND_IP="$(tailscale ip -4)"` on a Tailscale-connected host, a static LAN IP, or a cloud instance's VPC private IP. This is idempotent — safe to re-run. It will: - Create `.` as a Cloudflare `A` record pointing at `BACKEND_IP`, `proxied=false` (skips if the record already exists — it does not update an existing record, to avoid clobbering something set up differently on purpose). - Write `/etc/traefik/dynamic/.yml` routing that hostname to `http://:`, reusing the existing wildcard cert. This step needs `sudo` (interactively — the script does not attempt to cache or supply a password). Never commit the Cloudflare token anywhere. Pass it as an env var for a single invocation only. 3. **Verify:** ```bash dig @1.1.1.1 . +short # should return BACKEND_IP curl -s -o /dev/null -w '%{http_code}\n' https://./ ``` Traefik's `file` provider watches `/etc/traefik/dynamic` and picks up new router files automatically — no restart needed. ## Notes / gotchas - If the service needs HTTPS all the way to the backend (rare — most backends are plain HTTP internally), set `BACKEND_SCHEME=https` before running the script. - `ROOT_DOMAIN`, `BASE_DOMAIN`, `CERT_MAIN_DOMAIN`, `CLOUDFLARE_API_TOKEN`, and `BACKEND_IP` are all required — the script fails fast with a clear message if any is unset. There is no default or auto-detection for `BACKEND_IP`; you must resolve it yourself for whatever private network you're using (e.g. `tailscale ip -4` for Tailscale) and pass it in. - This only handles exposure (DNS + reverse proxy). It does not create the Docker Compose file, generate secrets, or start the service.