Chạy Tailscale trong Docker Compose không mở port
Ví dụ Compose của Tailscale vẫn publish port trên host. Dùng sidecar Tailscale để app không có port public, chỉ truy cập qua tên trong tailnet.
Chạy Tailscale trong một stack Docker Compose
Chạy Tailscale trong Docker Compose cần 2 service. Một service là container tailscale/tailscale tham gia tailnet của bạn. Service còn lại là container ứng dụng, dùng chung network namespace của container đầu tiên thay vì publish một port trên host. Kết quả là bạn có thể mở service bằng tên từ laptop, còn public Internet hoàn toàn không thể truy cập service đó.
Tailnet là mạng riêng mà Tailscale tạo giữa các thiết bị bạn đăng nhập. Nếu đây là khái niệm mới với bạn, trước tiên hãy đọc Tailscale là gì và kết nối 2 máy như thế nào. Hướng dẫn này giả định Docker Engine và plugin Compose v2 đã hoạt động, theo thiết lập trong stack Docker Compose đầu tiên trên VPS.
Ví dụ của vendor và cổng mà nó để mở
Guide Compose của Tailscale tự đưa ra một stack gần giống stack này.
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-stoppedMọi dòng trong service tailscale đều đúng. TS_AUTHKEY xác thực node. TS_STATE_DIR cho tailscaled biết nơi ghi state, còn bind mount giữ state trên disk. Service thứ hai mới là vấn đề.
Hai container này nằm trên default Compose bridge network và mỗi container có một địa chỉ riêng. Đây là hành vi thông thường được giải thích trong cách Compose kết nối các container bằng tên service. Container tailscale tự join tailnet, nhưng không forward lưu lượng nào đến container nginx. Vì vậy, đường duy nhất đến nginx là cổng 8080 trên host.
Port được publish sẽ bind vào 0.0.0.0 nếu bạn không ghi địa chỉ ở phía trước. Vì vậy, trên VPS, cổng đó trả lời trên public IP. App có tên trong tailnet nhưng thực tế lại nằm trên internet. Host firewall cũng không bảo vệ được bạn, vì Docker chèn các forwarding rule của nó trước chain của ufw. Đây là cái bẫy được mô tả trong vì sao rule deny của ufw không đóng được Docker port đã publish.
Còn một chi tiết cần nêu rõ. Ví dụ này cấp net_admin và net_raw nhưng không map /dev/net/tun. TS_USERSPACE mặc định là true, nên container chạy userspace network stack và hai capability đó không có tác dụng.
Sidecar: dùng chung namespace, không publish port
Đặt ứng dụng vào network namespace của container Tailscale bằng network_mode: service:tailscale. Cả hai process sẽ thấy cùng loopback và cùng địa chỉ tailnet, dù chúng chạy trong các container riêng.
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Đặt key trong file .env bên cạnh file compose, không đặt trong YAML, để file được commit không chứa secret. File chỉ có một dòng: TS_AUTHKEY=tskey-auth-.... Không để secret trong file compose đã commit trình bày phần còn lại của cách làm này.
Khởi động stack rồi kiểm tra cả hai phía.
docker compose up -d
docker compose exec tailscale tailscale status
docker compose logs --tail 20 tailscaletailscale status phải in một dòng cho node này với địa chỉ 100.x, sau đó là các máy khác trong tailnet. Từ một laptop cũng đã đăng nhập, curl http://nginx-demo/ sẽ trả về trang chào mừng của nginx. Trên chính VPS, sudo ss -lntp | grep 8080 không trả về gì vì không có port nào được publish.
Tại sao dùng port 80 thay vì 8080: trong userspace mode, tailscaled chuyển kết nối tunnel đến trên cùng port của localhost. nginx lắng nghe trên port 80 trong namespace dùng chung, nên tailnet có thể truy cập ứng dụng qua port 80. Khi thay đổi port mà ứng dụng lắng nghe, tailnet port cũng thay đổi theo. Cách dùng chung namespace này không chỉ áp dụng cho Tailscale. Các câu hỏi về cách truy cập host và phần còn lại của stack cũng xuất hiện trong container Gluetun quản lý network của các container lân cận.
Container cần auth key nào?
Loại key quyết định điều gì xảy ra khi khởi động lần thứ hai, vì vậy hãy chọn trước khi deploy. Tạo key trong trang Keys của admin console. Dialog chỉ hiển thị key một lần.
- One-off key dùng để xác thực một thiết bị duy nhất. Nếu recreate stack mà không có state directory, stack sẽ không khởi động lại được.
- Reusable key dùng để xác thực bao nhiêu thiết bị cũng được. Đây thường là loại mà một Compose stack cần.
- Ephemeral key đánh dấu node để tự động dọn dẹp. Tailscale xóa thiết bị ephemeral sau 30 đến 60 phút kể từ hoạt động cuối cùng của thiết bị.
- Pre-approved key bỏ qua bước phê duyệt thiết bị thủ công. Tùy chọn này chỉ có tác dụng khi device approval được bật cho tailnet của bạn.
- Tagged key áp dụng ACL tag như
tag:containertại thời điểm xác thực. Thiết bị không còn thuộc về một người dùng, và mặc định key expiry của thiết bị bị tắt.
Dòng cuối cùng mới là điểm cần chú ý khi vận hành. Node key mặc định hết hạn sau 180 ngày. Khi hết hạn, node bị loại khỏi tailnet cho đến khi có người đăng nhập lại cho node. Tagged key loại bỏ thời hạn này. Đây là lý do tag được dùng cho server và container.
Auth key hết hạn là một sự kiện khác, và nhiều người nhầm lẫn hai loại này. Auth key có thời hạn từ 1 đến 90 ngày, mặc định là 90 ngày. Khi key đến ngày hết hạn, các thiết bị đã được key đó xác thực sẽ không bị disconnect. Key chỉ không thể dùng để thêm thiết bị mới. Với service chạy lâu dài, hãy dùng reusable tagged key không phải ephemeral. Với stack thường xuyên bị tear down, chẳng hạn preview environment, ephemeral key giúp admin console luôn gọn gàng mà không cần xóa thủ công.
Vì sao container quay lại như một máy mới?
Vì tailscaled đã ghi state của nó vào writable layer của container, còn docker compose down đã xóa container.
Node identity nằm trong thư mục state đó. Persist thư mục này để container giữ nguyên tên và địa chỉ 100.x sau các lần restart, cùng với mọi cấu hình serve. Nếu mất thư mục này, lần start tiếp theo sẽ là lần start đầu tiên: container xác thực lại bằng cùng key, còn admin console sẽ có thêm một machine thứ hai. Cả hai cùng nhận hostname nginx-demo, nên MagicDNS thêm hậu tố dạng số cho machine mới hơn. Mọi link bạn đã lưu đều trỏ đến node cũ không còn hoạt động.
Có 2 điều phải đúng. TS_STATE_DIR=/var/lib/tailscale phải được set vì bên ngoài Kubernetes, biến này không có giá trị mặc định. Path đó cũng phải được mount bằng bind mount ở trên hoặc named volume. Lựa chọn này được giải thích trong bind mount và named volume. Chỉ set một trong hai là lỗi thường gặp và lỗi này không biểu hiện ngay: stack vẫn hoạt động bình thường cho đến lần down đầu tiên.
Hãy verify thay vì giả định.
docker compose down
ls -l ./ts-state
docker compose up -d
docker compose exec tailscale tailscale status./ts-state phải đã chứa tailscaled.state trước up thứ hai, và node phải quay lại với địa chỉ trước đó. Nếu địa chỉ thay đổi, mount không hoạt động đúng.
Ghim image và ghi rõ tag đã dùng
tailscale/tailscale:latest luôn trỏ đến bản stable mới nhất. Sau sáu tháng, một docker compose pull có thể âm thầm thay tailscaled bằng một version khác, rồi lần restart tiếp theo sẽ chạy code mà bạn chưa bao giờ chọn. Cấu hình ở trên ghim v1.102.3, là bản stable tính đến tháng 09/2026. Docker Hub cũng phát hành v1.102 cho dòng patch và tag unstable, nhưng bạn không nên dùng tag đó trên server.
Hãy chủ động nâng cấp.
docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale versionSửa tag rồi chạy up -d sẽ tạo lại container. Việc này không giống restart container. Sự khác nhau giữa restart, up và rebuild đáng để đọc trước khi bạn debug một thay đổi version nhưng thay đổi đó chưa có hiệu lực.
Mạng userspace và chi phí đi kèm
TS_USERSPACE mặc định là true. Container sẽ chạy TCP/IP stack trong userspace và không truy cập /dev/net/tun. Nhờ đó, cách này hoạt động trên các host không cấp thiết bị TUN cho container. Kết nối vào vẫn hoạt động vì các kết nối tunnel đến được forward đến cùng một port trên localhost. Đây là lý do sidecar ở trên không cần device hoặc capability nào.
Chi phí nằm ở lưu lượng đi ra. Trong userspace mode, ứng dụng không thể chỉ cần mở socket đến một node khác trong tailnet. Thay vào đó, tailscaled cung cấp SOCKS5 proxy và HTTP proxy. Bạn đặt TS_SOCKS5_SERVER=localhost:1055 trên tailscale service và ALL_PROXY=socks5://localhost:1055 trên app, đồng thời app phải hỗ trợ biến đó. Mọi ứng dụng bỏ qua các biến môi trường proxy sẽ không truy cập được tailnet.
Stack này có một số giới hạn cần biết. Chỉ TCP và UDP được truyền, nên các IP protocol khác như SCTP sẽ không đi qua. ICMP chỉ hỗ trợ ping do daemon tái tạo, và việc này làm tăng một chút latency cảm nhận được. Kết nối bị kết thúc tại node rồi được thiết lập lại đến target, nên không phải kết nối end-to-end. Node userspace cũng không thể dùng exit node hoặc subnet route do node khác advertise, dù nó có thể tự advertise các route đó.
Khi cần lưu lượng đi ra transparent, hãy chuyển sang kernel networking bằng cách thêm 3 mục vào tailscale service.
environment:
- TS_USERSPACE=false
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_adminTrước tiên, kiểm tra host có thể cung cấp thiết bị này bằng test -c /dev/net/tun && echo ok. Trên KVM, device thường có sẵn. Với container virtualisation dùng chung kernel của host, device này có thể không tồn tại; khi đó userspace là lựa chọn duy nhất. Hãy cấp thiết bị TUN cho container nếu container được dùng làm subnet router quảng bá một private range hoặc exit node cho các device khác của bạn, vì đây là những vai trò bị hạn chế nhiều nhất trong userspace.
Truy cập service: Serve hoặc tên MagicDNS dạng ngắn
Cách đơn giản là dùng tên MagicDNS. Từ bất kỳ thiết bị nào trong tailnet, http://nginx-demo/ hoạt động, và tên đầy đủ http://nginx-demo.your-tailnet.ts.net/ cũng vậy. HTTP dạng thường ở đây không phải là cleartext trên đường truyền, vì WireGuard mã hóa lưu lượng giữa 2 node. Coordination server có thể và không thể xem gì sẽ xác định phạm vi của bảo đảm này. Không có certificate, nên browser đánh dấu origin là không an toàn. Mọi web feature yêu cầu secure context sẽ từ chối chạy.
Cách còn lại là Tailscale Serve, chạy bên trong container.
docker compose exec tailscale tailscale serve --bg localhost:80
docker compose exec tailscale tailscale serve statusCách này publish app tại https://nginx-demo.your-tailnet.ts.net bằng certificate do Tailscale provision. MagicDNS và HTTPS certificate đều phải được enable trên trang DNS của admin console. Nếu không, sẽ không có tên để đưa vào certificate. --bg ghi cấu hình vào state của tailscaled mà bạn đã persist, nên cấu hình sẽ được khôi phục cùng container. tailscale serve reset xóa cấu hình đó. TS_SERVE_CONFIG trỏ đến một file JSON nếu bạn muốn lưu cấu hình trong repository thay vì trong shell command. Serve chỉ hoạt động trong tailnet. Funnel là command riêng để đưa cùng service lên public internet. Vì vậy, hãy đọc sự khác nhau giữa Serve và Funnel trước khi chạy một trong hai command.
Các tình huống lỗi và thông báo bạn sẽ thấy
Error response from daemon: conflicting options: port publishing and the container type network mode. Bạn đã để lại một block ports: trên sidecar service. Chỉ container sở hữu namespace mới được publish port, còn app chỉ chạy trong tailnet thì không được publish port nào. Xóa block này.
App container đang chạy nhưng không có request nào tới được app. Bạn đã tạo lại tailscale service riêng lẻ. Namespace mà service này sở hữu đã bị hủy cùng với service, còn app đang gắn vào một namespace không còn tồn tại. Tạo lại cả cặp cùng với docker compose up -d --force-recreate.
Không có node nào xuất hiện trong admin console. Đọc docker compose logs tailscale. Key bị từ chối sẽ được ghi ở đó. One-off key đã được sử dụng và key đã hết hạn đều khiến node dừng trước khi node kịp tham gia tailnet.
Node đang hoạt động, tailscale status có vẻ đúng, nhưng curl http://nginx-demo/ bị treo. App không listen ở vị trí bạn nghĩ. Kiểm tra từ bên trong shared namespace bằng docker compose exec nginx wget -qO- http://localhost/. Nếu lệnh này cũng fail, vấn đề nằm ở app, không phải Tailscale. Nếu lệnh thành công, app đang bind vào một interface thay vì tất cả interface.
Máy biến mất khỏi console một giờ sau khi bạn dừng stack. Key là ephemeral. Node sẽ bị xóa sau 30 đến 60 phút kể từ hoạt động cuối cùng. Đây là hành vi đúng của tính năng này.
Từ version 1.78, image có thể expose endpoint /healthz không yêu cầu authentication: đặt TS_ENABLE_HEALTH_CHECK=true; endpoint này listen trên TS_LOCAL_ADDR_PORT, mặc định là [::]:9002. Trỏ một Compose healthcheck vào endpoint đó để node fail authentication được báo là unhealthy thay vì vẫn hiển thị như đang hoạt động. Nếu không muốn phụ thuộc vào coordination server của Tailscale, cùng compose file có thể kết nối đến control plane của bạn thông qua TS_EXTRA_ARGS=--login-server=https://headscale.example.com. Đây là điểm bắt đầu để chạy Headscale làm control server riêng của bạn.
FAQ
Vì sao các thiết bị khác trong tailnet không thể truy cập app container của tôi?
Vì tailscale container chỉ tham gia tailnet cho chính nó. Nếu app chạy trên default Compose bridge network với địa chỉ riêng, tailscale node không forward lưu lượng đến app, và cách duy nhất để truy cập là qua host port đã publish. Gán cho app network_mode: service:tailscale để app dùng chung network namespace của tailscale container, rồi xóa block ports: của app. Sau đó, app có thể được truy cập trên tailnet bằng port mà app đang listen.
Tôi có cần /dev/net/tun để chạy Tailscale trong Docker Compose không?
Không cần nếu chỉ truy cập inbound. TS_USERSPACE mặc định là true. Ở chế độ này, tailscaled chạy network stack riêng và forward các kết nối tunnel đến cùng port trên localhost. Vì vậy, sidecar hoạt động mà không cần device hoặc capability bổ sung. Bạn cần /dev/net/tun, TS_USERSPACE=false và net_admin khi container phải mở các kết nối outbound đến tailnet một cách trong suốt, hoặc hoạt động như subnet router hay exit node.
Tôi nên dùng auth key ephemeral hay reusable cho một Compose stack?
Với mọi stack chạy lâu dài, hãy dùng reusable key có tag và không phải ephemeral. Việc gắn tag sẽ tắt thời hạn của node key. Nhờ đó, container không bị loại khỏi tailnet sau 180 ngày chỉ vì đang chờ ai đó đăng nhập lại cho node. Chỉ chọn ephemeral cho các stack thường xuyên bị xóa, chẳng hạn preview environment. Tailscale sẽ xóa ephemeral device từ 30 đến 60 phút sau lần hoạt động cuối cùng, giúp admin console luôn gọn.
Vì sao container của tôi xuất hiện như một máy mới mỗi lần restart?
State directory không được persist, nên tailscaled khởi động mà không có identity và authenticate như một node hoàn toàn mới. Đặt TS_STATE_DIR=/var/lib/tailscale, biến này không có default bên ngoài Kubernetes, rồi mount path đó vào bind mount hoặc named volume. Chỉ đặt một trong hai cấu hình này sẽ vẫn có vẻ đúng cho đến lần docker compose down đầu tiên. Khi stack đã dừng, hãy xác nhận tailscaled.state tồn tại trong directory được mount.