밖에서 집 NAS와 LAN에 붙을 때는 VPN을 쓴다. 지금까지는 NetBird를 NAS에 직접 띄워 쓰고 있었다. WireGuard 기반 메시 VPN이고, 제어 서버·대시보드·릴레이까지 모두 셀프호스팅할 수 있어서 골랐다. 연결 자체는 문제가 없었다. 지친 건 업데이트였다.

이 글은 NetBird를 내려놓고 headscale(Tailscale 제어 서버의 오픈소스 구현)로 옮긴 기록이다. 왜 옮겼는지, DERP 중계 서버를 집에 둔 이유, 집 LAN까지 여는 subnet router, Gitea 계정으로 로그인하게 한 과정을 정리했다.

NetBird를 내려놓은 이유: 너무 잦은 업데이트

NetBird는 개발이 활발하다. 좋은 일이지만 쓰는 쪽에서는 일이 된다. GitHub 릴리스를 세어 보니 2026년 1~9월에 나온 정식 릴리스(rc·canary 제외)가 72개였다. 한 달에 8개꼴이고, 7월 한 달에만 0.74.0부터 0.76.1까지 12개가 나왔다.

서버는 latest 태그라 Watchtower가 매일 새벽 알아서 올렸다. 문제는 클라이언트였다. 노트북, 휴대폰, NAS의 클라이언트 컨테이너까지 기기마다 따라 올려야 했고, 업데이트 알림을 넘기다 보면 서버만 앞서 나가 있었다. VPN에 바라는 건 "신경 쓰지 않아도 붙는 것" 하나인데, 그 VPN을 신경 쓰고 있었다.

Tailscale 쪽을 보면 사정이 다르다.

NetBird (셀프호스팅)headscale + Tailscale 앱
클라이언트NetBird 클라이언트, 2026년 1~9월 릴리스 72개Tailscale 공식 앱, 같은 기간 릴리스 17개
클라이언트 업데이트기기마다 직접앱스토어 · 자동 업데이트
서버 업데이트latest를 매일 따라감태그를 고정하고 직접 올림

headscale의 핵심은 클라이언트를 만들지 않는다는 점이다. 기기에는 Tailscale 공식 앱을 그대로 깔고 로그인 서버 주소만 내 서버로 바꾼다. 클라이언트 업데이트는 앱스토어가 알아서 하고, 내가 관리할 건 서버 하나로 줄어든다. 사실 지난 4월에 한 번 만들다 만 headscale 구성이 있었는데, 그건 _archive/로 치우고 처음부터 다시 만들었다.

구성: 제어 서버만 집에 둔다

Tailscale 계열 VPN은 역할이 둘로 나뉜다. 제어 서버는 어떤 기기가 있는지, 서로의 공개키와 주소가 무엇인지를 나눠 주기만 한다. 실제 트래픽은 기기끼리 WireGuard로 직접 주고받는다. NAT 때문에 직접 연결이 안 되면 DERP라는 중계 서버를 거친다. headscale은 이 중 제어 서버를 대신한다.

외부 기기의 Tailscale 앱이 Caddy를 거쳐 headscale 제어 서버·내장 DERP에 붙고, 실제 트래픽은 NAS의 tailscale 노드와 WireGuard로 직접 주고받아 집 LAN까지 들어가는 구조
컨테이너역할노출
headscale v0.29.4제어 서버 + 내장 DERPvpn.example.com, 3478/udp
Headplane 0.7.1웹 관리 UIhs.example.com
tailscale (stable)NAS 자신을 tailnet에 붙이고 LAN을 광고host 네트워크

(글에 나오는 VPN 관련 주소는 example.com으로 바꿔 적었다.) NetBird 때 겪은 일이 있어서 이번에는 버전을 고정했다. headscale은 마이너 버전마다 설정 키가 바뀌는 일이 잦다. 태그를 고정해 두면 Watchtower는 같은 태그의 재빌드만 받아 오고, 버전은 릴리스 노트를 읽은 다음 내가 올린다.

services:
  headscale:
    # 마이너 버전마다 설정 키가 바뀌므로 버전 고정 (Watchtower는 같은 태그만 확인)
    image: headscale/headscale:v0.29.4
    command: serve
    ports:
      - "8282:8080"          # Caddy vpn.example.com → 제어 서버 + 내장 DERP(HTTPS)
      - "3478:3478/udp"      # 내장 DERP STUN (공유기 3478/udp 포워딩)

  headplane:
    image: ghcr.io/tale/headplane:0.7.1
    ports:
      - "3301:3000"          # Caddy hs.example.com

리버스 프록시는 한 줄

리버스 프록시는 지난번에 옮긴 Caddy를 그대로 쓴다. NetBird는 gRPC(h2c)와 일반 HTTP를 경로마다 다른 업스트림으로 나눠야 해서 블록이 길었다.

@vpn host vpn.example.com
handle @vpn {
	@nb_grpc path /signalexchange.SignalExchange/* /management.ManagementService/*
	reverse_proxy @nb_grpc h2c://netbird-server:80

	@nb_http path /relay* /ws-proxy/* /api/* /oauth2/*
	reverse_proxy @nb_http netbird-server:80

	reverse_proxy 192.168.50.10:8082
}

headscale은 제어 채널(/ts2021)과 DERP(/derp)가 모두 HTTP 업그레이드 연결이라 평범한 리버스 프록시 한 줄로 끝난다. Caddy는 업그레이드 연결을 따로 설정하지 않아도 넘겨 준다.

# headscale 제어 서버 + 내장 DERP (/ts2021, /derp 업그레이드 연결 포함)
import p vpn            192.168.50.10:8282

DERP 중계 서버를 집에 하나 더

직접 연결이 안 될 때 기본 DERP는 Tailscale이 운영하는 공용 서버다. 한국에서 가장 가까운 곳이 도쿄라, 중계로 붙으면 트래픽이 도쿄를 한 바퀴 돌아온다. headscale에는 DERP 서버가 내장되어 있어서 집에 지역을 하나 더 만들었다. 공용 DERP 목록도 함께 받아 예비로 둔다.

derp:
  # 내장 DERP(집 NAS) + Tailscale 공용 DERP를 함께 쓴다. 클라이언트가 지연이 짧은 쪽을 고른다.
  server:
    enabled: true
    region_id: 999
    region_code: "sejong"
    region_name: "SEJONG-NAS"
    verify_clients: true              # 내 tailnet 기기만 중계
    stun_listen_addr: "0.0.0.0:3478"
    automatically_add_embedded_derp_region: true
  urls:
    - https://controlplane.tailscale.com/derpmap/default

NAS 노드에서 tailscale netcheck를 돌린 결과다(공인 IP는 뺐다).

Report:
	* UDP: true
	* MappingVariesByDestIP: true
	* Nearest DERP: SEJONG-NAS
	* DERP latency:
		- sejong: 900µs   (SEJONG-NAS)
		- tok: 44.7ms  (Tokyo)
		- sin: 80.4ms  (Singapore)
		- hkg: 89.5ms  (Hong Kong)
		...

눈에 띄는 줄이 하나 더 있다. MappingVariesByDestIP: true는 공유기 NAT가 목적지마다 다른 외부 포트를 쓴다는 뜻이다. 이런 NAT(endpoint-dependent mapping)에서는 STUN으로 알아낸 주소가 상대방에게는 맞지 않을 수 있어서, 양쪽 NAT 조합에 따라 홀 펀칭이 실패하고 DERP로 떨어질 수 있다. 직접 연결을 장담할 수 없는 환경이라 집 안 DERP가 더 의미 있다.

집 LAN까지: subnet router

VPN에 붙는 목적은 결국 집 LAN이다. NAS에 tailscale 컨테이너를 하나 띄워 NAS 자신을 tailnet에 넣고, LAN 대역을 subnet route로 광고하게 했다. 커널 라우팅을 써야 하니 host 네트워크와 /dev/net/tun이 필요하다.

services:
  tailscale:
    image: tailscale/tailscale:stable
    network_mode: host
    privileged: true
    devices:
      - /dev/net/tun
    environment:
      TS_ROUTES: 192.168.50.0/24       # 집 LAN 광고
      TS_ACCEPT_DNS: "false"           # NAS의 DNS 설정은 건드리지 않는다
      TS_EXTRA_ARGS: --login-server=https://vpn.example.com

광고한 경로는 headscale에서 승인해야 퍼진다. 그다음 휴대폰을 와이파이에서 떼고 모바일 데이터로 붙여 봤다. DSM 화면이 열렸고, 연결은 DERP를 거치지 않는 직접 연결(약 87ms)이었다.

NAS 쪽 로그에는 경고가 하나 남는다.

Warning: Extension connmark revision 0 not supported, missing kernel module?
iptables v1.8.11 (legacy): unknown option "--nfmask"

tailscale이 방화벽 규칙에 쓰려는 connmark 확장 모듈이 시놀로지 커널에 없어서 나는 경고다. 커널을 바꿀 수는 없으니, 외부망에서 LAN 접속이 실제로 되는지를 확인하는 쪽으로 판단했다. 위처럼 잘 되어서 무시해도 되는 경고로 정리했다.

로그인은 Gitea 계정으로

기기를 등록할 때마다 CLI로 승인하는 것도 귀찮은 일이다. NAS에서 이미 Gitea를 돌리고 있으니, Gitea를 OIDC 제공자로 붙였다. headscale과 Headplane이 같은 OAuth2 앱을 쓴다.

oidc:
  # Gitea가 늦게 떠도 VPN 제어 서버는 먼저 뜨게 한다 (그동안 OIDC 로그인만 실패)
  only_start_if_oidc_is_available: false
  issuer: "https://git.sejong.dev"
  client_id: "<Gitea OAuth2 앱 ID>"
  client_secret_path: "/etc/headscale/oidc_client_secret"
  scope: ["openid", "profile", "email"]
  pkce:
    enabled: true
    method: S256

두 가지를 신경 썼다.

  • 누가 VPN을 쓸 수 있는가. 허용 사용자 목록(allowed_*)은 비워 두었다. 대신 Gitea 회원가입이 꺼져 있어서 관리자만 계정을 만들 수 있다. Gitea 계정을 발급하는 것이 곧 VPN 권한을 주는 것이다.
  • Gitea가 죽으면 VPN도 죽는가. 기본값은 OIDC 제공자가 응답할 때까지 headscale이 뜨지 않는다. 그런데 Gitea도 같은 NAS에 있다. 밖에서 Gitea를 고쳐야 하는데 그 Gitea 때문에 VPN이 안 뜨면 곤란하다. 그래서 only_start_if_oidc_is_available: false로 풀었다. Gitea가 없는 동안에는 새 로그인만 안 되고, 이미 등록된 기기는 그대로 붙는다.

Headplane은 처음 SSO로 로그인한 사용자를 Owner로, 그다음부터는 UI 권한이 없는 member로 만든다. 혹시 몰라 headscale API 키 로그인도 비상용으로 남겨 두었다.

옮기면서 걸린 것들

  • Headplane 0.7은 꺼 둔 설정도 요구한다. Kubernetes 연동을 쓰지 않는데도 integration.kubernetes.pod_name 같은 항목이 없으면 뜨지 않았다. 예시 설정대로 값을 채우고 enabled: false로 두었다.
  • headscale 0.29 CLI가 바뀌었다. 검색해서 나오는 예전 글의 명령이 그대로 안 된다. --user는 이름이 아니라 ID를 받고, 기기 등록 승인은 auth register --auth-id로 한다.
  • MagicDNS가 기기 DNS를 덮지 않게 했다. override_local_dns: false로 두면 *.ts.internal 이름만 MagicDNS가 풀고, 나머지는 기기의 원래 DNS를 그대로 쓴다.

새 기기를 붙이는 순서는 이렇다. Gitea 로그인을 쓰면 2번은 생략된다.

# 1) 기기에서 로그인 서버 지정 (모바일은 앱 설정 → Use an alternate server)
tailscale login --login-server https://vpn.example.com

# 2) 화면에 나온 auth-id로 승인 (또는 Headplane에서)
docker exec headscale headscale auth register --auth-id <hskey-authreq-...> --user <사용자 ID>

정리

교체는 한 번에 했다. NetBird를 compose down하고 폴더를 _archive/로 옮긴 뒤 headscale을 띄웠다. 볼륨은 지우지 않았으니, 문제가 생기면 폴더를 되돌리고 Caddyfile의 vpn 블록만 git 이력에서 복원하면 된다. 남은 일은 NetBird에 붙어 있던 나머지 기기를 Tailscale 앱으로 옮기고, 며칠 지켜본 뒤 NetBird 볼륨을 정리하는 것이다.

바뀐 건 많지 않다. 여전히 집 서버가 제어 서버이고, 트래픽은 WireGuard로 오간다. 달라진 건 클라이언트를 내가 챙기지 않아도 된다는 것 하나다. VPN에 바라던 게 딱 그거였다.

최종 수정: 2026년 10월 5일