Docker Compose에서 Tailscale 사이드카로 보안 강화하기
Docker Compose 공식 예제는 호스트 포트를 공개하여 보안 위험이 있습니다. Tailscale 사이드카 패턴을 사용하여 포트 노출 없이 tailnet 내부에서만 서비스에 안전하게 접근하는 방법을 단계별로 설명합니다.
Docker Compose 스택에서 Tailscale 실행하기
Docker Compose에서 Tailscale을 실행하려면 두 개의 서비스가 필요합니다. 하나는 tailnet에 연결하는 tailscale/tailscale 컨테이너입니다. 다른 하나는 애플리케이션 컨테이너이며, 호스트에 포트를 노출하는 대신 첫 번째 컨테이너의 네트워크 네임스페이스를 공유합니다. 결과적으로 사용자의 노트북에서 이름으로 서비스에 접근할 수 있게 되며, 공용 인터넷에서는 전혀 접근할 수 없게 됩니다.
tailnet은 로그인한 장치들 사이에 Tailscale이 구축하는 사설 네트워크입니다. 이 용어가 생소하다면 먼저 Tailscale의 정의와 두 기기 간 연결 방식을 읽어보시기 바랍니다. 이 가이드는 VPS에서 첫 Docker Compose 스택 구성하기에서 설정한 대로 Docker Engine과 Compose v2 플러그인이 이미 작동 중임을 전제로 합니다.
벤더 예시와 열려 있는 포트
Tailscale의 공식 Compose 가이드는 이와 유사한 스택을 게시하고 있습니다.
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: tailscale-nginx
environment:
- TS_AUTHKEY=tskey-auth-REPLACE-ME
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./tailscale-state:/var/lib/tailscale
cap_add:
- net_admin
- net_raw
restart: unless-stopped
nginx:
image: nginx:latest
container_name: nginx_server
ports:
- "8080:80"
depends_on:
- tailscale
restart: unless-stoppedtailscale 서비스의 모든 줄은 올바릅니다. TS_AUTHKEY은 노드를 인증합니다. TS_STATE_DIR는 tailscaled이 상태를 기록할 위치를 지정하며, bind mount를 통해 해당 상태를 디스크에 유지합니다. 문제는 두 번째 서비스입니다.
두 컨테이너는 기본 Compose 브리지 네트워크에 위치하며 각각 고유한 주소를 가집니다. 이는 Compose 네트워크가 서비스 이름으로 컨테이너를 연결하는 방식에서 설명하는 일반적인 동작입니다. tailscale 컨테이너는 스스로 tailnet에 참여할 뿐, nginx 컨테이너로 아무것도 전달하지 않습니다. 따라서 nginx에 도달하는 유일한 경로는 호스트의 8080 포트입니다.
포트를 게시(publish)하면 앞에 주소를 명시하지 않는 한 0.0.0.0에 바인딩되므로, VPS에서는 해당 포트가 공인 IP로 응답하게 됩니다. 애플리케이션은 이름상으로는 tailnet에 있지만, 실제로는 인터넷에 노출된 상태입니다. Docker가 ufw 체인보다 앞서 자체 포워딩 규칙을 삽입하기 때문에 호스트 방화벽으로도 보호할 수 없습니다. 이는 ufw 거부 규칙이 게시된 Docker 포트를 닫지 못하는 이유에서 설명하는 함정입니다.
언급할 가치가 있는 세부 사항이 하나 더 있습니다. 해당 예시는 net_admin와 net_raw을 부여하지만 /dev/net/tun는 매핑하지 않습니다. TS_USERSPACE는 기본값이 true이므로 컨테이너는 사용자 공간 네트워크 스택을 실행하며, 해당 두 기능은 아무런 역할을 하지 않습니다.
사이드카: 하나의 네임스페이스, 게시된 포트 없음
network_mode: service:tailscale을 사용하여 애플리케이션을 Tailscale 컨테이너의 네트워크 네임스페이스 안에 배치합니다. 두 프로세스는 별도의 컨테이너에서 실행되더라도 동일한 루프백과 동일한 tailnet 주소를 공유합니다.
services:
tailscale:
image: tailscale/tailscale:v1.102.3
container_name: ts-nginx
hostname: nginx-demo
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_HOSTNAME=nginx-demo
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./ts-state:/var/lib/tailscale
restart: unless-stopped
nginx:
image: nginx:1.30.4-alpine
network_mode: service:tailscale
depends_on:
- tailscale
restart: unless-stopped키는 YAML이 아닌 compose 파일 옆의 .env 파일에 저장하므로, 커밋하는 파일에는 비밀 정보가 포함되지 않습니다. 한 줄로 작성합니다: TS_AUTHKEY=tskey-auth-.... 커밋된 compose 파일에서 비밀 정보 제외하기에서 해당 패턴의 나머지 부분을 다룹니다.
서비스를 시작하고 양쪽 모두 확인합니다.
docker compose up -d
docker compose exec tailscale tailscale status
docker compose logs --tail 20 tailscaletailscale status는 이 노드에 대한 100.x 주소와 함께 tailnet의 다른 머신 목록을 출력해야 합니다. 동일한 계정으로 로그인된 노트북에서 curl http://nginx-demo/을 실행하면 nginx 환영 페이지가 반환됩니다. VPS 자체에서는 포트가 게시되지 않았으므로 sudo ss -lntp | grep 8080를 실행해도 아무것도 반환되지 않습니다.
왜 8080이 아닌 80 포트인가 하면, 유저스페이스 모드에서 tailscaled은 들어오는 터널 연결을 localhost의 동일한 포트로 전달하기 때문입니다. nginx는 공유 네임스페이스 내부의 80 포트에서 수신 대기하므로, tailnet은 80 포트를 통해 접근합니다. 애플리케이션이 수신 대기하는 포트를 변경하면 tailnet 포트도 함께 변경됩니다. 이 네임스페이스 공유 기법은 Tailscale에만 국한된 것이 아니며, 호스트 및 나머지 스택에 접근하는 것과 관련된 동일한 질문들은 이웃 컨테이너의 네트워크를 소유하는 Gluetun 컨테이너에서 다룹니다.
컨테이너에는 어떤 인증 키가 필요한가?
키 유형에 따라 두 번째 시작 시 동작이 결정되므로 배포 전에 선택해야 합니다. 관리 콘솔의 Keys 페이지에서 키를 생성하십시오. 대화 상자에는 키가 한 번만 표시됩니다.
- 일회용 키(One-off keys)는 단일 장치를 인증합니다. 상태 디렉터리 없이 재생성된 스택은 다시 연결되지 않습니다.
- 재사용 가능 키(Reusable keys)는 여러 장치를 인증합니다. 일반적으로 Compose 스택에 필요한 방식입니다.
- 임시 키(Ephemeral keys)는 노드를 자동 정리 대상으로 표시합니다. Tailscale은 마지막 활동 후 30분에서 60분이 지나면 임시 장치를 제거합니다.
- 사전 승인 키(Pre-approved keys)는 수동 장치 승인 과정을 건너뜁니다. 이는 tailnet에 장치 승인 기능이 켜져 있을 때만 중요합니다.
- 태그 지정 키(Tagged keys)는 인증 시점에
tag:container와 같은 ACL 태그를 적용합니다. 장치는 특정 개인의 소유에서 벗어나며, 기본적으로 키 만료가 비활성화됩니다.
마지막 항목이 운영상 중요합니다. 노드 키는 기본적으로 180일 후에 만료되며, 만료된 노드는 사람이 다시 로그인할 때까지 tailnet에서 제외됩니다. 태그 지정 키는 이러한 만료 알람을 제거하며, 이것이 서버와 컨테이너에 태그가 존재하는 이유입니다.
인증 키 만료는 노드 키 만료와는 별개의 사건이며, 많은 사용자가 이를 혼동합니다. 인증 키는 1일에서 90일까지 유효하며 기본값은 90일입니다. 키가 만료 날짜에 도달해도 이미 인증된 장치의 연결이 끊기지는 않습니다. 단지 새로운 장치를 추가하는 기능만 중단됩니다. 장기 실행 서비스에는 임시가 아닌 재사용 가능한 태그 지정 키를 사용하십시오. 미리보기 환경과 같이 지속적으로 삭제하는 스택의 경우, 임시 키를 사용하면 수동 삭제 없이 관리 콘솔을 깔끔하게 유지할 수 있습니다.
컨테이너가 왜 새로운 머신으로 인식되나요?
tailscaled가 컨테이너의 쓰기 가능 계층(writable layer)에 상태를 기록했는데, docker compose down이 해당 컨테이너를 삭제했기 때문입니다.
노드 식별 정보는 해당 상태 디렉터리에 저장됩니다. 이 디렉터리를 영구적으로 유지하면 컨테이너가 재시작되어도 이름과 100.x 주소, 그리고 각종 서비스 설정을 그대로 유지합니다. 이 정보를 잃어버리면 다음 시작 시 최초 실행으로 간주됩니다. 컨테이너는 동일한 키로 다시 인증을 수행하고, 관리자 콘솔에는 두 번째 머신이 추가됩니다. 두 머신 모두 호스트 이름 nginx-demo을 사용하려 하므로, MagicDNS는 새로운 머신에 번호가 붙은 접미사를 부여하게 되며, 사용자가 저장해 둔 모든 링크는 죽은 노드를 가리키게 됩니다.
두 가지 조건이 충족되어야 합니다. TS_STATE_DIR=/var/lib/tailscale는 Kubernetes 외부에서는 기본값이 없으므로 반드시 설정해야 합니다. 또한 해당 경로는 바인드 마운트나 명명된 볼륨(named volume)을 통해 마운트되어야 합니다. 이 선택에 관한 내용은 바인드 마운트와 명명된 볼륨 비교에서 다룹니다. 둘 중 하나만 설정하는 것이 흔한 실수이며, 이 경우 첫 번째 down이 발생하기 전까지는 스택이 정상적으로 작동하는 것처럼 보여 문제가 즉시 드러나지 않습니다.
추측하지 말고 직접 확인하십시오.
docker compose down
ls -l ./ts-state
docker compose up -d
docker compose exec tailscale tailscale status두 번째 up이 실행되기 전에 ./ts-state에 이미 tailscaled.state가 포함되어 있어야 하며, 노드는 이전과 동일한 주소로 복구되어야 합니다. 다른 주소가 할당된다면 마운트가 제대로 작동하지 않는 것입니다.
이미지를 고정하고 사용한 태그를 지정하십시오
tailscale/tailscale:latest는 최신 안정 빌드를 따릅니다. 6개월 뒤의 docker compose pull는 tailscaled을 다른 버전으로 예고 없이 교체하며, 다음 재시작 시 선택한 적 없는 코드가 실행됩니다. 위 스택은 2026년 9월 기준 안정 릴리스인 v1.102.3을 고정합니다. Docker Hub는 패치 라인에 대한 v1.102과 unstable 태그도 게시하지만, 서버에서는 이를 사용하지 않아야 합니다.
업그레이드는 의도적으로 수행하십시오.
docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale version태그를 수정하고 up -d을 실행하면 컨테이너가 다시 생성되는데, 이는 단순히 재시작하는 것과는 다릅니다. 재시작, up, 재빌드의 차이는 적용되지 않은 버전 변경 사항을 디버깅하기 전에 읽어볼 가치가 있습니다.
유저스페이스 네트워킹과 그 비용
TS_USERSPACE은 기본적으로 true로 설정됩니다. 그러면 컨테이너는 유저스페이스에서 TCP/IP 스택을 실행하며 /dev/net/tun를 전혀 건드리지 않습니다. 이것이 컨테이너에 TUN 장치를 제공하지 않는 호스트에서도 작동하게 하는 원리입니다. 인바운드 연결은 여전히 작동하는데, 들어오는 터널 연결이 localhost의 동일한 포트로 전달되기 때문입니다. 이것이 바로 위에서 언급한 사이드카가 장치나 권한을 필요로 하지 않는 이유입니다.
아웃바운드 연결에는 비용이 발생합니다. 유저스페이스 모드에서는 애플리케이션이 다른 tailnet 노드로 소켓을 직접 열 수 없습니다. 대신 tailscaled이 SOCKS5 프록시와 HTTP 프록시를 제공하므로, tailscale 서비스에 TS_SOCKS5_SERVER=localhost:1055를 설정하고 애플리케이션에 ALL_PROXY=socks5://localhost:1055를 설정해야 하며, 애플리케이션은 이를 준수해야 합니다. 프록시 환경 변수를 무시하는 모든 통신은 tailnet에 도달하지 못합니다.
스택 자체에도 알아두어야 할 제한 사항이 있습니다. TCP와 UDP만 전송되므로 SCTP와 같은 다른 IP 프로토콜은 통과하지 못합니다. ICMP는 데몬이 재구성하는 ping으로 제한되며, 이로 인해 약간의 지연 시간이 발생합니다. 연결은 노드에서 종료된 후 대상 노드로 다시 연결되므로 종단 간(end-to-end) 연결이 아닙니다. 또한 유저스페이스 노드는 다른 사용자가 광고하는 엑시트 노드나 서브넷 경로를 사용할 수 없지만, 스스로 광고하는 것은 가능합니다.
투명한 아웃바운드 트래픽이 필요한 경우, tailscale 서비스에 다음 세 가지를 추가하여 커널 네트워킹으로 전환하십시오.
environment:
- TS_USERSPACE=false
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin먼저 test -c /dev/net/tun && echo ok을 사용하여 호스트가 이를 제공할 수 있는지 확인하십시오. KVM에서는 해당 장치를 사용할 수 있습니다. 호스트 커널을 공유하는 컨테이너 가상화 환경에서는 장치가 없을 수 있으며, 이 경우 유저스페이스가 유일한 경로입니다. 컨테이너가 사설 대역을 광고하는 서브넷 라우터나 다른 장치를 위한 엑시트 노드 역할을 수행해야 한다면 컨테이너에 TUN 장치를 제공하십시오. 이러한 역할은 유저스페이스에서 가장 큰 제약을 받기 때문입니다.
서비스 접속: serve 또는 일반 MagicDNS 이름 사용
가장 간단한 경로는 MagicDNS 이름을 사용하는 것입니다. 테일넷(tailnet)에 연결된 모든 기기에서 http://nginx-demo/로 접속할 수 있으며, 전체 이름인 http://nginx-demo.your-tailnet.ts.net/도 동일하게 작동합니다. WireGuard가 두 노드 사이의 트래픽을 암호화하므로, 이 경로의 일반 HTTP는 네트워크상에서 평문으로 노출되지 않습니다. 조정 서버가 볼 수 있는 것과 없는 것에 대한 문서는 이러한 보장이 어디까지 적용되는지 명시합니다. 인증서가 없으므로 브라우저는 해당 출처를 안전하지 않음으로 표시하며, 보안 컨텍스트를 요구하는 웹 기능은 실행되지 않습니다.
다른 경로는 컨테이너 내부에서 실행하는 Tailscale Serve를 사용하는 것입니다.
docker compose exec tailscale tailscale serve --bg localhost:80
docker compose exec tailscale tailscale serve status이 명령은 Tailscale이 제공하는 인증서를 사용하여 https://nginx-demo.your-tailnet.ts.net에 애플리케이션을 게시합니다. MagicDNS와 HTTPS 인증서 모두 관리 콘솔의 DNS 페이지에서 활성화되어 있어야 하며, 그렇지 않으면 인증서에 사용할 이름이 생성되지 않습니다. --bg은 설정을 영구 저장된 tailscaled 상태에 기록하므로 컨테이너가 재시작되어도 설정이 유지되며, tailscale serve reset은 이를 제거합니다. 셸 명령 대신 저장소에 설정을 유지하고 싶다면 TS_SERVE_CONFIG를 사용하여 JSON 파일을 지정할 수 있습니다. Serve는 테일넷 내부에서만 작동합니다. Funnel은 동일한 서비스를 공용 인터넷에 노출하는 별도의 명령이므로, 명령을 실행하기 전에 Serve와 Funnel의 차이점을 먼저 읽어보시기 바랍니다.
실패 유형과 표시되는 메시지
Error response from daemon: conflicting options: port publishing and the container type network mode. 사이드카 서비스에 ports: 블록을 남겨두었습니다. 네임스페이스를 소유한 컨테이너만 포트를 게시할 수 있으며, tailnet 전용 앱은 포트를 게시해서는 안 됩니다. 해당 블록을 삭제하십시오.
앱 컨테이너는 실행 중이나 아무것도 도달하지 못함. tailscale 서비스를 단독으로 재생성했습니다. 서비스가 소유했던 네임스페이스가 함께 파괴되었으며, 앱은 더 이상 존재하지 않는 대상에 연결되어 있습니다. docker compose up -d --force-recreate를 사용하여 쌍을 함께 재생성하십시오.
관리 콘솔에 노드가 나타나지 않음. docker compose logs tailscale을 읽어보십시오. 거부된 키가 그곳에 보고됩니다. 이미 사용된 일회용 키나 만료된 키는 노드가 tailnet에 도달하기 전에 차단합니다.
노드는 활성화되었고, tailscale status은 정상으로 보이나 curl http://nginx-demo/이 응답하지 않음. 앱이 예상한 위치에서 수신 대기 중이 아닙니다. docker compose exec nginx wget -qO- http://localhost/를 사용하여 공유 네임스페이스 내부에서 앱에 직접 요청을 보내보십시오. 이마저 실패한다면 문제는 Tailscale이 아니라 앱 자체에 있습니다. 성공한다면 앱이 모든 인터페이스가 아닌 특정 인터페이스에만 바인딩된 상태입니다.
스택을 중지한 지 1시간 후 콘솔에서 머신이 사라짐. 키가 일시적(ephemeral)으로 설정되었습니다. 마지막 활동 후 30분에서 60분 사이에 제거가 발생하며, 이는 기능이 정상적으로 작동하는 것입니다.
버전 1.78부터는 이미지에서 인증되지 않은 /healthz 엔드포인트를 노출할 수 있습니다. TS_ENABLE_HEALTH_CHECK=true을 설정하면 TS_LOCAL_ADDR_PORT에서 수신 대기하며, 기본값은 [::]:9002입니다. Compose 헬스체크를 이 엔드포인트로 지정하면 인증에 실패한 노드가 정상인 것처럼 보이는 대신 비정상(unhealthy)으로 보고됩니다. Tailscale의 조정 서버에 전혀 의존하고 싶지 않다면, 동일한 compose 파일에서 TS_EXTRA_ARGS=--login-server=https://headscale.example.com를 통해 자체 제어 평면과 통신할 수 있습니다. 이는 Headscale을 자체 제어 서버로 운영하기를 시작하는 지점입니다.
FAQ
왜 tailnet의 다른 기기에서 내 앱 컨테이너에 접근할 수 없나요?
tailscale 컨테이너는 자기 자신만을 위해 tailnet에 참여하기 때문입니다. 앱이 고유 주소를 가진 기본 Compose 브리지 네트워크에서 실행 중이라면, tailscale 노드는 앱으로 트래픽을 전달하지 않으며 유일한 접근 방법은 호스트에 게시된 포트뿐입니다. 앱에 network_mode: service:tailscale를 설정하여 tailscale 컨테이너의 네트워크 네임스페이스를 공유하게 한 뒤, 앱의 ports: 블록을 제거하십시오. 그러면 앱이 수신 대기 중인 포트를 통해 tailnet에서 직접 접근할 수 있게 됩니다.
Docker Compose에서 Tailscale을 실행하려면 /dev/net/tun이 필요한가요?
인바운드 접근 시에는 필요하지 않습니다. TS_USERSPACE은 기본값이 true이며, 이 모드에서 tailscaled은 자체 네트워크 스택을 실행하고 들어오는 터널 연결을 localhost의 동일한 포트로 전달하므로, 사이드카는 별도의 장치나 추가 권한 없이 작동합니다. 컨테이너가 tailnet으로 나가는 연결을 투명하게 생성해야 하거나, 서브넷 라우터 또는 엑시트 노드로 동작해야 하는 경우에는 /dev/net/tun, TS_USERSPACE=false 및 net_admin이 필요합니다.
Compose 스택에는 일회용(ephemeral) 인증 키를 사용해야 하나요, 재사용 가능한 키를 사용해야 하나요?
장기간 운영되는 서비스에는 태그가 지정되어 있고 일회용이 아닌 재사용 가능한 키를 사용하십시오. 태그를 지정하면 노드 키 만료가 비활성화되므로, 180일마다 수동으로 재인증할 필요 없이 컨테이너가 tailnet에서 제외되지 않습니다. 일회용 키는 미리보기 환경처럼 자주 삭제되는 스택에만 선택하십시오. Tailscale은 마지막 활동 후 30분에서 60분이 지나면 일회용 기기를 제거하므로 관리 콘솔을 깔끔하게 유지할 수 있습니다.
왜 컨테이너가 재시작될 때마다 새로운 기기로 표시되나요?
상태 디렉터리가 유지되지 않아 tailscaled가 식별 정보 없이 시작되고 새로운 노드로 인증되기 때문입니다. Kubernetes 외부에서는 기본값이 없는 TS_STATE_DIR=/var/lib/tailscale을 설정하고, 해당 경로를 바인드 마운트나 명명된 볼륨에 연결하십시오. 하나만 설정하면 첫 번째 docker compose down가 발생하기 전까지는 정상적으로 보일 수 있습니다. 스택이 중지된 상태에서 마운트된 디렉터리에 tailscaled.state가 존재하는지 확인하십시오.