Tailscale-Only Services
Overview
By default, Docker publishes ports to all network interfaces (0.0.0.0), making services reachable from both the public internet and your Tailscale network. This guide shows how to restrict specific services to your Tailscale network without modifying docker-compose.yml, Caddy, or domain DNS.
Prerequisites
- Tailscale Setup — your VPS must be joined to your tailnet
- Docker Compose — services running in Docker
- UFW Setup — firewall basics (optional but recommended)
- SSH access to your VPS
Before You Begin
1. Check your iptables backend
On Ubuntu 22.04+, there are two iptables backends: iptables-nft (nftables-based) and iptables-legacy. Docker uses iptables-nft by default. If your iptables command points to the legacy version, your rules will silently fail — they’ll appear in the output but won’t actually affect Docker traffic.
Check which backend you’re using:
sudo update-alternatives --display iptables
If the output shows iptables-legacy, switch to iptables-nft:
sudo update-alternatives --set iptables /usr/sbin/iptables-nft
sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-nft
2. Find your actual public network interface
ip route | grep default
Look for the interface name after dev. Common names include eth0, ens3, ens5, or eth1. Use this exact name in the commands below — using the wrong interface name is the most common reason these rules don’t work.
Example output:
default via 192.168.1.1 dev ens3 proto dhcp src 15.235.186.232 metric 100
→ Your interface is ens3. Use -i ens3 in all commands below.
How It Works
Docker’s nat table rewrites the destination port (DNAT) before the packet reaches the DOCKER-USER chain. This is the key concept:
Your compose: ports: "5301:8090"
↓
Internet arrives: ens3:5301
↓
Docker DNAT: 5301 → 8090 (rewritten)
↓
DOCKER-USER sees: destination port 8090 (NOT 5301)
So your iptables rules must match the container port (right side of host:container), not the host port (left side).
Steps
3. Add iptables rules
First, allow all Tailscale traffic. Then block public traffic to specific container ports:
# Allow Tailscale traffic on all ports (must come first)
sudo iptables -I DOCKER-USER -i tailscale0 -j ACCEPT
# Block Beszel (compose has "5301:8090" → match container port 8090)
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 8090 -j DROP
# Block n8n (compose has "5302:5678" → match container port 5678)
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 5678 -j DROP
⚠️ Critical: Use the container port (right side), not the host port (left side). A rule with
--dport 5301will never match — the packet counter stays at 0. Docker already rewrote it to8090before this chain runs.
You do not need to change docker-compose.yml or restart containers.
4. Persist rules across reboots
sudo apt install -y iptables-persistent
sudo netfilter-persistent save
Select Yes when prompted to save current IPv4 and IPv6 rules.
5. Verify the rules are active
Use -v (verbose) to see the interface column and packet counters:
sudo iptables -L DOCKER-USER -n --line-numbers -v
Expected output:
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 112 6892 ACCEPT 0 -- tailscale0 * 0.0.0.0/0 0.0.0.0/0
2 0 0 DROP 6 -- ens3 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8090
3 0 0 DROP 6 -- ens3 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:5678
The pkts counter on the DROP rules will increase each time someone tries to access those ports publicly. A counter stuck at 0 means the rule is never matching — usually because you used the host port instead of the container port.
Troubleshooting:
- If the
incolumn is blank, you forgot-i ens3. Flush and re-add.- If
pktsstays at0on DROP rules but the port is still publicly accessible, you used the host port. Flush and re-add with the container port.sudo iptables -F DOCKER-USER sudo iptables -I DOCKER-USER -i tailscale0 -j ACCEPT sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 8090 -j DROP sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 5678 -j DROP sudo netfilter-persistent save
6. Find your Tailscale address
Option A: Tailscale IP (always works)
Get your VPS’s Tailscale IP:
tailscale ip -4
# → 100.64.x.x
Use it directly:
http://100.64.x.x:8090
This works regardless of DNS configuration.
Option B: Magic DNS (if enabled)
If your tailnet has Magic DNS enabled, Tailscale assigns each machine a name. Check yours:
tailscale status
The output shows your machine name (e.g., vps). Depending on your tailnet’s DNS setup, you may be able to reach it as:
http://vps:8090
Or with a full domain if your tailnet uses one (e.g., vps.your-tailnet.ts.net for hosted Tailscale, or a custom domain for Headscale).
If you’re unsure whether Magic DNS is configured, use Option A (the Tailscale IP). It always works.
7. Test access
From a Tailscale-connected device:
curl -I http://<tailscale-address>:8090
# Expected: HTTP 200
From a non-Tailscale device (e.g., mobile data):
curl -I --connect-timeout 5 http://<your-vps-public-ip>:8090
# Expected: timeout / no response
Adding More Services
Whenever you deploy a new private service, find its container port (the right side of the ports mapping in docker-compose.yml) and add a DROP rule:
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport <container-port> -j DROP
sudo netfilter-persistent save
For example, if your compose has ports: - "9999:3000", block port 3000 (not 9999).
No container restarts or compose changes are required.
Removing a Rule
List current rules with line numbers:
sudo iptables -L DOCKER-USER -n --line-numbers
Delete by number:
sudo iptables -D DOCKER-USER <number>
sudo netfilter-persistent save
Key Commands
| Command | Description |
|---|---|
ip route | grep default | Find your public network interface |
sudo update-alternatives --display iptables | Check iptables backend |
sudo iptables -L DOCKER-USER -n --line-numbers -v | List rules with packet counters |
sudo iptables -I DOCKER-USER -i tailscale0 -j ACCEPT | Allow Tailscale traffic |
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport <container-port> -j DROP | Block a port from public |
sudo iptables -F DOCKER-USER | Flush all DOCKER-USER rules (start over) |
sudo iptables -D DOCKER-USER <num> | Delete a rule by line number |
sudo netfilter-persistent save | Save rules to survive reboots |
Notes
- Docker Compose files remain unchanged. The
ports:mapping stays as-is; iptables handles the restriction at the network layer. - Traffic from
tailscale0,lo, and other interfaces is not affected by the DROP rules. - This method works for any TCP service. For UDP services, replace
-p tcpwith-p udp. - Multiple containers with the same port: If two containers use the same container port (e.g., both map to
:8090), blocking that port affects both. To avoid this, either use different container ports in each compose file, or remove theports:mapping entirely for services that are only accessed through Caddy’s internal network.
Blocking a Single Container Instead of a Port
If you need to block one specific container without affecting others on the same port, block by container IP instead:
# Find the container's IP
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <container-name>
# Block public traffic to that specific container IP
sudo iptables -I DOCKER-USER -i ens3 -d <container-ip> -j DROP
sudo netfilter-persistent save
Warning: Container IPs change when you recreate the container (
docker compose up -d --force-recreate). You’ll need to update the rule after each recreation.