Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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 5301 will never match — the packet counter stays at 0. Docker already rewrote it to 8090 before 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 in column is blank, you forgot -i ens3. Flush and re-add.
  • If pkts stays at 0 on 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

CommandDescription
ip route | grep defaultFind your public network interface
sudo update-alternatives --display iptablesCheck iptables backend
sudo iptables -L DOCKER-USER -n --line-numbers -vList rules with packet counters
sudo iptables -I DOCKER-USER -i tailscale0 -j ACCEPTAllow Tailscale traffic
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport <container-port> -j DROPBlock a port from public
sudo iptables -F DOCKER-USERFlush all DOCKER-USER rules (start over)
sudo iptables -D DOCKER-USER <num>Delete a rule by line number
sudo netfilter-persistent saveSave 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 tcp with -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 the ports: 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.