> ## Content Index
> Fetch the complete content index at: https://blog.sejong.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# NetBird에서 headscale로
- URL: https://blog.sejong.dev/netbird-to-headscale/
- Published: 2026-10-05T10:33:06.000Z
- Updated: 2026-10-05T10:45:41.000Z
- Description: 한 달에 8번 나오는 클라이언트 업데이트에 지쳐 NetBird를 내려놓았다. 제어 서버만 집에 두고 클라이언트는 Tailscale 공식 앱에 맡기는 headscale로 옮기면서, 집 DERP·subnet router·Gitea 로그인까지 붙인 기록.
- Author: Sejong Yoo
- Tags: 홈서버, VPN, headscale, Docker, 서버

밖에서 집 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까지 들어가는 구조](https://blog.sejong.dev/content/images/2026/10/headscale-diagram.svg)

| 컨테이너               | 역할                           | 노출                        |
| ------------------ | ---------------------------- | ------------------------- |
| headscale v0.29.4  | 제어 서버 + 내장 DERP              | vpn.example.com, 3478/udp |
| Headplane 0.7.1    | 웹 관리 UI                      | hs.example.com            |
| tailscale (stable) | NAS 자신을 tailnet에 붙이고 LAN을 광고 | host 네트워크                 |

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

```yaml
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](https://blog.sejong.dev/npmplus-to-caddy/)를 그대로 쓴다. NetBird는 gRPC(h2c)와 일반 HTTP를 경로마다 다른 업스트림으로 나눠야 해서 블록이 길었다.

```caddyfile
@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는 업그레이드 연결을 따로 설정하지 않아도 넘겨 준다.

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

## DERP 중계 서버를 집에 하나 더

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

```yaml
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`이 필요하다.

```yaml
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 앱을 쓴다.

```yaml
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번은 생략된다.

```bash
# 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에 바라던 게 딱 그거였다.