Had a lot of issues figuring this out myself so I wanted to publish this for posterity. Basically, if you are running Traefik as a docker container, to serve as a reverse proxy for a bunch of other services running as docker containers, and you also want to gate some of those docker container services behind a Tailscale private network, and you’re using Cloudflare for DNS, then this is for you. It’s quite simple actually, here’s the docker compose YAML for the network infra:
# for port mapping, left side is host, right side is container
networks:
public:
name: public
driver: bridge
private:
name: private
driver: bridge
services:
tailscale:
image: tailscale/tailscale
container_name: tailscale
hostname: MACHINENAMEHERE
networks:
- private
environment:
- TS_AUTHKEY=YOUR_OAUTH_KEY_HERE?ephemeral=false
- TS_STATE_DIR=/var/lib/tailscale
- TS_EXTRA_ARGS=--advertise-tags=tag:container
volumes:
- /home/tailscale:/var/lib/tailscale
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin
- sys_module
restart: unless-stopped
traefik-ts:
image: traefik
container_name: traefik-ts
restart: unless-stopped
network_mode: service:tailscale
volumes:
- /home/traefik-tailscale/letsencrypt:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- CLOUDFLARE_EMAIL=XXX
- CLOUDFLARE_API_KEY=XXX
command:
- --accesslog=true
- --providers.docker=true
- --api
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entryPoint.to=websecure
- --entrypoints.web.http.redirections.entryPoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.cloudflare.acme.dnschallenge=true
- --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.cloudflare.acme.email=xxx
- --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json
labels:
- traefik.enable=true
- traefik.http.routers.traefik-ts.rule=Host(`traefik.ts.domain.com`)
- traefik.http.routers.traefik-ts.service=api@internal
- traefik.http.routers.traefik-ts.entrypoints=websecure
- traefik.http.routers.traefik-ts.tls.certresolver=cloudflare
- traefik.http.routers.traefik-ts.middlewares=myauth
- traefik.http.middlewares.myauth.basicauth.users=someauthhere
- traefik.docker.network=private
traefik:
image: traefik
container_name: traefik
restart: unless-stopped
networks:
- public
volumes:
- /home/traefik/letsencrypt:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- 80:80
- 443:443
environment:
- CLOUDFLARE_EMAIL=xxx
- CLOUDFLARE_API_KEY=xxx
command:
- --accesslog=true
- --api
- --providers.docker=true
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entryPoint.to=websecure
- --entrypoints.web.http.redirections.entryPoint.scheme=https
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.http.tls=true
- --certificatesresolvers.cloudflare.acme.dnschallenge=true
- --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.cloudflare.acme.email=xxx
- --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json
labels:
- traefik.enable=true
- traefik.http.routers.traefik.rule=Host(`traefik.domain.com`)
- traefik.http.routers.traefik.service=api@internal
- traefik.http.routers.traefik.entrypoints=websecure
- traefik.http.routers.traefik.tls.certresolver=cloudflare
- traefik.http.routers.traefik.middlewares=myauth
- traefik.http.middlewares.myauth.basicauth.users=someauthhere
So what’s happening here? Basically we’re running 2 Traefik instances, a public one that’s bound to ports 80 and 443 on the host machine, and a private one that’s bound to those same ports on the Tailscale node. This means that HTTP requests to the host machine IP will be routed by the public Traefik instance, while requests to the Tailscale node IP will be handled by the private one.
Then in Cloudflare you just need to add DNS A records that point domain.com
to the host IP, and ts.domain.com
to the Tailscale node. Then add CNAME records for *.domain.com -> domain.com
and *.ts.domain.com -> ts.domain.com
. Now you can easily add services that are either public or private to the tailnet, based on which docker network you assign and what domain you use. Here’s an example of a public service:
corsproxy:
image: imjacobclark/cors-container
container_name: corsproxy
networks:
- public
ports:
- 3333:3000
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.corsproxy.rule=Host(`corsproxy.domain.com`)
- traefik.http.services.corsproxy.loadbalancer.server.port=3000
- traefik.http.routers.corsproxy.entrypoints=websecure
- traefik.http.routers.corsproxy.tls.certresolver=cloudflare
And here’s an example of a private service:
actual:
image: docker.io/actualbudget/actual-server:latest
container_name: actual
networks:
- private
restart: unless-stopped
ports:
- 5006:5006
volumes:
- /home/actual:/data
labels:
- traefik.enable=true
- traefik.http.routers.actual.rule=Host(`actual.ts.domain.com`)
- traefik.http.services.actual.loadbalancer.server.port=5006
- traefik.http.routers.actual.entrypoints=websecure
- traefik.http.routers.actual.tls.certresolver=cloudflare
A container can even be on both networks (e.g. a public facing service that needs to locally communicate with private services), you can just list both networks e.g.
networks:
- private
- public
One caveat however is that if you are defining a container on both networks, you need to specify which network should be used for Traefik, otherwise it might use the wrong IP and you’ll get bad gateway errors. You can do this by adding the correct network to labels
, .e.g.
- traefik.docker.network=public