---
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}:<host-port>:<container-port>"
   ```
   Check `ss -tlnp` first to confirm `<host-port>` 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=<token> BACKEND_IP=<private-ip> ./scripts/expose.sh <subdomain> <host-port>
   ```
   `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 `<subdomain>.<BASE_DOMAIN>` 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/<subdomain>.yml` routing that hostname to
     `http://<backend-ip>:<host-port>`, 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 <subdomain>.<BASE_DOMAIN> +short   # should return BACKEND_IP
   curl -s -o /dev/null -w '%{http_code}\n' https://<subdomain>.<BASE_DOMAIN>/
   ```
   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.
