In a previous post: Headscale, how to self-host tailscale, I explained how to configure headscale in server; now it is outdated. At that time the only way to install it was using the .deb or binary file, but now we can use a simpler method. This time we will focus on: Docker, headscale and SWAG.

SWAG (Secure Web Application Gateway) sets up an Nginx reverse proxy with built-in cerbot for SSL certificates using Let's Encrypt. I already wrote a litter bit about it here.

Headscale configuration

Create a directory named docker and inside it make the headscale/config directory.

Then copy the headscale config example in headscale/config:

1
curl https://raw.githubusercontent.com/juanfont/headscale/refs/heads/main/config-example.yaml -o ./headscale/config/config.yaml

Edit the following values:

1
2
3
4
5
6
7
server_url: https://{your-sever-site} # example: https://headscale.subdomain.duckdns.org
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false
tls_cert_path: ""
tls_key_path: ""

And comment the following (since SWAG will be the instance that will get the SSL certificate):

1
2
3
4
5
# tls_letsencrypt_listen: ":http"
# tls_letsencrypt_cache_dir: ./cache
# tls_letsencrypt_hostname: ""
# acme_email: ""
# acme_url: https://acme-v02.api.letsencrypt.org/directory

Docker compose file

The docker-compose file was done following SWAG and headscale documentations, as well as this gist.

Inside the previously created directory: docker, put the docker-compose.yml.

In this case I am using duckdns as the dns validation method to get the SSL certificate. I am also getting a wildcard certificate, so I can run more services (like filebrowser) in the same sever using the same SSL cert. SWAG will get the wildcard cert as: *.subdomain.duckdns.org

According to the docs, the first run will give errors during validation due to wrong credentials. We need to update our duckdns API token in the file located in swag/config/dns-conf/duckdns.ini then restart the swag container.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
---
services:
   swag:
     image: lscr.io/linuxserver/swag
     container_name: swag
     cap_add:
       - NET_ADMIN
     environment:
       - PUID=1000
       - PGID=1000
       - TZ=Europe/London
       - URL={subdomain}.duckdns.org
       - SUBDOMAINS=wildcard
       - VALIDATION=dns
       - DNSPLUGIN=duckdns
     volumes:
       - ./swag/config:/config
     ports:
       - 443:443
       - 80:80 #optional
     restart: unless-stopped

   headscale:
     image: docker.io/headscale/headscale:latest
     restart: unless-stopped
     container_name: headscale
     read_only: true
     tmpfs:
       - /var/run/headscale
     ports:
       - "127.0.0.1:8080:8080"
       - "127.0.0.1:9090:9090"
     volumes:
       - ./headscale/config:/etc/headscale:ro
       - ./headscale/lib:/var/lib/headscale
     command: serve
     healthcheck:
         test: ["CMD", "headscale", "health"]

   # duckdns:
   #   image: lscr.io/linuxserver/duckdns:latest
   #   container_name: duckdns
   #   network_mode: host #optional
   #   environment:
   #     - PUID=1000 #optional
   #     - PGID=1000 #optional
   #     - TZ=Europe/London
   #     - SUBDOMAINS={subdomain}
   #     - TOKEN={your-token}
   #   volumes:
   #     - ./duckdns/config:/config
   #   restart: unless-stopped

Optionally, if your server does not have a fixed IP address, you can uncomment the duckdns service in the compose file to automatically update the server IP in duckdns.org; just add your SUBDOMAINS={subdomain} and your TOKEN={your-token}

Headscale SWAG proxy configuration

SWAG does not provide a proxy *.subdomain.conf.sample, but luckly the one provided in the gist works like charm.

Save the following as headscale.subdomain.conf inside swag/config/nginx/proxy-confs/, assuming the headscale container name is headscale and restart swag container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
map $http_upgrade $connection_upgrade {
    default      keep-alive;
    'websocket'  upgrade;
    ''           close;
}

server {
  listen 443      ssl http2;
  listen [::]:443 ssl http2;

  server_name headscale.*;

  include /config/nginx/ssl.conf;


  location / {
    include /config/nginx/proxy.conf;
    include /config/nginx/resolver.conf;
    set $upstream_app headscale;
    set $upstream_port 8080;
    set $upstream_proto http;
    proxy_pass $upstream_proto://$upstream_app:$upstream_port;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_buffering off;
    add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
  }
}

That's it, your headscale sever should be available in https://headscale.subdomain.duckdns.org

Now you can create your user and add clients/nodes.

Headscale commands

Finally, use the headscale commands to create the users and add clients (you might need sudo):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Create a user
docker exec {headscale-container-name} headscale users create {your-user}
# Add client
docker exec {headscale-container-name} headscale nodes register --key {client-key} --user {your-user}
# Client list
docker exec {headscale-container-name} headscale nodes list
# Client appove routes
docker exec {headscale-container-name} headscale nodes approve-routes -i {identifier} -r "{routes}" # example: 192.168.1.0/24
# Client renamo
docker exec {headscale-container-name} headscale nodes rename {new-name} -i {identifier}

Join devices

In order to join your devices, you can use the official tailscale app and run:

Linux and OpenWrt

Login can be done with:

1
sudo tailscale up --login-server=https://headscale.subdomain.duckdns.org # Replace with your subdomain

To expose your local subnet (devices connected to your OpenWrt router) you should add the flag --advertise-routes=192.168.1.0/24.

If you are running OpenWrt 22.03, you need to add the flag --netfilter-mode=off and configure the firewall rules, due to tailscale uses still iptables and latest versions of OpenWrt switched to nftables. See issue here.

This was fixed in version 23.0.5 and later the --netfilter-mode=off flag is no longer needed.

Android

Set the custom server in the official tailscale android app.

And now enjoy the SWAG!

If you found this content useful, please support me:
BTC: 1E2YjL6ysiPxRF4AEdXChpzpesRuyzgE1y