Deploy Headscale and Headscale UI with Docker and Traefik
Run your own Tailscale-compatible control server and protect the optional UI.
Run your own Tailscale-compatible control server and protect the optional UI.
This is a standalone service guide. It explains the service in isolation so you can deploy it without copying unrelated parts of another stack.
All domains, emails, usernames, IP addresses, passwords, and tokens in this post are placeholders. Replace them for your own server, but do not publish real secrets or private infrastructure details.
What this service does
Route or access pattern:
1
headscale.example.com and headscale-ui.example.com
Main components:
1
Headscale control server, Headscale UI, config directory, persistent SQLite/state directory.
Network and port model:
1
Traefik routes control API/UI to port 8080; UDP 3478 can be published for STUN/DERP-style connectivity when used.
In a Docker homelab, the safest pattern is to keep the application private on Docker networks and let the reverse proxy handle HTTPS traffic.
Folder layout
Use a dedicated folder:
1
2
3
4
5
/home/ubuntu/headscale/
├── docker-compose.yml
├── .env
├── data/ # or service-specific persistent data
└── backups/ # optional local backup destination
Keep .env private. Public documentation should show only placeholders.
Environment file
Example .env values:
SERVER_URL=https://headscale.example.com
BASE_DOMAIN=tail.example.com
Rules:
- generate long random passwords and tokens;
- keep
.envout of Git; - do not paste production values into public tutorials;
- rotate exposed credentials immediately if they ever leak.
Compose pattern
A minimal Traefik-aware Compose pattern looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
headscale:
image: <service-image>:<version>
container_name: headscale
restart: unless-stopped
env_file:
- .env
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.headscale.entrypoints=https"
- "traefik.http.routers.headscale.rule=Host(`headscale.example.com`)"
- "traefik.http.routers.headscale.tls=true"
- "traefik.http.routers.headscale.tls.certresolver=cloudflare"
- "traefik.http.services.headscale.loadbalancer.server.port=<internal-port>"
networks:
proxy:
external: true
Adapt image names, volumes, internal ports, and service-specific environment variables for the actual application.
Deployment steps
Create the directory:
1
2
mkdir -p /home/ubuntu/headscale
cd /home/ubuntu/headscale
Create the .env file with placeholder values replaced by your own secrets:
1
2
nano .env
chmod 600 .env
Create or edit docker-compose.yml, then start the service:
1
docker compose up -d
Check status:
1
2
docker compose ps
docker compose logs --tail=100
Verification checklist
Verify the container is running:
1
docker ps --filter name=headscale
Verify Traefik can see the route:
1
docker logs traefik --tail=100
Verify the public route, if the service has one:
1
curl -I https://service.example.com
Expected results vary by service:
200means the app is reachable;302can be correct when authentication redirects to an identity provider;401can be correct for APIs that require authentication;404usually means the Traefik router rule did not match.
Backup checklist
Back up at least:
1
2
3
/home/ubuntu/headscale/docker-compose.yml
/home/ubuntu/headscale/.env # private backup only
/home/ubuntu/headscale/data/ # or named Docker volumes
For database-backed services, prefer application-aware dumps in addition to copying files:
1
docker compose exec <database> <dump-command> > backup.sql
Never publish backup archives. They often contain tokens, password hashes, uploads, user data, or private keys.
Common problems
The domain returns 404
Check:
- the container has
traefik.enable=true; - the service is attached to the
proxynetwork; - the router
Host(...)rule matches the exact domain; traefik.docker.network=proxyis set when the container has multiple networks.
The domain returns bad gateway
Traefik found the route but cannot reach the internal app port.
Check the application logs and confirm the internal port used by:
1
traefik.http.services.<service>.loadbalancer.server.port
The service starts but data disappears after restart
The persistent directory or Docker volume is missing. Add a named volume or a bind mount for the service data before using it seriously.
Authentication loops or redirects fail
Check the public URL, trusted proxy headers, cookie domain, and whether the app knows it is behind HTTPS.
Security notes
- Keep admin dashboards behind authentication or private networks.
- Do not expose databases, caches, or admin APIs directly to the internet.
- Use least-privilege API tokens.
- Prefer internal Docker networks for dependencies.
- Keep public docs generic: use
example.com,<token>,<password>, and documentation IP ranges.
Service documentation map
This post is part of the standalone homelab service documentation series. Use these guides together when building the full stack:
- Traefik reverse proxy
- Authentik SSO
- Cloudflare Companion
- Headscale control server
- Pi-hole private DNS
- Garage S3 object storage
- WordPress stack
- WatchParty service
- Pterodactyl Panel and Wings for private game hosting with public Panel access and Headscale-only Wings/game ports.
- Jekyll documentation site
- OpenClaw gateway
- Vaultwarden password manager
- Portainer Docker management
- Karakeep bookmarks
- Discourse forum
- PocketBase backend
- LM Studio local AI API