2026년 6월 29일
해당 글은 Claude를 기반으로 작성된 내용이 있습니다. 실제로 홈서버에 적용하면서 로직 및 구조에 대한 검증은 마쳤으나 틀린 부분이 있다면 [email protected]으로 제보 부탁 드립니다.
작년부터 아이패드로도 코딩을 해볼 겸 나스를 구축하기 위해 홈서버를 구축했다. 그런데 막상 외부에서 접근할 수 있게 열어두니, 요즘처럼 보안 사고가 잦은 세상에서는 걱정이 따라왔다. 그래서 Nextcloud, code-server처럼 민감한 일부 서비스는 아예 인터넷에 노출하지 않고 망을 분리하기로 했다. 다만 WireGuard나 OpenVPN을 처음부터 직접 구성하는 건 DDNS·서버 설정까지 손이 많이 가서, 좀 더 간편한 Tailscale을 쓰기로 했다. 이 글에서는 단순히 "Tailscale을 깔았다"가 아니라, 하나의 Docker Compose 안에서 공개 서비스와 tailnet 전용 서비스를 깔끔하게 나누는 패턴과 그게 어떤 원리로 동작하는지를 정리한다.
Tailscale은 WireGuard를 기반으로 한 메시 VPN이다. 기존에 WireGuard나 OpenVPN으로 직접 VPN을 구성하려면 공인 IP·DDNS 설정, 서버 키 교환, 방화벽 포트 개방 같은 작업을 일일이 해야 했다. Tailscale은 이 과정을 대부분 자동화해서, 계정으로 로그인한 기기들을 같은 사설 네트워크(tailnet)에 묶어준다. 마침 2026년 4월 8일에 무료 Personal 플랜이 크게 개편됐다. 이전에도 기기를 최대 100개까지 붙일 수 있어서 개인이 한도를 체감할 일은 거의 없었는데, 이번 개편으로 기기 수 제한 자체가 사라지고 사용자도 3명에서 6명으로 늘었다(기존 Personal Plus 유료 티어는 폐지). 안 그래도 넉넉하던 무료 플랜이 더 부담 없어진 셈이라, 홈랩 용도로 도입하기 좋아졌다.
참고: 무제한이 되는 건 "사용자 소유 기기(user devices)"이고, 태그가 붙은 인프라 장비(tagged resources)는 플랜별 한도가 따로 있다. 개인 홈랩 수준에서는 신경 쓸 일이 거의 없다.
Tailscale을 컨테이너로 쓸 때 가장 널리 소개되는 방식은 사이드카(sidecar) 패턴이다. 어떤 컨테이너에 network_mode: service:tailscale를 주면, 그 컨테이너는 자기 네트워크 스택을 버리고 Tailscale 컨테이너의 인터페이스·IP·라우팅(tailscale0 포함)을 그대로 공유한다. Tailscale 공식 문서에서도 이걸 표준 배포 패턴으로 다루고, NPM과 묶는 조합도 여러 홈랩 글에서 흔하게 등장한다.
그런데 공식적으로 권장되는 형태는 "서비스 하나당 사이드카 하나" 다. 이걸 무시하고 여러 서비스를 같은 Tailscale 네임스페이스에 몰아넣으면, 모든 컨테이너가 하나의 포트 공간을 공유하게 된다. 그 결과 같은 포트를 두 서비스가 동시에 쓸 수 없어서, 웹 서버들을 80이 아니라 82, 83, 8443 같은 비표준 포트로 강제로 밀어내야 한다. 게다가 이미지에 따라 내부 포트를 못 바꾸는 경우도 있어서, 거기서 막히면 구성이 꼬인다.
이 글의 패턴은 이 문제를 정면으로 푼다. 핵심 한 줄로 요약하면 이렇다.
Tailscale 네임스페이스를 공유하는 건 리버스 프록시(NPM) 하나뿐이고, 나머지 백엔드는 각자 독립 네임스페이스로 분리한다. 그러면 백엔드들이 각자 자기 포트 공간을 가지므로 전부 기본 포트를 그대로 쓸 수 있고, 포트 충돌이 원천적으로 사라진다.
# ============================================================================
# 홈랩 Compose 예시 — "네임스페이스 분리 + NPM 이름 기반 라우팅" 패턴
# ----------------------------------------------------------------------------
# 이 구성의 핵심 아이디어
# 1) Tailscale 네임스페이스를 공유하는 건 리버스 프록시(NPM) "하나"뿐
# → 프록시가 tailnet 클라이언트의 source IP(100.64.0.0/10)를 그대로 봄
# → NPM Access List 로 "tailnet 전용 / 공개"를 호스트별로 차등 게이트
# 2) 백엔드는 전부 일반 브리지(proxy)에 각자 네임스페이스로 분리
# → 포트 충돌이 사라져 전부 "기본 포트" 사용 가능
# → NPM은 컨테이너 "이름"으로 프록시 (http://app:80 처럼)
# 3) DB 등 비공개 컴포넌트는 internal 망에 격리 (호스트로 포트 노출 X)
#
# ※ 모든 비밀값은 .env 로 분리 (아래 .env.example 참고). compose 에는 평문 없음.
# ============================================================================
networks:
proxy:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16 # 프록시가 백엔드에 붙는 내부망 (TRUSTED_PROXIES 고정용)
internal:
driver: bridge
internal: true # 외부/호스트 egress 없는 DB 전용 망
services:
# ───────────────────────── DDNS (선택) ─────────────────────────
# 공인 IP 가 바뀌는 가정용 회선에서 도메인 A레코드를 자동 갱신.
# host 네트워크로 띄워 외부 IP 감지. 토큰은 .env 에서 주입.
# Proxy가 필요없는 도메인의 경우, 동일한 컨테이너를 띄우되 PROXIED=false로 변경하면 됨
ddns:
image: favonia/cloudflare-ddns:latest
[]
[]
[]
[]
[]
[]
[, ]
[]
[]
# compose.yml 과 같은 디렉터리에 .env 로 저장
# 아래 값은 전부 예시 — 실제 값으로 교체하세요.
# Cloudflare DNS 편집용 API 토큰 (DDNS / DNS-01 인증서 발급)
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
# Tailscale 인증 키 (admin 콘솔 → Settings → Keys 에서 발급)
TS_AUTHKEY=tskey-auth-xxxxxxxxxxxx
# DB 자격증명
DB_ROOT_PASSWORD=change_me_root
DB_PASSWORD=change_me_app
이 compose가 "왜" 이렇게 생겼는지 한 조각씩 뜯어보자. 전체 트래픽 흐름을 먼저 그림으로 두면 이렇다.
[ 공개 사용자 ] [ tailnet 사용자 ]
│ │
Cloudflare 엣지 (WireGuard 터널)
│ │
▼ ▼
호스트 :443 (publish) ───────────► tailscale 컨테이너 (tailscale0)
│
[ 같은 네임스페이스 공유 ]
│
reverse-proxy (NPM)
│ 판단: 도메인 + Access List
┌────────────────┬──────┴──────┬─────────────────┐
▼ ▼ ▼ ▼
web-app:80 internal-tool:8080 data-app:80 (udp-service는
(proxy 망) (proxy 망) │ (proxy) 프록시 우회,
▼ 호스트 직결)
database (internal 망, 외부 차단)
network_mode: service:tailscale — 네임스페이스를 "공유"한다는 것리눅스에서 컨테이너의 네트워크는 네트워크 네임스페이스라는 격리 단위로 분리된다. 각 네임스페이스는 자기만의 인터페이스, IP, 라우팅 테이블, 그리고 포트 공간을 가진다.
network_mode: service:tailscale는 NPM 컨테이너에게 "네트워크를 따로 갖지 말고 tailscale 컨테이너의 네임스페이스를 그대로 써라"는 뜻이다. 그래서 NPM은 자기 IP가 없고, tailscale이 가진 인터페이스(WireGuard의 tailscale0, 그리고 proxy 브리지 인터페이스)를 공유한다. NPM 입장에서는 localhost가 곧 tailscale의 네트워크가 된다.
이게 왜 중요하냐면, tailnet으로 들어온 트래픽이 NPM에게 그대로 도달하기 때문이다. tailnet 사용자가 보낸 패킷은 tailscale0을 통해 들어오고, 그 네임스페이스를 공유하는 NPM이 곧바로 받는다.
핵심은 NPM이 "누가 접속했는지"를 IP로 구분할 수 있게 만드는 것이다.
Tailscale은 tailnet 안의 각 기기에 100.64.0.0/10 대역(CGNAT 대역)의 IP를 부여한다. NPM이 tailscale 네임스페이스를 공유하므로, tailnet 사용자가 접속하면 NPM이 보는 **출발지 IP가 그대로 **100.x.y.z 가 된다.
만약 중간에 다른 프록시나 NAT를 한 번 더 거치면 이 출발지 IP가 가려져 버린다. 그러면 "이 요청이 tailnet에서 온 건지" 판단할 근거가 사라진다. 네임스페이스 공유는 그 IP를 변형 없이 NPM까지 전달하기 위한 장치다.
이 IP가 보존되기 때문에, NPM의 Access List에서 이런 규칙을 걸 수 있다.
ALLOW 100.64.0.0/10
DENY all
즉 "출발지가 tailnet 대역이면 통과, 아니면 차단". 이 규칙을 비공개 서비스(internal-tool, data-app)의 Proxy Host에만 붙이면, 그 서비스들은 tailnet에 연결된 상태에서만 열린다. 반대로 web-app처럼 공개할 서비스에는 이 규칙을 붙이지 않으면 누구나 접근할 수 있다. 같은 NPM, 같은 443 포트 뒤에서 호스트마다 공개 여부를 다르게 줄 수 있는 게 이 패턴의 강점이다.
web-app, internal-tool, data-app, database는 전부 networks: [proxy]처럼 일반 브리지 네트워크에 올라간다. 이 말은 각 컨테이너가 자기만의 네임스페이스 = 자기만의 포트 공간을 가진다는 뜻이다.
그래서 세 웹앱이 전부 내부 80 포트를 써도 충돌하지 않는다. 서로 다른 네임스페이스라 80이 세 개 따로 존재하는 셈이기 때문이다. 앞서 말한 "공유 네임스페이스라 82, 83으로 밀어내야 했던" 문제가 여기서 사라진다. 비표준 포트로 우회할 필요 없이 이미지 기본값을 그대로 쓰면 된다.
NPM에서 Proxy Host의 목적지를 IP가 아니라 http://web-app:80처럼 컨테이너 이름으로 적을 수 있다. 이게 되는 이유는 Docker의 사용자 정의 브리지 네트워크에 내장 DNS가 있기 때문이다. 같은 네트워크에 속한 컨테이너는 서로의 이름을 IP로 해석할 수 있다.
여기서 한 가지 짚을 점이 있다. NPM은 tailscale의 네임스페이스를 공유하므로, NPM이 백엔드 이름을 해석하려면 tailscale 컨테이너가 proxy 네트워크의 멤버여야 한다. 그래서 compose에서 tailscale에 networks: [proxy]를 붙였다. tailscale이 proxy 망에 속해 있으니, 그 네임스페이스를 빌려 쓰는 NPM도 web-app, data-app 같은 이름을 해석할 수 있는 것이다.
정리하면 NPM은 두 세계에 동시에 발을 걸치고 있다. 한쪽은 tailscale0(들어오는 tailnet 트래픽을 받는 곳), 다른 한쪽은 proxy 브리지(백엔드로 나가는 곳)다. 둘 다 tailscale 컨테이너가 가진 인터페이스라서 가능하다.
internal 망 — DB를 바깥에서 못 보게 격리database는 networks: [internal]에만 속하고, 이 네트워크는 internal: true로 선언돼 있다. internal: true인 브리지는 호스트나 외부로 나가는 경로가 없다. 즉 이 DB는 호스트에 포트를 publish하지도 않고, 인터넷으로 나가지도 못한다.
그러면서도 data-app은 proxy와 internal 양쪽에 속하므로, 앱은 DB에 접근하면서 동시에 NPM의 프록시도 받을 수 있다. "앱은 DB에 닿되, DB는 앱 외엔 누구도 닿을 수 없는" 구조가 된다. DB 포트를 호스트에 여는 흔한 실수(3306:3306)를 아예 차단하는 셈이다.
udp-service처럼 UDP나 비-HTTP 트래픽을 쓰는 서비스는 HTTP 리버스 프록시로 다룰 수 없다. NPM은 기본적으로 HTTP(S)를 다루는 도구이기 때문이다. 그래서 이런 서비스는 컨테이너에서 직접 호스트 포트로 publish(30000:30000/udp)해서 프록시를 건너뛴다.
이 경우 NPM의 Access List 게이트가 적용되지 않으므로, 공개 여부는 방화벽이나 Cloudflare DNS 설정(프록시 여부)으로 따로 관리해야 한다는 점만 기억하면 된다.
이 패턴의 설계 원칙을 한 문장씩 다시 모으면 이렇다.
100.64.0.0/10)를 비공개 서비스에만 붙여서, 같은 진입점 뒤에서 차등 노출한다.internal 망으로 외부 경로를 끊는다..env로 빼고 compose에는 평문을 남기지 않는다.
덕분에 code-server나 Nextcloud처럼 노출하기 부담스러운 서비스는 tailnet 안에서만 열리고, 블로그 같은 공개 서비스는 평소처럼 인터넷에 둘 수 있다. VPN을 직접 구성하는 복잡함 없이, Tailscale과 리버스 프록시의 조합만으로 "필요한 만큼만 여는" 망 분리가 완성된다.
추가적으로 DNS에 Tailscale을 거쳐야하는 도메인이면 프록시를 비활성화 하면서 Tailscale의 IP를 입력하고 거치지 않는다면 프록시가 활성화 된 상태로 일반 공개 IP를 입력해서 서브 도메인을 적용한다면 구매한 도메인으로도 접근할 수 있다.이 패턴의 사고방식(게이트는 한 곳에 모으고, 백엔드는 분리하고, 상태는 격리한다)은 AWS ECS 같은 클라우드 환경으로도 옮길 수 있다. 다만 구성을 그대로 복사하는 건 아니다. ECS에서는 네임스페이스 공유가 "한 Task 안의 멀티 컨테이너"로, 커널 모드 Tailscale이 userspace 모드로, 서비스별 사이드카가 VPC 단위 subnet router로 바뀌고, 접근 제어도 NPM Access List에서 Tailscale ACL + 보안 그룹으로 이동한다. 상태가 있는 서비스(GitLab, DB 등)는 RDS·EFS 같은 관리형으로 빼는 재설계가 함께 필요하다. 즉 "옮긴다"기보다 같은 원칙을 다른 전제 위에 다시 구현하는 작업에 가깝다.
network_mode: service: 의 동작, "서비스당 사이드카 하나" 권장tailscale0 가 공유되는 메커니즘