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