In this article we demonstrate how you can run Headscale and Headscale UI using Docker Containers. Our recent article was specific to installation of Headscale on Ubuntu Linux system using .deb
package. The link to the post is shared in the link below.
If you don’t prefer package based installation of Headscale then this article is designed for you. Headscale is a very powerful and open source alternative solution to Tailscale coordination server. It is built to be self-hosted in your own infrastructure. Headscale is independently developed and doesn’t have any relationship with Tailscale company except being re-implemented version of Tailscale coordination server.
Follow the steps shared in this blog post to setup a dedicated Headscale server running in Docker container. The same procedure can be re-used for Podman / Podman compose setup with few modifications. We’ve also included installation of Headscale UI in docker.
1) Install Docker Engine
Begin your setup by ensuring Docker Engine is installed and working. Refer to the following guide on installation of Docker on Linux systems.
Once installed you can check version.
$ docker --version
Docker version 27.0.3, build 7d4bcd8
Also check if compose plugin is installed.
$ docker compose version
Docker Compose version v2.28.1
2) Define Docker Compose file
Let’s create a directory that stores Headscale configurations and data.
mkdir -p ~/headscale && cd ~/headscale
Create an empty SQlite datebase in the headscale directory:
mkdir ./config
touch ./config/db.sqlite
Create a new file called docker-compose.yml
vim docker-compose.yml
Paste and modify below contents. If you don’t need Headscale running in Docker container, then remove its sections.
services:
headscale:
container_name: headscale
image: headscale/headscale:latest
command: headscale serve
restart: unless-stopped
ports:
- 8080:8080
- 9090:9090
volumes:
- ./config:/etc/headscale/
- ./data:/var/lib/headscale/
headscale-ui:
container_name: headscale-ui
image: ghcr.io/gurucomputing/headscale-ui:latest
pull_policy: always
restart: unless-stopped
ports:
- 9080:80
In our compose file we are:
- Mounting
config/
under/etc/headscale
- Mounting
data/
under/var/lib/headscale/
- Forwarding port 8080 out of the headscale container so the
headscale
instance becomes available - Forwarding port 9080 out of the headscale-ui container so the
headscale-ui
instance becomes available
If using https on the UI you can map separate ports as below.
- 9443:443
3) Start Headscale containers
Download configuration template file.
mkdir {config,data}
wget https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml -O config/config.yaml
Modify the config file to your preferences before launching Docker container. Here are some settings that you likely want:
# Change to your hostname or host IP
# server_url: //vpn.computingforgeeks.com:8080
server_url: http://192.168.20.11:8080
# Listen to 0.0.0.0 so it's accessible outside the container
metrics_listen_addr: 0.0.0.0:9090
Start the containers using docker compose
command. The -d
option means detach so they run in the background.
$ docker compose up -d
[+] Running 19/19
✔ headscale-ui 13 layers [⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿] 0B/0B Pulled 3.2s
✔ 8921db27df28 Pull complete 0.5s
✔ 5e0dab5e7f18 Pull complete 0.5s
✔ e7466e9238c6 Pull complete 0.5s
✔ 9aca5eef2d98 Pull complete 0.7s
✔ 4f4fb700ef54 Pull complete 0.8s
✔ 5007d4d43f68 Pull complete 0.8s
✔ 2ebc6f305da4 Pull complete 1.0s
✔ 712d06ffc498 Pull complete 1.1s
✔ ac473b17e437 Pull complete 1.7s
✔ 36f5767e6555 Pull complete 1.7s
✔ 7c6ac0210a21 Pull complete 1.8s
✔ 7765e166cec5 Pull complete 1.8s
✔ 896f16a8f15c Pull complete 2.0s
✔ headscale 4 layers [⣿⣿⣿⣿] 0B/0B Pulled 4.4s
✔ 9e3ea8720c6d Pull complete 2.3s
✔ 71273269cd97 Pull complete 2.4s
✔ 007b622b7a95 Pull complete 2.5s
✔ 50a37fbfee41 Pull complete 2.5s
[+] Running 3/3
✔ Network headscale_default Created 0.1s
✔ Container headscale-ui Started 0.6s
✔ Container headscale Started
Check status the containers created.
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
headscale headscale/headscale:latest "headscale serve" headscale 45 seconds ago Up 43 seconds 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:9090->9090/tcp, :::9090->9090/tcp
headscale-ui ghcr.io/gurucomputing/headscale-ui:latest "/bin/sh -c '/bin/sh…" headscale-ui 45 seconds ago Up 43 seconds 443/tcp, 0.0.0.0:9080->80/tcp, :::9080->80/tcp
You can follow the container logs to see what’s happening inside by running.
$ docker logs --follow headscale
2023-10-05T23:29:19Z INF go/src/headscale/hscontrol/protocol_common.go:574 > Successfully sent auth url AuthURL=http://192.168.20.11:8080/register/nodekey:af52937e8bc375a0d73a01cd453fd2f814144796b0270cedb0d126a39518c306 machine=jammy noise=true
$ docker logs --follow headscale-ui
no Caddyfile detected, copying across default config
Starting Caddy
{"level":"info","ts":1696545638.4848695,"msg":"using provided configuration","config_file":"/data/Caddyfile","config_adapter":"caddyfile"}
{"level":"info","ts":1696545638.4858341,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforce_origin":false,"origins":["//localhost:2019","//[::1]:2019","//127.0.0.1:2019"]}
{"level":"info","ts":1696545638.4859416,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc000544fc0"}
{"level":"info","ts":1696545638.486509,"logger":"http","msg":"server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS","server_name":"srv0","https_port":443}
{"level":"warn","ts":1696545638.4865189,"logger":"http","msg":"automatic HTTP->HTTPS redirects are disabled","server_name":"srv0"}
{"level":"warn","ts":1696545638.4865253,"logger":"http","msg":"server is listening only on the HTTP port, so no automatic HTTPS will be applied to this server","server_name":"srv1","http_port":80}
{"level":"info","ts":1696545638.4865966,"logger":"pki.ca.local","msg":"root certificate trust store installation disabled; unconfigured clients may show warnings","path":"storage:pki/authorities/local/root.crt"}
{"level":"warn","ts":1696545638.4866195,"logger":"tls","msg":"YOUR SERVER MAY BE VULNERABLE TO ABUSE: on-demand TLS is enabled, but no protections are in place","docs":"https://caddyserver.com/docs/automatic-https#on-demand-tls"}
{"level":"info","ts":1696545638.48668,"logger":"http.log","msg":"server running","name":"srv1","protocols":["h1","h2","h3"]}
{"level":"info","ts":1696545638.486689,"logger":"tls","msg":"cleaning storage unit","description":"FileStorage:/home/appuser/.local/share/caddy"}
{"level":"info","ts":1696545638.4867213,"logger":"http","msg":"enabling HTTP/3 listener","addr":":443"}
{"level":"info","ts":1696545638.4867342,"logger":"tls","msg":"finished cleaning storage units"}
{"level":"info","ts":1696545638.4867566,"msg":"failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 2048 kiB, got: 416 kiB). See https://github.com/lucas-clemente/quic-go/wiki/UDP-Receive-Buffer-Size for details."}
{"level":"info","ts":1696545638.4868112,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
{"level":"info","ts":1696545638.486932,"msg":"autosaved config (load with --resume flag)","file":"/home/appuser/.config/caddy/autosave.json"}
{"level":"info","ts":1696545638.4869497,"msg":"serving initial configuration"}
The ss
command can be used to check ports on the host are bound.
$ ss -tunlp|egrep '8080|9080'
tcp LISTEN 0 4096 0.0.0.0:9080 0.0.0.0:* users:(("docker-proxy",pid=364448,fd=4))
tcp LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=364428,fd=4))
tcp LISTEN 0 4096 [::]:9080 [::]:* users:(("docker-proxy",pid=364455,fd=4))
tcp LISTEN 0 4096 [::]:8080 [::]:* users:(("docker-proxy",pid=364434,fd=4))
4) Access Container shell
You can access headscale
container shell by running:
docker exec -ti headscale bash
For headscale-ui run:
docker exec -ti headscale-ui sh
We can test the functionality by creating a user on Headscale instance.
root@4f2fbe02c029:/# headscale users create computingforgeeks
A one line command execution without entering container’s shell will be.
docker exec headscale \
headscale users create myuser
5) Join client devices to Headscale network
You need a user created on your Headscale server. We’ll create one named computingforgeeks.
$ docker exec headscale headscale users create computingforgeeks
User created
Installing Tailscale clients on your devices
Installing Tailscale on Linux / BSD
For Linux and BSD based systems, run the commands:
curl -fsSL https://tailscale.com/install.sh | sudo sh
Checking the version of Tailscale can be done with the commands below.
$ tailscale --version
1.50.1
tailscale commit: f45c02bfcf5ee5790c3af278c9e974c9b9b0e771
other commit: 36a20760a45bd1936686879b34c35146cc0c4ec1
go version: go1.21.1
Installing on macOS
You can install using Homebrew:
brew install tailscale
Or by running the script.
curl -fsSL https://tailscale.com/install.sh | sudo sh
Register a machine (normal login)
On a client machine, execute the tailscale
login command:
tailscale up --login-server YOUR_HEADSCALE_URL
See example below.
# tailscale up --login-server http://192.168.20.11:8080
To authenticate, visit:
http://192.168.20.11:8080/register/nodekey:410155d1792d0f81a5f39415a1a418f882208751570c2e5195f7a6842ca44e6a
Opening the links gives commands to be executed in Headscale server to register the device.

Listing all the users created in Headscale server:
$ docker exec headscale headscale user list
ID | Name | Created
1 | computingforgeeks | 2023-10-05 22:50:40
2 | myuser | 2023-10-05 22:52:18
3 | user2 | 2023-10-05 22:59:03
Remember to replace USERNAME with your valid user name when running the commands.
$ docker exec headscale \
headscale nodes register --user computingforgeeks --key nodekey:410155d1792d0f81a5f39415a1a418f882208751570c2e5195f7a6842ca44e6a
Machine rocky8 registered
To general syntax in registering a machine using headscale
is:
docker exec headscale \
headscale --user <username> nodes register --key <YOU_+MACHINE_KEY>
Listing all the machines added to Headscale mesh network.
# docker exec headscale headscale node list
ID | Hostname | Name | MachineKey | NodeKey | User | IP addresses | Ephemeral | Last seen | Expiration | Online | Expired
1 | jammy | jammy | [cN/Um] | [r1KTf] | computingforgeeks | 100.64.0.1, fd7a:115c:a1e0::1 | false | 2023-10-05 23:39:04 | 0001-01-01 00:00:00 | online | no
Register machine using a pre authenticated key
You can also register a new machine using pre authenticated key.
First generate a key using the command line:
docker exec headscale \
headscale --user <username> preauthkeys create --reusable --expiration 24h
Executing the command returns a pre-authenticated key used to connect a node to headscale
when running the tailscale
command:
tailscale up --login-server <YOUR_HEADSCALE_URL> --authkey <YOUR_AUTH_KEY>
Let’s see example below.
# On Headscale server
# docker exec headscale headscale --user jkmutai preauthkeys create --reusable --expiration 24h
4763c4f4293b260eff230065378e5668c13db44f4569ed7b
# On Machine to be registered
# tailscale up --login-server http://vpn.hirebestengineers.com --authkey 4763c4f4293b260eff230065378e5668c13db44f4569ed7b
The machine registration command with pre-authenticated key gives no output. But you can confirm if a new nodes is added from Headscale server CLI.
# docker exec headscale headscale node list
ID | Hostname | Name | MachineKey | NodeKey | User | IP addresses | Ephemeral | Last seen | Expiration | Online | Expired
1 | rocky8 | rocky8 | [s+TG9] | [QQFV0] | jkmutai | 100.64.0.1, fd7a:115c:a1e0::1 | false | 2023-10-05 17:05:58 | 0001-01-01 00:00:00 | online | no
2 | mail | mail | [V8WI2] | [OvPLb] | jkmutai | 100.64.0.2, fd7a:115c:a1e0::2 | false | 2023-10-05 17:06:32 | 0001-01-01 00:00:00 | online | no
6) Helpful Headscale commands
Delete a node in your network.
docker exec headscale \
headscale node delete -i <ID>
Move node to another user
docker exec headscale \
headscale node move -i <ID> -u <New-User>
Rename a machine in your network
docker exec headscale \
headscale node rename -i <ID> <NEW_NAME>
Expire (log out) a machine in your network
docker exec headscale \
headscale node expire -i <ID>
Create a API key:
docker exec headscale \
headscale apikeys create --expiration 90d
List API keys:
docker exec headscale \
headscale apikeys list
Expire an API key:
docker exec headscale \
headscale apikeys expire --prefix "<PREFIX>"
7) Access Headscale UI
Generate API Key on Headscale server.
docker exec headscale \
headscale apikeys create --expiration 120d
This gives an output similar to this.
8bnNOGwOkw.bjQXvEB4Vk9Ia1R9HupEB0yB9PFthth_Or8QcHncKmw
Open Headscale UI on http://ServerIP_or_hostname:9080/web/settings.html

Input Headscale server URL and token generated into the boxes. Examples:
- Headscale URL: //api.computingforgeeks.com
- Headscale API key: 8bnNOGwOkw.bjQXvEB4Vk9Ia1R9HupEB0yB9PFthth_Or8QcHncKmw
Next article to check out:
- Joining pfSense to Tailscale / Headscale VPN Mesh
- How To Enable and Start SSH Server on OPNsense
- How To Install and Configure Tailscale Client on OPNsense
Conclusion
Headscale is a perfect solution for small to medium size companies looking to create Tailscale mesh network and connect devices to it. It is powerful, secure, and reliable solution alternative to VPN server setup. It gives you full control over the mesh network and coordination server. Mesh VPNs use a peer-to-peer (P2P) model to create a secure shared environment for its users.