Requirements:
- a linux server with a public IPv4 address
- docker and docker compose installed
- a domain/subdomain with a A DNS record pointing to your IP
I will use example.com as the domain name in this example, please replace it with your domain/subdomain.
Traefik
Ssh to your public server where you want to host your vpn, make a folder called traefik
with the following files:
mkdir traefik && cd traefik
1. An empty letsencrypt
folder: mkdir letsencrypt
2. An empty file touch dynamic.yml
(for future expansion)
3. The static config: nano static.yml
1api:
2 insecure: true
3 dashboard: true
4
5providers:
6 docker:
7 exposedByDefault: false
8
9entryPoints:
10 web:
11 address: ":80"
12 http2:
13 maxConcurrentStreams: 250 # fix for network error on the proxy to my immich instance
14 http3:
15 advertisedPort: 443
16 # http:
17 # redirections:
18 # entryPoint:
19 # to: websecure
20 # scheme: https
21 forwardedHeaders:
22 trustedIPs:
23 - 10.0.0.0/8
24 - 172.16.0.0/12
25 - 192.168.0.0/16
26 - fc00::/7
27 proxyProtocol:
28 trustedIPs:
29 - 10.0.0.0/8
30 - 172.16.0.0/12
31 - 192.168.0.0/16
32 - fc00::/7
33 websecure:
34 address: ":443"
35 http2:
36 maxConcurrentStreams: 250 # fix for network error on the proxy to my immich instance
37 http3:
38 advertisedPort: 443
39 forwardedHeaders:
40 trustedIPs:
41 - 10.0.0.0/8
42 - 172.16.0.0/12
43 - 192.168.0.0/16
44 - fc00::/7
45 proxyProtocol:
46 trustedIPs:
47 - 10.0.0.0/8
48 - 172.16.0.0/12
49 - 192.168.0.0/16
50 - fc00::/7
51
52certificatesResolvers:
53 myresolver:
54 acme:
55 email: "your-email-goes-here@tutorial.example"
56 storage: "/letsencrypt/acme.json"
57 httpChallenge:
58 entryPoint: web
Replace
your-email-goes-here@tutorial.example
with your email address. Let’s Encrypt send you notifications when your ssl certificate expires.
4. The docker compose config: nano compose.yml
1services:
2
3 traefik:
4 image: "traefik:v3.2"
5 container_name: "traefik"
6 restart: "always"
7 ports:
8 - "80:80"
9 - "443:443"
10 networks:
11 - pub
12 volumes:
13 - "./letsencrypt:/letsencrypt"
14 - "/var/run/docker.sock:/var/run/docker.sock:ro"
15 - "./static.yml:/etc/traefik/traefik.yml:ro"
16 - "./dynamic.yml:/etc/traefik/dynamic.yaml:ro"
17 labels:
18 - "traefik.enable=true"
19 - "traefik.http.routers.dashboard.rule=Host(`example.com`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api/overview`) || PathPrefix(`/api/version`) || PathPrefix(`/api/http`) || PathPrefix(`/api/entrypoints`) || PathPrefix(`/api/tcp`) || PathPrefix(`/api/udp`))"
20 - "traefik.http.routers.dashboard.entrypoints=websecure"
21 - "traefik.http.routers.dashboard.tls.certresolver=myresolver"
22 - "traefik.http.routers.dashboard.service=api@internal"
23 # # if you want to password protect your traefik dashboard uncomment the following and change the user:pass
24 #- "traefik.http.routers.dashboard.middlewares=auth"
25 #- "traefik.http.middlewares.auth.basicauth.users=" # to create user and pass: bash -c 'echo $(htpasswd -nB admin) | sed -e s/\\$/\\$\\$/g'
26
27networks:
28 pub:
29 external: true
Create the pub
network first using the command docker network create pub
Now we can start the traefik instance: docker compose up -d
HeadScale
Let’s make a separate folder for headscale: cd .. && mkdir headscale
1. 2 empty folders: mkdir data && mkdir tailscale-client
2. The headscale config: nano config.yml
1---
2# The url clients will connect to.
3server_url: https://example.com
4
5# Address to listen to / bind to on the server
6listen_addr: 0.0.0.0:8080
7
8# Address to listen to /metrics, you may want
9metrics_listen_addr: 127.0.0.1:9090
10
11# Address to listen for gRPC.
12grpc_listen_addr: 127.0.0.1:50443
13
14# Allow the gRPC admin interface to run in INSECURE mode.
15grpc_allow_insecure: false
16
17noise:
18 # The Noise private key is used to encrypt the traffic
19 private_key_path: /var/lib/headscale/noise_private.key
20
21# List of IP prefixes to allocate tailaddresses from.
22# Each prefix consists of either an IPv4 or IPv6 address,
23prefixes:
24 v6: fd7a:115c:a1e0::/48
25 v4: 100.64.0.0/10
26 # Strategy used for allocation of IPs to nodes, available options:
27 # - sequential (default): assigns the next free IP from the previous given IP.
28 # - random: assigns the next free IP from a pseudo-random IP generator (crypto/rand).
29 allocation: sequential
30
31# DERP is a relay system that Tailscale uses when a direct connection cannot be established.
32derp:
33 server:
34 enabled: false
35 # Region ID to use for the embedded DERP server.
36 region_id: 999
37 region_code: "headscale"
38 region_name: "Headscale Embedded DERP"
39 # Listens over UDP at the configured address for STUN connections - to help with NAT traversal.
40 stun_listen_addr: "0.0.0.0:3478"
41 # Private key used to encrypt the traffic between headscale DERP and Tailscale clients.
42 private_key_path: /var/lib/headscale/derp_server_private.key
43 # This flag can be used, so the DERP map entry for the embedded DERP server is not written automatically,
44 automatically_add_embedded_derp_region: true
45 # For better connection stability (especially when using an Exit-Node and DNS is not working),
46 ipv4: 1.2.3.4
47 ipv6: 2001:db8::1
48 # List of externally available DERP maps encoded in JSON
49 urls:
50 - https://controlplane.tailscale.com/derpmap/default
51 # Locally available DERP map files encoded in YAML
52 paths: []
53 # If enabled, a worker will be set up to periodically refresh the given sources and update the derpmap
54 auto_update_enabled: true
55 # How often should we check for DERP updates?
56 update_frequency: 24h
57# Disables the automatic check for headscale updates on startup
58disable_check_updates: false
59# Time before an inactive ephemeral node is deleted?
60ephemeral_node_inactivity_timeout: 30m
61database:
62 type: sqlite
63 # Enable debug mode. This setting requires the log.level to be set to "debug" or "trace".
64 debug: false
65 gorm:
66 prepare_stmt: true
67 parameterized_queries: true
68 skip_err_record_not_found: true
69 slow_threshold: 1000
70 sqlite:
71 path: /var/lib/headscale/db.sqlite
72 write_ahead_log: true
73
74 ### TLS configuration Let's encrypt / ACME
75acme_url: https://acme-v02.api.letsencrypt.org/directory
76acme_email: "your-email-goes-here@tutorial.example"
77tls_letsencrypt_hostname: ""
78# Path to store certificates and metadata needed by letsencrypt
79tls_letsencrypt_cache_dir: /var/lib/headscale/cache
80
81# Type of ACME challenge to use, currently supported types: HTTP-01 or TLS-ALPN-01
82tls_letsencrypt_challenge_type: HTTP-01
83# When HTTP-01 challenge is chosen, letsencrypt must set up a verification endpoint, and it will be listening on:
84tls_letsencrypt_listen: ":http" # :http = port 80
85tls_cert_path: ""
86tls_key_path: ""
87
88log:
89 format: text # text or json
90 level: info
91
92policy:
93 mode: file
94 path: ""
95
96dns:
97 magic_dns: true
98 # Defines the base domain to create the hostnames for MagicDNS.
99 base_domain: head.local
100 nameservers:
101 global:
102 - 1.1.1.1
103 - 1.0.0.1
104 - 2606:4700:4700::1111
105 - 2606:4700:4700::1001
106 split: {}
107 search_domains: []
108 # Extra DNS records
109 extra_records: []
110
111unix_socket: /var/run/headscale/headscale.sock
112unix_socket_permission: "0770"
113logtail:
114 # Enable logtail for this headscales clients.
115 enabled: false
116# Enabling this option makes devices prefer a random port for WireGuard traffic over the default static port 41641.
117randomize_client_port: false
It’s the default config, I removed some comments and replaced
server_url
,acme_email
and thebase_domain
from dns section. This last config is the Magic DNS that headscale uses to create a DNS entry for every device you connect to your tailscale vpn.
3. The docker compose config: nano compose.yml
1services:
2
3 headscale:
4 container_name: headscale
5 image: headscale/headscale
6 command: serve
7 restart: always
8 ports:
9 - "8080:8080"
10 networks:
11 - pub
12 volumes:
13 - "./data:/var/lib/headscale"
14 - "./config.yml:/etc/headscale/config.yaml"
15 cap_add:
16 - NET_ADMIN
17 - SYS_MODULE
18 sysctls:
19 - net.ipv4.ip_forward=1
20 - net.ipv4.conf.all.src_valid_mark=1
21 labels:
22 - "traefik.enable=true"
23 - "traefik.http.routers.headscale.rule=Host(`example.com`)"
24 - "traefik.http.routers.headscale.entrypoints=websecure"
25 - "traefik.http.routers.headscale.tls.certresolver=myresolver"
26 - "traefik.http.services.headscale.loadbalancer.server.port=8080"
27
28 headplane:
29 container_name: headplane
30 image: ghcr.io/tale/headplane:0.3.2
31 restart: unless-stopped
32 ports:
33 - '3000:3000'
34 networks:
35 - pub
36 depends_on:
37 - headscale
38 volumes:
39 - "./config.yml:/etc/headscale/config.yaml"
40 environment:
41 HEADSCALE_URL: 'http://headscale:8080'
42 API_KEY: ""
43 CONFIG_FILE: "/etc/headscale/config.yaml"
44 COOKIE_SECRET: 'put-some-random-string-here-32j564b64kl6n34lk'
45 DISABLE_API_KEY_LOGIN: 'true'
46 COOKIE_SECURE: 'false'
47 HOST: '0.0.0.0'
48 PORT: '3000'
49 labels:
50 - "traefik.enable=true"
51 - "traefik.http.routers.headplane.rule=Host(`example.com`) && PathPrefix(`/admin`)"
52 - "traefik.http.routers.headplane.entrypoints=websecure"
53 - "traefik.http.routers.headplane.tls.certresolver=myresolver"
54 - "traefik.http.services.headplane.loadbalancer.server.port=3000"
55 #- "traefik.http.routers.headplane.middlewares=auth" # uncomment this only if you generated a password in traefik step 4
56
57 # client:
58 # container_name: tailscale-client
59 # image: tailscale/tailscale
60 # restart: unless-stopped
61 # environment:
62 # - "TS_AUTHKEY="
63 # - "TS_STATE_DIR=/var/lib/tailscale"
64 # - "TS_USERSPACE=false"
65 # - "TS_EXTRA_ARGS=--login-server=https://example.com --advertise-exit-node --hostname=server-client --shields-up=false"
66 # networks:
67 # - pub
68 # volumes:
69 # - "./tailscale-client:/var/lib/tailscale"
70 # - "/dev/net/tun:/dev/net/tun"
71 # cap_add:
72 # - net_admin
73 # - sys_module
74
75networks:
76 pub:
77 external: true
We can start headscale now: docker compose up -d
Last modified on 2025-01-25