This path makes the app reachable only from your own devices on a Tailscale tailnet. The public DNS record resolves to a tailnet IP that is not publicly routable, so the deployment is invisible from the open internet. Auth still applies on top.
This is exactly the setup the original author runs. It requires more moving parts than the public Docker Compose path. Use it if you want a network-level perimeter on top of the auth allowlist.
- A Tailscale sidecar (
ts-edge) joins your tailnet on container start. - Caddy uses
network_mode: "service:ts-edge"to share the sidecar's network namespace, so it listens only ontailscale0(and the internal Docker bridge). - No host ports are published. The VPS firewall can block 80/443 entirely.
- Public DNS for your domain points at the tailnet IPv4 (
100.x.y.z) via Cloudflare DNS-only (gray cloud — no proxying). Cloudflare cannot proxy a private address, so this is correct. - ACME uses the DNS-01 challenge (because port 80 is unreachable). Caddy talks to the Cloudflare API directly to satisfy the challenge.
- Everything in the public Docker Compose guide (VPS, GitHub OAuth app, Voyage/OpenRouter keys, vault repo).
- A Tailscale account.
- The VPS kernel exposes
/dev/net/tun(most providers do; some VPS-on-LXC hosts don't). - Docker Compose v2.24+ (the overlay uses the
!resetdirective).
In the Tailscale admin console:
- Create an ACL tag (e.g.
tag:brain) and grant your identity ownership of it. - Add an ACL rule allowing
autogroup:member(your devices) to reachtag:brainon TCP 80/443:
- Generate an auth key: Settings → Keys → Generate auth key.
- Reusable (so the container can re-auth on restart).
- Non-ephemeral (so the node persists between restarts; state lives in the
ts-stateDocker volume). - Tagged with
tag:brain.
Set TS_AUTHKEY=tskey-auth-... in .env.
- Create an A record at your
DOMAINpointing to the tailnet IPv4 the sidecar will get (you'll know the IP after first boot — see step 4). DNS-only (gray cloud), TTL Auto. - Create an API token (Profile → API Tokens → Create Token):
- Permissions:
Zone:Zone:Read+Zone:DNS:Edit. - Resources: include only your zone.
- Set
CF_API_TOKEN=...in.env.
- Permissions:
Create a GitHub OAuth app whose callback URL is https://<your-domain>/api/auth/callback/github (same as the public path).
docker compose -f docker-compose.yml -f docker-compose.tailscale.yml up -d --build
docker compose logs -f ts-edge
# Note the tailnet IPv4 the sidecar advertises: e.g. 100.64.0.42.
# Update the Cloudflare A record (step 2) to that IP.
docker compose logs -f caddy
# Expect "certificate obtained successfully" via DNS-01.- From a tailnet device:
curl -I https://<your-domain>returns200/302with a Let's Encrypt cert. - From a non-tailnet network (cellular with Tailscale off):
curl --max-time 5 https://<your-domain>times out. - Run the pen-test checklist including the Tailnet-only exposure section.
To deploy code updates with the overlay:
docker compose -f docker-compose.yml -f docker-compose.tailscale.yml pull
docker compose -f docker-compose.yml -f docker-compose.tailscale.yml up -d --buildTo return to the public path: bring the stack down and run plain docker compose up -d against the default docker-compose.yml. You'll need to adjust your DNS record (point at the public VPS IP) and let Caddy re-issue via HTTP-01.
- The sidecar approach means every container restart re-authenticates with the tailnet. The persistent
ts-statevolume is what keeps the node identity stable across restarts. Don't delete that volume casually. - DNS-01 needs the API token to remain valid. Rotate the Cloudflare token on your normal cadence.
- Cloudflare proxying (orange cloud) will not work — it cannot proxy a tailnet address. Keep the record DNS-only.
{ "tagOwners": { "tag:brain": ["autogroup:member"] }, "acls": [ { "action": "accept", "src": ["autogroup:member"], "dst": ["tag:brain:80,443"] } ] }