# คู่มือทำ Site-to-Site VPN ด้วย Headscale แบบ step-by-step

Table of Contents

ภาพรวมก่อนเริ่ม

เป้าหมายของคู่มือนี้คือเชื่อม subnet ของหลาย site เข้า mesh เดียวกัน โดย traffic ระหว่าง site วิ่งเป็น WireGuard P2P ตรงถึงกัน ไม่ผ่าน gateway กลาง

Headscale Site-to-Site Architecture

ในคู่มือนี้ผมจะใช้ค่าตัวอย่างดังนี้ (เปลี่ยนเป็นของคุณได้เลย):

ส่วนประกอบค่าตัวอย่าง
Headscale control serverheadscale.example.com
Site A (สำนักงาน) subnet192.168.10.0/24
Site B (cloud VPC) subnet10.0.0.0/16
OS ของ subnet routerLinux (Debian/Ubuntu)

ขั้นที่ 1 — Deploy Headscale Control Server

วาง Headscale ด้วย Docker บน server ที่มี public IP แล้วเอา nginx มา terminate TLS ข้างหน้า

docker-compose.yml
services:
headscale:
image: headscale/headscale:latest
command: serve
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
ports:
- '127.0.0.1:8080:8080'
restart: unless-stopped

ในไฟล์ config หลัก ตั้ง server_url ให้ตรงกับ domain ที่จะใช้:

config/config.yaml (ส่วนสำคัญ)
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
prefixes:
v4: 100.64.0.0/10 # IP range ของ Tailnet
dns:
base_domain: example-net.internal

ฝั่ง nginx reverse proxy เข้า 127.0.0.1:8080 — สิ่งสำคัญคืออย่าตั้ง timeout สั้นเกินไป เพราะ Headscale ใช้ long-lived connection สำหรับ control channel:

nginx — headscale.example.com
server {
listen 443 ssl;
server_name headscale.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $host;
proxy_read_timeout 86400s; # กัน control channel หลุด
}
}

ยิง compose ขึ้นแล้วเช็คว่า server ตอบ:

start + healthcheck
docker compose up -d
curl -fsSL https://headscale.example.com/health # ควรได้ 200

ขั้นที่ 2 — สร้าง User และ Pre-auth Key

Headscale จัดกลุ่ม node ด้วย user (เดิมเรียก namespace) สร้าง user แล้วออก pre-auth key สำหรับ enroll subnet router แต่ละตัว

รันบน server (ใน container)
headscale users create corp
headscale preauthkeys create --user corp --reusable --expiration 1h

เก็บค่า key ที่ได้ (hskey-...) ไว้ใช้ในขั้นถัดไป

ขั้นที่ 3 — ตั้ง Subnet Router ที่ Site A

ไปที่เครื่อง subnet router ของ Site A ติดตั้ง Tailscale client ตามปกติ:

ติดตั้ง tailscale client
curl -fsSL https://tailscale.com/install.sh | sh

จากนั้น เปิด IP forwarding ในระดับ kernel — ขั้นนี้ห้ามลืม ไม่งั้น tunnel ติดแต่เครื่องหลัง router เข้าไม่ถึงกัน:

เปิด IP forwarding ให้ถาวร
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

แล้ว up โดยชี้ไปที่ Headscale ของเรา พร้อม advertise subnet ของ Site A:

enroll Site A พร้อม advertise subnet
sudo tailscale up \
--login-server https://headscale.example.com \
--authkey hskey-xxxxxxxxxxxx \
--advertise-routes=192.168.10.0/24 \
--accept-routes

ขั้นที่ 4 — ตั้ง Subnet Router ที่ Site B

ทำแบบเดียวกันที่เครื่อง subnet router ของ Site B (cloud VPC) — ติดตั้ง client, เปิด IP forwarding เหมือนขั้นที่ 3 แล้ว up โดย advertise subnet ของ Site B แทน:

enroll Site B พร้อม advertise subnet
sudo tailscale up \
--login-server https://headscale.example.com \
--authkey hskey-xxxxxxxxxxxx \
--advertise-routes=10.0.0.0/16 \
--accept-routes

ขั้นที่ 5 — Approve Routes บน Headscale

ตอนนี้ subnet router ทั้งสอง advertise route เข้ามาแล้ว แต่ Headscale ยังไม่เปิดใช้จนกว่าเราจะ approve — ดู route ที่ค้างอยู่:

ดู route ที่ถูก advertise เข้ามา
headscale nodes list
headscale routes list

จะเห็น route ของแต่ละ site พร้อม id แล้ว enable ทีละอัน:

approve route ทั้งสอง site
headscale routes enable -r 1 # 192.168.10.0/24 (Site A)
headscale routes enable -r 2 # 10.0.0.0/16 (Site B)

ขั้นที่ 6 — เขียน ACL as-code

มาถึงจุดที่ผมชอบที่สุด — กำหนดว่า site ไหนคุยกับ site ไหนได้ ผ่านไฟล์ ACL (HuJSON) ที่เก็บใน git ได้

สมมติเราต้องการให้ สอง site คุยกันได้สองทาง ก็เขียนแบบนี้:

acl.hujson
{
"tagOwners": {
"tag:site-a": ["corp"],
"tag:site-b": ["corp"],
},
"acls": [
// Site A <-> Site B คุยกันได้ทั้งสองทาง
{
"action": "accept",
"src": ["tag:site-a", "tag:site-b"],
"dst": ["192.168.10.0/24:*", "10.0.0.0/16:*"],
},
],
}

ถ้าอยากได้ one-way (เช่นให้ Site A เข้า Site B ได้ แต่ไม่ให้ย้อนกลับ) ก็แยก src/dst ให้ชัด:

acl.hujson — one-way A เข้า B
"acls": [
{
"action": "accept",
"src": ["tag:site-a"],
"dst": ["10.0.0.0/16:*"],
},
]

โหลด policy เข้า Headscale แล้ว tag node ให้ตรง:

apply policy + tag node
headscale policy set -f acl.hujson
headscale nodes tag -i 1 -t tag:site-a
headscale nodes tag -i 2 -t tag:site-b

ขั้นที่ 7 — Verify ให้ครบทุกเส้น

อย่าเพิ่งวางใจว่าเสร็จ — ทดสอบจริงทีละเส้น จากเครื่อง ที่อยู่ใน LAN หลัง subnet router (ไม่ใช่ตัว router เอง) เพื่อพิสูจน์ว่า routing ทะลุถึงเครื่องปลายทางจริง

ทดสอบจาก host ใน Site A ไปยัง host ใน Site B
ping 10.0.0.5
curl http://10.0.0.5:8080/health
traceroute 10.0.0.5 # ดูว่า traffic วิ่งผ่าน tunnel จริง

แล้วเช็คฝั่ง tunnel ว่าต่อแบบ P2P หรือ relay:

ดูสถานะ peer
tailscale status
tailscale ping site-b # บอกว่า direct หรือ via relay

checklist สรุป

ก่อนปิดงาน ไล่เช็คให้ครบ:

  • Headscale ขึ้น + /health ตอบ 200, nginx timeout ยาวพอ
  • สร้าง user + pre-auth key แล้ว
  • subnet router ทุก site เปิด IP forwarding ถาวร
  • ทุก site up ด้วย --advertise-routes + --accept-routes
  • approve route ทุกอันบน Headscale แล้ว
  • ACL apply + tag node ตรง
  • ทดสอบจาก host ใน LAN (ไม่ใช่ตัว router) ผ่านทุกเส้น
  • tailscale ping ขึ้น direct (ถ้าได้)

สรุป

หัวใจของการทำ site-to-site VPN ด้วย Headscale มีแค่ไม่กี่จุด — advertise + approve + accept routes ให้ครบทั้งสองฝั่ง, เปิด IP forwarding บน subnet router, แล้วคุม access ด้วย ACL as-code จุดที่คนพลาดบ่อยที่สุดคือลืม approve route บน Headscale กับลืมเปิด IP forwarding — ถ้าจำสองอย่างนี้ได้ ที่เหลือก็ตรงไปตรงมาครับ

An open source, self-hosted implementation of the Tailscale control server
40.1K2.2KBSD-3-ClauseGo
My avatar

Thanks for reading! Feel free to check out my other posts or reach out via the links in the footer.


More Posts

# สร้าง Internal Portal ดู Observability หลาย K8s Cluster ด้วย Next.js + OTel

4 min read

high-level architecture ของ internal portal ที่รวม observability ของ Kubernetes หลาย cluster ไว้ที่เดียว — ทุก cluster push telemetry ผ่าน OpenTelemetry เข้า ClickHouse ส่วนกลาง แล้ว Next.js portal…

Read

# ผมใช้ Claude ตั้ง Site-to-Site VPN ด้วย Headscale ยังไง

4 min read

เล่า workflow จริงที่ผมใช้ Claude Code ช่วยตั้ง site-to-site VPN ด้วย Headscale — ตั้งแต่ grill design, generate docker-compose/nginx/ACL, รันคำสั่งทีละขั้น, debug, ไปจนถึงให้มันเขียน runbook เก็บลง…

Read

Comments