SSD Nodes Learn 8GB RAM — 연 $66
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-01

Headscale로 나만의 Tailscale 제어 서버 구축하기

VPS에서 Tailscale 제어 서버를 직접 운영하는 방법입니다. 공식 .deb로 headscale을 설치하고 server_url을 설정한 뒤 첫 노드를 연결합니다. 릴리스 0.29.3 기준으로 설명합니다.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

headscale이란

Headscale은 Tailscale 제어 서버를 자체 호스팅하는 구현입니다. 따라서 사설 네트워크를 조정하는 시스템은 사용자가 소유한 VPS입니다. Headscale은 커뮤니티 프로젝트이며 Tailscale Inc.가 운영하지 않습니다. 모든 시스템은 여전히 공식 tailscale 클라이언트를 실행하며, --login-server 플래그 하나로 사용자의 서버를 지정합니다.

제어 서버는 네트워크에 속한 구성원을 파악하는 부분입니다. 제어 서버는 각 노드에 100.64.0.0/10의 주소를 할당하고, 공개 키를 배포하며, 노드가 서로를 찾을 위치를 알려 줍니다. 터널은 노드 간에 구성되는 WireGuard 방식으로 유지됩니다. 두 시스템 간의 트래픽은 직접 경로를 구성할 수 없어 노드가 릴레이로 대체되는 경우를 제외하면 headscale 서버를 통과하지 않습니다.

Headscale은 인스턴스당 하나의 tailnet(하나의 Tailscale 네트워크)을 제공합니다. 프로젝트에서는 이를 개인용 또는 소규모 조직에 적합한 구성으로 설명합니다. 시스템이 3대 또는 4대라면 사용자가 소유한 VPS에서 일반 WireGuard VPN을 실행하는 방식이 실행할 소프트웨어가 적고 문제를 일으킬 요소도 적습니다. 새 노드를 추가할 때마다 [Peer] 블록을 직접 작성하고 싶지 않다면 Headscale이 유용합니다. 두 모델을 더 폭넓게 비교하려면 WireGuard와 Tailscale의 차이를 참조하십시오.

설치 전에 필요한 항목

  • 공인 IPv4 주소와 sudo 액세스 권한이 있는 Ubuntu 24.04 실행 VPS. 서버가 새 서버라면 먼저 새 VPS에서 처음 10분 동안 수행할 작업을 진행합니다.
  • 해당 주소를 가리키는 DNS A 레코드. 이 가이드에서는 headscale.example.com를 사용합니다.
  • MagicDNS용 두 번째 도메인 또는 하위 도메인. 이 가이드에서는 tailnet.example.net을 사용합니다. server_url에 있는 도메인과 동일한 도메인이어서는 안 됩니다.
  • 연결할 클라이언트 시스템 1대. Linux, macOS, Windows, Android 또는 iOS를 실행해야 합니다.

공식 .deb에서 headscale 설치

프로젝트는 GitHub 릴리스 페이지에서 .deb 패키지를 배포합니다. 2026년 7월 기준 현재 릴리스는 0.29.3입니다. 파일 이름에 아키텍처가 포함되므로 먼저 아키텍처를 확인합니다.

sudo apt update
sudo apt install -y wget
dpkg --print-architecture

일반적인 x86 VPS에서는 amd64이 출력되고, Ampere 또는 Graviton 계열 플랜에서는 arm64가 출력됩니다. 아래 변수에 결과를 저장합니다.

HEADSCALE_VERSION="0.29.3"
HEADSCALE_ARCH="amd64"
wget --output-document=headscale.deb \\
  "https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_${HEADSCALE_ARCH}.deb"
sudo apt install -y ./headscale.deb
headscale version

파일 이름 앞의 ./가 필요합니다. 이 옵션이 없으면 apt가 저장소에서 headscale.deb라는 패키지를 찾다가 실패합니다.

이 패키지는 headscale system user를 생성하고, 기본 /etc/headscale/config.yaml를 작성하며, systemd unit을 설치합니다. 서비스는 시작하지 않습니다. 이 순서가 올바릅니다. 설치된 설정은 server_urlhttp://127.0.0.1:8080에 연결합니다. 이 주소는 사용자의 어떤 클라이언트에서도 연결할 수 없습니다. 따라서 지금 서비스를 시작하면 서비스가 실행되더라도 설정이 잘못됩니다. 이 시점에 sudo systemctl is-active headscale를 실행하면 inactive이 출력됩니다. 이는 예상된 결과이며 오류가 아닙니다.

서비스를 시작하기 전에 server_url을 구성합니다

/etc/headscale/config.yamlsudo nano /etc/headscale/config.yaml으로 편집하거나, sed으로 동일한 세 가지 변경 사항을 적용합니다. 원본 사본을 보관합니다. 파일이 길고 주석이 많기 때문에 나머지 설정을 확인할 때 가장 유용한 참고 자료입니다.

sudo cp /etc/headscale/config.yaml /etc/headscale/config.yaml.orig
sudo sed -i 's|^server_url:.*|server_url: https://headscale.example.com|' /etc/headscale/config.yaml
sudo sed -i 's|^listen_addr:.*|listen_addr: 127.0.0.1:8080|' /etc/headscale/config.yaml
sudo sed -i 's|^  base_domain:.*|  base_domain: tailnet.example.net|' /etc/headscale/config.yaml
sudo grep -E '^(server_url|listen_addr):|^  base_domain:' /etc/headscale/config.yaml

server_url은 headscale이 모든 클라이언트 등록 정보에 기록하는 주소입니다. 클라이언트는 그 정확한 문자열로 계속 연결하므로, 앞에 https://이 붙은 공개 이름이어야 하며 127.0.0.1이어서는 안 됩니다.

listen_addr은 프로세스가 바인딩되는 주소입니다. loopback으로 유지합니다. 동일한 서버의 reverse proxy가 TLS(transport layer security)를 종료한 후 이 주소로 전달하므로, 서버 외부에서 port 8080에 접근할 필요가 없습니다.

base_domain은 MagicDNS 접미사이며, 노드 이름이 이 도메인 아래에서 부여됩니다. 끝에 마침표가 없는 fully qualified domain name이어야 합니다. 또한 server_url에 지정된 도메인과 달라야 합니다. 그렇지 않으면 두 이름 공간이 충돌합니다.

database 섹션은 변경하지 않습니다. 기본값은 /var/lib/headscale/db.sqlite의 SQLite입니다. 이 경로는 패키지가 생성하고 소유하는 디렉터리에 있으며, 이 정도 규모의 tailnet에는 SQLite로 충분합니다.

headscale을 시작하고 실행 중인지 확인합니다

sudo systemctl enable --now headscale
sudo systemctl is-active headscale
curl -sS -o /dev/null -w '%{http_code}\\n' http://127.0.0.1:8080/health

is-activeactive를 출력하고, curl200를 출력합니다. enable --now는 두 작업을 모두 수행합니다. 서비스를 시작하고 재부팅 후에도 시작되도록 설정합니다.

is-activefailed를 출력하면 sudo journalctl -u headscale -n 50 --no-pager로 journal을 읽습니다. 이 단계의 실패 원인은 거의 항상 구성 파일입니다. headscale은 socket을 열기 전에 파일 전체를 구문 분석하므로 잘못된 들여쓰기나 알 수 없는 key가 있으면 어떤 것도 listening 상태가 되기 전에 프로세스가 중지됩니다. 파일을 수정한 다음 sudo systemctl restart headscale를 실행합니다. 이후 구성 변경에도 동일한 restart가 필요합니다. 그러면 client는 자동으로 다시 연결합니다. systemd unit이 익숙하지 않다면 systemd로 직접 service와 timer를 실행하는 방법에서 여기서 사용하는 명령을 설명합니다.

셸에 있는 동안 상태 파일을 확인합니다.

stat -c '%U %n' /var/lib/headscale/db.sqlite /var/lib/headscale/noise_private.key

두 줄 모두 패키지가 생성한 권한이 없는 user인 headscale로 시작합니다. noise_private.key는 client에 대한 server의 identity입니다. 이 파일을 유지합니다. 삭제하면 headscale이 새 identity를 생성하므로 모든 node를 다시 등록해야 합니다.

headscale 앞에 TLS 배치

클라이언트는 HTTPS를 통해 server_url에 연결해야 합니다. Caddy는 인증서를 직접 요청하고 갱신하므로 가장 간단한 방법입니다.

sudo apt install -y caddy

/etc/caddy/Caddyfile을 headscale 문서의 블록으로 바꿉니다.

headscale.example.com {
    reverse_proxy 127.0.0.1:8080 {
        header_up True-Client-IP {remote_host}
        header_up X-Real-IP {remote_host}
    }
}
sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
sudo systemctl is-active caddy

파일 구문 분석이 성공하면 validateadapted config to JSON이 출력됩니다. 파일 형식이 지정되지 않았다는 경고는 외관상의 문제입니다. 노트북에서 curl -sS -o /dev/null -w '%{http_code}\\n' https://headscale.example.com/health200을 출력해야 합니다. 이 한 번의 확인으로 DNS, 방화벽, 인증서 및 프록시가 모두 함께 작동하는지 검증할 수 있습니다.

다음은 문제를 해결하는 데 저녁 시간을 허비하게 만드는 프록시 세부 사항입니다. Tailscale 제어 연결은 HTTP 업그레이드이며, GET이 아니라 POST로 시작됩니다. 또한 Upgrade 헤더의 값은 tailscale-control-protocol입니다. Caddy는 추가 구성 없이 이를 전달합니다. nginx는 그렇지 않으므로 nginx 프런트 엔드에는 다음 업그레이드 맵이 필요합니다.

map $http_upgrade $connection_upgrade {
    default keep-alive;
    ''      close;
}

server {
    listen 443 ssl;
    server_name headscale.example.com;
    location / {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_buffering off;
        proxy_pass http://127.0.0.1:8080;
    }
}

이 줄을 생략해도 일반 요청은 계속 성공합니다. 따라서 /health은 200을 반환하고 모든 것이 정상인 것처럼 보입니다. 그러나 장시간 유지되는 제어 연결은 설정되지 않으므로 노드가 등록된 후 오프라인 상태로 남습니다. nginx를 사용한다면 Ubuntu 24.04에서 nginx와 함께 사용하는 Certbot에서 인증서 설정 부분을 다룹니다.

UFW에서 열어야 하는 포트

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

포트 443은 모든 클라이언트 통신을 전달합니다. 포트 80은 ACME(자동 인증서 관리 환경) HTTP challenge와 HTTPS로의 redirect에만 사용됩니다. 또한 Caddy가 인증서를 받으려면 포트 80이 필요합니다.

포트 8080은 닫힌 상태로 유지합니다. listen_addr127.0.0.1:8080이므로 proxy는 loopback interface를 통해 headscale에 연결하며, 방화벽 규칙이 필요하지 않습니다. 인터넷에 포트 8080을 개방하면 클라이언트에 평문 control channel을 제공할 뿐이며, 얻는 이점은 없습니다. 대부분의 provider는 UFW와 별도로 control panel에서 두 번째 방화벽을 운영한다는 점에 유의합니다. 따라서 서버에서는 포트가 열려 있어도 edge에서는 닫혀 있을 수 있습니다. VPS에서 UFW 방화벽 기초에서는 규칙 구문을 더 자세히 설명합니다.

사용자 및 preauth 키 생성

sudo headscale users create alice
sudo headscale users list

headscale 명령은 클라이언트입니다. 실행 중인 daemon과 /var/run/headscale/headscale.sock의 unix socket을 통해 통신합니다. 이 socket의 모드는 0770이며 headscale group이 소유합니다. 여기서 두 가지 사항을 알 수 있습니다. service가 중지되어 있으면 명령이 실패합니다. 이것이 이 가이드에서 순서가 중요한 또 다른 이유입니다. 또한 사용자 계정을 headscale group에 추가하지 않았다면 sudo가 필요합니다.

users list은 각 이름 옆에 ID를 출력합니다. key 명령은 이름이 아니라 숫자 user ID를 사용하므로 이 번호가 필요합니다.

sudo headscale preauthkeys create --user 1 --expiration 24h

key는 한 번만 출력됩니다. 지금 복사하십시오. preauth key는 1회만 사용할 수 있으며 별도로 지정하지 않으면 1시간 동안 유효합니다. 따라서 아직 테스트 중이라면 --expiration 24h를 설정하는 것이 좋습니다. 여러 machine을 등록하는 key에는 --reusable를 추가하십시오. 이 key를 보유한 사람은 누구나 네트워크에 참여할 수 있으므로 password와 같이 취급해야 합니다.

--login-server로 첫 번째 클라이언트 연결

가입하려는 컴퓨터에서 다음을 실행합니다.

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://headscale.example.com --auth-key 'hskey-auth-PASTE-YOUR-KEY-HERE'
tailscale status
tailscale ip -4

tailscale ip -4는 headscale이 할당한 주소를 출력합니다. 출력 예시는 100.64.0.1와 같습니다. 서버에서 sudo headscale nodes list을 실행하면 ID, 사용자 및 온라인 상태와 함께 노드가 표시됩니다.

--login-server의 값은 스킴을 포함하고 끝에 슬래시를 붙이지 않은 상태로 server_url과 정확히 일치해야 합니다. 두 값은 문자열로 비교됩니다. 값이 일치하지 않으면 클라이언트가 한 주소에 등록된 후 다른 주소와 통신하라는 지시를 받게 됩니다.

이전에 Tailscale의 호스팅 서비스에 로그인한 적이 있는 컴퓨터는 해당 로그인을 유지합니다. 먼저 해당 컴퓨터에서 sudo tailscale logout를 실행한 다음, --login-server과 함께 tailscale up을 실행합니다.

--auth-key를 생략하면 클라이언트가 대신 URL을 출력합니다. 해당 URL을 열면 등록 시도의 식별자가 페이지에 표시됩니다. 서버에서 이 식별자를 승인합니다.

sudo headscale auth register --user alice --auth-id PASTE-THE-ID-FROM-THE-PAGE

이 방식은 개인 노트북에 더 편리합니다. 사람이 지켜보지 않아도 되므로 스크립트로 처리하는 경우에는 사전 인증 키가 더 적합합니다.

DERP와 직접 경로가 실패할 때 트래픽을 중계하는 방식

DERP(designated encrypted relay for packets)는 대체 경로입니다. 두 노드가 직접 WireGuard 연결을 열 수 없을 때는 대신 릴레이를 통해 패킷을 전송합니다. 일반적으로 두 노드가 모두 엄격한 NAT(network address translation) 뒤에 있을 때 발생합니다. 릴레이에는 키가 없으므로 트래픽을 읽을 수 없습니다. 그러나 어떤 노드가 통신하는지와 데이터가 얼마나 전송되는지는 확인할 수 있습니다.

기본 구성이 수행하는 작업을 정확히 이해해야 합니다. Headscale는 https://controlplane.tailscale.com/derpmap/default을 가리키고 auto_update_enabled: trueupdate_frequency: 3h를 사용하도록 제공되므로, 제어 플레인은 사용자가 관리하고 릴레이는 Tailscale이 관리합니다. 대부분의 사용자에게 이는 합리적인 절충입니다. 그렇지 않다면 직접 운영합니다.

자체 릴레이를 운영하려면 config.yamlderp.server 아래에 enabled: true을 설정하고 headscale를 다시 시작한 다음, sudo ufw allow 3478/udp로 STUN(session traversal utilities for NAT) 포트를 엽니다. 구성 파일에는 이 요구 사항이 명확히 명시되어 있습니다. server_url은 https를 사용해야 합니다. DERP에는 TLS가 필요하기 때문입니다. derp.urls 목록을 비우면 맵에서 Tailscale의 릴레이가 제거됩니다. 작동하는 내장 릴레이 없이 이렇게 설정하면 직접 연결할 수 없는 모든 노드 쌍은 어떤 방식으로도 연결할 수 없습니다.

클라이언트에서 tailscale netcheck를 실행하면 알고 있는 각 릴레이 리전까지의 지연 시간이 출력됩니다. tailscale status은 모든 피어를 주소가 있는 direct 또는 리전 코드가 있는 relay로 표시합니다. relay 상태에 머무는 피어는 headscale 문제가 아니라 NAT 문제입니다.

노드가 오프라인으로 표시되는 이유

프록시가 업그레이드 요청을 삭제하고 있습니다. 가장 일반적인 원인입니다. 다른 항목은 모두 정상적으로 보입니다. /health은 200을 반환하고, headscale nodes list에는 노드가 표시되지만, 노드는 온라인 상태가 되지 않습니다. 제어 연결은 Upgrade: tailscale-control-protocol를 포함하는 POST 요청입니다. 이 요청을 전달하지 않는 프록시는 노드 상태를 보고하는 유일한 채널을 차단합니다. 위의 map 블록과 nginx 구성을 비교하거나, Caddy로 전환하여 프록시가 원인인지 확인합니다.

노드가 등록된 후 server_url이 변경되었습니다. 노드는 등록할 때 전달받은 값을 사용하여 계속 연결을 시도합니다. 해당 값을 수정했다면 각 노드에서 sudo tailscale up --login-server https://headscale.example.com --force-reauth을 실행합니다.

클라이언트가 실행 중이 아닙니다. 노드에서 sudo systemctl is-active tailscaledsudo journalctl -u tailscaled -n 50 --no-pager을 실행합니다. 도메인 이름을 확인하거나 해당 도메인에 연결할 수 없는 클라이언트는 그 위치에 재시도 로그를 기록합니다.

키가 만료되었습니다. 다음 섹션에서 설명합니다.

테스트하는 동안 서버 측을 모니터링하려면 VPS에서 sudo journalctl -u headscale -f를 실행하고 클라이언트에서 tailscaled를 다시 시작합니다. headscale에 도달한 노드는 즉시 로그 행을 생성합니다. 아무 출력도 없으면 요청이 도착하지 않은 것입니다. 따라서 headscale을 확인하기 전에 DNS, 방화벽 및 프록시를 확인합니다.

키 만료와 몇 주 후 작동을 멈추는 노드

서로 별개의 만료가 2가지 있으며, 이를 혼동하면 문제 해결에 시간이 낭비됩니다.

Preauth 키는 설계상 빠르게 만료됩니다. 기본값은 1시간 후 만료되고 1회만 사용할 수 있습니다. tailscale up에서 키를 거부하면 클라이언트에서 무엇이든 편집하지 말고 서버에서 새 키를 생성합니다.

노드 키는 장기간 유효한 부분입니다. config.yamlnode 섹션에서 expiry: 0를 설정합니다. 0은 기본 만료가 없다는 의미입니다. 등록된 노드는 만료 처리할 때까지 유효합니다. 태그가 지정된 노드는 어떤 경우에도 만료되지 않습니다. 등록이 일정 기간 후 만료되도록 하려면 expiry: 180d을 설정합니다. 다만 그 의미를 이해해야 합니다. 이후 태그가 지정되지 않은 모든 노드는 해당 주기에 따라 sudo tailscale up --login-server https://headscale.example.com --force-reauth이 필요합니다. 아무도 다시 인증하지 않는 headless 서버는 자체적으로 네트워크에서 이탈합니다.

누군가 노트북을 잃어버렸다면 수동으로 처리합니다. sudo headscale nodes list에서 ID를 확인한 다음 sudo headscale nodes expire -i 3으로 해당 노드를 로그아웃하고, sudo headscale nodes delete -i 3으로 네트워크에서 완전히 제거합니다.

백업 및 업그레이드

/var/lib/headscale/etc/headscale이(가) 함께 전체 서버를 구성합니다. 복사하기 전에 서비스를 중지해야 합니다. SQLite에 처리 중인 쓰기가 있을 수 있으며, 부하가 걸린 상태에서 복사한 데이터베이스는 일관성이 없을 수 있습니다.

sudo systemctl stop headscale
sudo tar czf /root/headscale-state.tgz -C /var/lib headscale
sudo tar czf /root/headscale-config.tgz -C /etc headscale
sudo systemctl start headscale
sudo chmod 600 /root/headscale-*.tgz

두 파일을 서버 외부로 이동합니다. 두 파일에는 private key와 모든 registration 정보가 포함되어 있으므로 서버 자체와 동일한 수준으로 보호해야 합니다. VPS에서 restic 백업에서는 이를 일정에 따라 암호화하여 수행하는 방법을 설명합니다.

업그레이드는 설치 과정을 반복합니다. 새 .debsudo apt install ./headscale.deb을(를) 다운로드한 다음 서비스를 다시 시작하고 is-active/health 검사를 다시 실행합니다. 0.29부터 업그레이드 경로가 엄격해졌습니다. minor version을 건너뛸 수 없으며, 이전 minor version으로 downgrade할 수도 없습니다. 한 번에 하나의 minor version만 이동하고, 각 단계 전에 백업을 수행하며, 먼저 해당 version의 release notes를 읽어야 합니다. 동일한 release에서 ACL policy 동작이 변경되고 여러 configuration key가 이동했기 때문입니다.

FAQ

.deb를 설치한 직후 headscale가 시작되지 않는 이유는 무엇입니까?

패키지는 unit를 설치하지만 서비스를 중지된 상태로 둡니다. 기본 /etc/headscale/config.yaml은 작동하는 설정이 아니라 템플릿입니다. 먼저 server_url, listen_addrbase_domain를 편집한 다음 sudo systemctl enable --now headscale을 실행하고 sudo systemctl is-active headscale로 확인합니다. 그래도 실패하면 sudo journalctl -u headscale -n 50 --no-pager에서 문제를 확인할 수 있습니다. 이 단계에서는 headscale가 포트에 바인딩하기 전에 전체 파일을 구문 분석하므로 거의 항상 YAML 오류입니다.

컴퓨터에 일반 Tailscale client도 설치해야 합니까?

그렇습니다. Headscale는 control server만 대체합니다. 각 node에서는 Tailscale의 공식 client가 실행되며, sudo tailscale up --login-server https://headscale.example.com으로 해당 client가 server를 사용하도록 지정합니다. 이 flag는 표준 client에 포함되어 있으므로 patch하거나 다시 빌드할 필요가 없습니다.

네트워크 traffic이 headscale server를 통과합니까?

일반적으로 그렇지 않습니다. Headscale는 네트워크를 조정하고 key와 address를 배포하며, data path는 node 간의 WireGuard 직접 연결입니다. 두 node가 서로 직접 연결되지 않아 DERP relay로 fallback하는 경우에만 traffic이 우회합니다. 제공된 configuration에서는 해당 relay가 Tailscale의 public relay입니다. node에서 tailscale status을 실행하면 특정 peer가 direct인지 또는 relay에 있는지 확인할 수 있습니다.

node가 등록된 후에도 offline 상태로 남는 이유는 무엇입니까?

headscale nodes list에 node가 표시되지만 online 상태가 되지 않는다면, 대개 reverse proxy에서 control connection이 끊긴 것입니다. 이 connection은 Upgrade: tailscale-control-protocol header가 포함된 POST로 전송되는 HTTP upgrade입니다. nginx는 map $http_upgrade $connection_upgrade block과 이에 대응하는 proxy_set_header lines를 추가하지 않으면 이 요청을 삭제합니다. Caddy는 추가 configuration 없이 이를 forward하므로 proxy가 원인인지 빠르게 확인할 수 있습니다.

headscale에 domain name과 TLS가 필요합니까?

실제로는 필요합니다. Client는 server_url에 입력한 문자열로 연결합니다. Certificate는 name에 발급되며 bare IP address에는 발급되지 않습니다. 또한 configuration file에는 DERP에 TLS가 필요하다고 명시되어 있습니다. Domain과 Caddy를 함께 사용하면 약 5분이 걸리며 자동으로 갱신되는 HTTPS endpoint를 제공합니다. Control server를 plain HTTP로 실행하면 모든 client 통신이 암호화되지 않은 상태로 인터넷을 통과합니다.