
Securely Exposing Services With Traefik and Tailscale
Table of Contents
Intro
I’ve been using Tailscale for quite some time now, and it’s one of those tools that just works. It creates a mesh VPN between my devices, making it easy to access services regardless of where I am.
However, I’ve always wanted a cleaner way to access my internal services without remembering IP addresses or using different ports, and with valid certificates.
I wanted a solution that would:
- Use nice, clean URLs for all my services
- Automatically handle HTTPS certificates
- Keep everything secure within my Tailscale network
- Be easy to maintain and scale
The solution I found was to combine Traefik with Tailscale, creating a secure gateway to all my services with clean, custom domain names.
The Concept
The basic idea is simple:
- Set up a Tailscale node as a container in Docker
- Attach a Traefik container to that Tailscale container, to share ip
- Create a wildcard DNS record (*.ts.mydomain.com) pointing to my Tailscale node
- Configure Traefik to route requests to the appropriate services based on container name
This creates a setup where I can access any of my services using a URL like service-name.ts.mydomain.com
, and all traffic is routed securely through my Tailscale network.
DNS Setup
The first step was to create a CNAME record for my subdomain. I added a wildcard CNAME record for *.ts.robert-jensen.dk
that points to my Tailscale endpoint.
This means that any request to a domain ending in .ts.robert-jensen.dk
will be directed to my Tailscale node.
Docker Compose Setup
Here’s the Docker Compose configuration I’m using:
services:
tailscale-traefik:
image: ghcr.io/tailscale/tailscale:stable
hostname: tailscale-traefik
container_name: tailscale-traefik
networks:
- tailscale
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_EXTRA_ARGS=--advertise-tags=tag:container
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
volumes:
- tailscale:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin
- sys_module
restart: always
labels:
- com.centurylinklabs.watchtower.enable=true
traefik:
image: traefik:3
container_name: ts-traefik
restart: always
security_opt:
- no-new-privileges:true
environment:
- TZ=Europe/Copenhagen # Change this to your timezone
- CF_API_EMAIL=${CF_API_EMAIL}
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
- LEGO_DISABLE_CNAME_SUPPORT=true
depends_on:
- tailscale-traefik
network_mode: service:tailscale-traefik
healthcheck:
test: traefik healthcheck || exit 1
interval: 60s
timeout: 30s
retries: 3
start_period: 20s
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # Docker socket to watch for Traefik
- traefik-certs:/certs
- conf:/etc/traefik/
labels:
- com.centurylinklabs.watchtower.enable=true
- tailscale=true
- traefik.enable=true
- traefik.http.routers.traefik.rule=Host(`traefik.ts.robert-jensen.dk`)
- traefik.http.routers.traefik.entrypoints=websecure
- traefik.http.routers.traefik.tls.certresolver=letencrypt
- traefik.http.routers.traefik.service=api@internal
- traefik.http.services.traefik.loadbalancer.server.port=8080
volumes:
tailscale: null
conf:
driver: local
driver_opts:
o: bind
type: none
device: /share/Container/conf/ts_traefik/
traefik-certs: null
networks:
tailscale:
external: true
Let’s break down the key components:
Understanding the Components
Tailscale Container
The first service is the Tailscale container:
tailscale-traefik:
image: ghcr.io/tailscale/tailscale:stable
hostname: tailscale-traefik
container_name: tailscale-traefik
networks:
- tailscale
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_EXTRA_ARGS=--advertise-tags=tag:container
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
volumes:
- tailscale:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin
- sys_module
restart: always
labels:
- com.centurylinklabs.watchtower.enable=true
This container:
- Uses the official Tailscale image
- Requires an auth key (stored as an environment variable)
- Mounts
/dev/net/tun
for networking - Requires the
net_admin
andsys_module
capabilities - Stores Tailscale state in a persistent volume
- Is tagged for Watchtower updates
Traefik Container
The second service is Traefik:
traefik:
image: traefik:3
container_name: ts-traefik
restart: always
security_opt:
- no-new-privileges:true
environment:
- TZ=Europe/Copenhagen
- CF_API_EMAIL=${CF_API_EMAIL}
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
- LEGO_DISABLE_CNAME_SUPPORT=true
depends_on:
- tailscale-traefik
network_mode: service:tailscale-traefik
healthcheck:
test: traefik healthcheck || exit 1
interval: 60s
timeout: 30s
retries: 3
start_period: 20s
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/certs
- conf:/etc/traefik/
labels:
- com.centurylinklabs.watchtower.enable=true
- tailscale=true
- traefik.enable=true
- traefik.http.routers.traefik.rule=Host(`traefik.ts.robert-jensen.dk`)
- traefik.http.routers.traefik.entrypoints=websecure
- traefik.http.routers.traefik.tls.certresolver=letencrypt
- traefik.http.routers.traefik.service=api@internal
- traefik.http.services.traefik.loadbalancer.server.port=8080
Key aspects of this configuration:
- Uses Traefik v3 (the latest)
- Uses
network_mode: service:tailscale-traefik
to share the same network/ip as the Tailscale container - Mounts the Docker socket to auto-discover services
- Stores certificates in a persistent volume
- Configures itself to be accessible at
traefik.ts.robert-jensen.dk
- Has Cloudflare DNS credentials for Let’s Encrypt DNS challenge
Traefik Configuration
In addition to the Docker Compose file, I needed to configure Traefik. My base configuration is stored in /share/Container/conf/ts_traefik/
and contains:
traefik.yml
api:
dashboard: true # Optional can be disabled
insecure: true # Optional can be disabled
debug: false # Optional can be Enabled if needed for troubleshooting
entryPoints:
websecure:
address: ":443"
serversTransport:
insecureSkipVerify: true
ping:
entrypoint: "websecure"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
constraints: "Label(`tailscale`,`true`)"
file:
directory: "/etc/traefik/dynamic"
watch: true
certificatesResolvers:
letencrypt:
acme:
email: MyEmailAddress
storage: /certs/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory # production (default)
#caServer: https://acme-staging-v02.api.letsencrypt.org/directory # staging
dnsChallenge:
provider: cloudflare
delayBeforeCheck: 10 #Optional to wait x second before checking with the DNS Server
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
Note this has a constraint setup, so it requires the container, to have the label “tailscale=true” for it to be picked up by this traefik container. This is done since I have more Traefik proxys running on the same Docker host.
Adding Services
Now that I have the basic infrastructure set up, I can easily add new services by simply adding the appropriate labels to my Docker containers. For example, to expose a Home Assistant instance:
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
# ... other configuration ...
networks:
- tailscale
labels:
- traefik.enable=true
- traefik.http.routers.homeassistant.rule=Host(`home.ts.robert-jensen.dk`)
- traefik.http.routers.homeassistant.entrypoints=websecure
- traefik.http.routers.homeassistant.tls.certresolver=letencrypt
- traefik.http.services.homeassistant.loadbalancer.server.port=8123
With this configuration, my Home Assistant instance is accessible at https://home.ts.robert-jensen.dk
from any device on my Tailscale network.
Benefits of This Setup
This setup provides several advantages:
Security: All services are only accessible through Tailscale, which means:
- They’re not exposed to the public internet
- There’s end-to-end encryption
- Strong authentication via Tailscale
Convenience:
- Clean URLs like
service.ts.robert-jensen.dk
- Automatic HTTPS with valid certificates
- No need to remember ports or IP addresses
- No need to setup dns records. Just tag new containers with the hostname I want, and point to hostname.ts.robert-jensen.dk
- Clean URLs like
Manageability:
- Centralized management through Traefik
- Easy to add new services
Troubleshooting
An important thing to this to work, is that my containers need to be on the same docker network, as the Traefik container. I can attach multiple networks, but then i need to add the label “traefik.docker.network=tailscale” where tailscale is the Docker network, for Traefik to know, which network to point to.
Conclusion
Combining Traefik and Tailscale has given me a secure, convenient way to access all my self-hosted services. The setup is:
- Secure by default (no direct exposure to the internet)
- Easy to maintain with Docker Compose
- Scalable as I add more services
- Clean and professional with proper domain names and HTTPS
If you’re already using Tailscale, adding Traefik to the mix is a natural next step to make your self-hosted services more accessible and manageable.
Thanks for reading this far!
Photo by Joshua Sortino on Unsplash