This repo is a cleaned template for running:
headscalein Dockerswagas the TLS reverse proxy- DuckDNS-based DNS validation for Let's Encrypt certificates
Only reusable setup files are kept here. Generated runtime state, logs, certificates, databases, and credentials are excluded.
docker/.envfor container runtime values such as UID, GID, timezone, and base domaindocker/headscale/config/config.yamlfor the public Headscale URL and server settingsdocker/swag/config/dns-conf/duckdns.inifor the DuckDNS API tokendocker/swag/config/nginx/proxy-confs/headscale.subdomain.conffor the public Headscale hostname
docker/
.env.example
docker-compose.yml
headscale/
config/
config.yaml.example
lib/
.gitkeep
swag/
config/
dns-conf/
duckdns.ini.sample
nginx/
proxy-confs/
headscale.subdomain.conf.sample
- Copy the example files to their runtime names.
cp docker/.env.example docker/.envcp docker/headscale/config/config.yaml.example docker/headscale/config/config.yamlcp docker/swag/config/dns-conf/duckdns.ini.sample docker/swag/config/dns-conf/duckdns.inicp docker/swag/config/nginx/proxy-confs/headscale.subdomain.conf.sample docker/swag/config/nginx/proxy-confs/headscale.subdomain.conf
- Replace all placeholders with your own domain and system values.
- Set
BASE_DOMAINindocker/.envto your DuckDNS root domain such asexample.duckdns.org - Replace
HEADSCALE_FQDNindocker/headscale/config/config.yaml - Replace
HEADSCALE_FQDNindocker/swag/config/nginx/proxy-confs/headscale.subdomain.conf - Replace
YOUR_DUCKDNS_TOKENindocker/swag/config/dns-conf/duckdns.ini - A typical Headscale hostname is
hs.<your-base-domain>
- Set
- Protect the DNS credential file.
chmod 600 docker/swag/config/dns-conf/duckdns.ini
- Start
swagfirst.cd dockerdocker compose up -d swagdocker logs -f swag
- After adding the DNS token, restart
swag.docker compose restart swag
- Confirm certificate issuance.
- Look for log lines such as
Successfully received certificate.andServer ready
- Look for log lines such as
- Start
headscale.docker compose up -d headscaledocker logs -f headscale
- Verify the stack.
docker compose psdocker exec headscale headscale healthdocker logs --tail 100 swagdocker logs --tail 100 headscale- Open
https://HEADSCALE_FQDN - A successful public check should return HTTP 200
docker-compose.ymlis parameterized throughdocker/.env.- SWAG generates most of its internal config on first start; those generated files are intentionally ignored.
- The repo keeps
docker/headscale/lib/.gitkeepso the bind-mounted data directory exists without committing the database. - If Headscale logs
Listening without TLS but ServerURL does not start with http://, that is expected when TLS is terminated by SWAG. - The SWAG proxy example assumes the upstream service name is
headscaleon the Docker network. - Persistent directories should exist before first startup:
docker/headscale/configdocker/headscale/libdocker/swag/config
cd docker
docker compose ps
docker logs --tail 100 swag
docker logs --tail 100 headscale
docker exec headscale headscale health
docker exec headscale headscale users list --output json
docker exec headscale headscale nodes list --output jsonCreate a user:
docker exec headscale headscale users create USERNAMECreate a reusable preauth key for numeric user ID 1:
docker exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h --output jsonRegister a mobile device manually when Headscale provides a registration key:
docker exec headscale headscale nodes register --user USERNAME --key <REGISTRATION_KEY>- If your WSL environment does not run
systemd,sudo systemctl start tailscaledwill not work. - A working alternative is
sudo tailscaled --tun=userspace-networking. - Then connect with
sudo tailscale up --login-server https://HEADSCALE_FQDN --authkey <AUTH_KEY>. - In this mode, manual Taildrop retrieval is reliable:
tailscale file get ~/Downloads
headscaleis the control plane.- An exit node is a separate Tailscale client that forwards internet traffic.
You do not need an exit node just to connect devices to each other through Headscale.