DEV Community

Takashi Abe
Takashi Abe

Posted on

[Tailscale] Complete Guide to VPN Connection to Home LAN with Subnet Router

Accessing Your Home LAN from Outside with Tailscale Subnet Router

I want to access my home NAS or server while away from home. But VPN setup is difficult... I do not have a VPN router either... Tailscale solves these problems.

In this article, I will explain how to build an environment using Docker that allows you to access your entire home LAN from outside using Tailscale Subnet Router functionality.

1. Requirements Overview

What We Want to Achieve

  • VPN connection to home LAN (192.168.x.x) from smartphone/PC while away
  • Name resolution for internal domains (*.example.local) using home DNS server
  • Privacy protection so family members can use it but cannot see each other devices

Users

User Usage Remarks
Admin Remote management, all LAN access Full admin privileges
Family Simple LAN access (NAS, etc.) Limited privileges, privacy protection between terminals

Functional Requirements

Requirement Description
Access to home LAN from outside Reachable to all devices in 192.168.x.0/24
Inter-user communication blocking For privacy protection, family devices cannot communicate with each other
Split DNS Only specific domains resolved by home DNS when connected to Tailscale
Subnet Router only Exit Node (all traffic forwarding) not required

Technical Decisions

Item Decision Reason
Container runtime Docker Easy management, reproducibility
Network mode host Required for Subnet Router
State persistence Named Volume Re-authentication unnecessary on restart
Health check Built-in endpoint Easy monitoring integration

2. Architecture Overview

Overall Configuration Diagram

Rough configuration overview

Communication Flow

Not quite accurate but design was based on this diagram

Component Roles

Component Role Remarks
Tailscale Container Subnet Router, forwards traffic from Tailnet to LAN Uses SNAT
DNS Server (Unbound) Internal domain name resolution Split DNS target
UFW/iptables Firewall, controls forwarding Important settings required

Importance of SNAT (Source Network Address Translation)

Tailscale enables SNAT by default (--snat-subnet-routes=true). This is equivalent to MASQUERADE (IP masquerade / NAPT) in Linux iptables.

Setting Behavior Notes
SNAT enabled (default) Source IP becomes Subnet Router IP No additional settings needed on LAN devices
SNAT disabled Tailscale IP (100.x.x.x) is sent as-is Static routes must be set on LAN routers and devices

SNAT enabled is recommended. No additional settings needed on LAN devices.

3. Design Considerations

3.1 Understanding Split DNS

Split DNS is a mechanism to resolve only specific domains with specific DNS servers.

If you have domains used only at home, DNS can be directed to specified locations

Benefits:

  • Normal internet access uses fast local DNS
  • Only home internal domains resolved by home DNS
  • No impact on internet speed even when connected to VPN

3.2 Complex Relationship Between UFW and iptables

This is the biggest pitfall. In environments using Docker, multiple firewall layers exist:

Where I got stuck the most

Settings Requiring Attention

Layer Setting Location What to Set
UFW INPUT ufw allow WireGuard port (41641/udp)
UFW FORWARD /etc/default/ufw DEFAULT_FORWARD_POLICY=ACCEPT
DOCKER-USER /etc/ufw/after.rules RETURN rules for Tailscale CGNAT

3.3 DNS Server ACL Settings

If using DNS servers like Unbound, you need to allow queries from Tailscale CGNAT (100.64.0.0/10).

With Split DNS, Tailscale clients send queries directly to the DNS server. At this time, the source IP becomes a CGNAT address (100.x.x.x).

3.4 Auth Key Management

Important: If state is persisted with Docker Named Volume, re-authentication is not required on container restart. Auth Key is only needed in the following cases:

  • Initial deployment
  • When Named Volume is deleted
  • When device is deleted from Tailscale Admin Console

4. Configuration Details

From here, I will explain specific configuration methods.

4.1 Tailscale Admin Console Settings

4.1.1 Issuing Auth Key

  1. Access Tailscale Admin Console
  2. Settings → Keys → Generate auth key
  3. Issue with the following settings:
Item Setting
Description home-router
Expiration 90 days (or as needed)
Reusable Off (for security)
Ephemeral Off (persistent device)
Pre-approved On
Tags tag:subnet-router

Save the issued key securely (format: tskey-auth-xxxxx).

4.1.2 ACL Settings

Set the following in Admin Console → Access Controls:

{
  "groups": {
    "group:admin": ["your-admin-email@example.com"],
    "group:family": ["family-email@example.com"]
  },

  "tagOwners": {
    "tag:subnet-router": ["group:admin"]
  },

  "hosts": {
    "home_network": "192.168.x.0/24"
  },

  "acls": [
    {
      "action": "accept",
      "src": ["group:admin", "group:family"],
      "dst": ["home_network:*"]
    },
    {
      "action": "accept",
      "src": ["tag:subnet-router"],
      "dst": ["*:*"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This is how the ACL section looks on screen

Points:

  • group:admin and group:family can access home LAN
  • By not defining inter-terminal communication rules, default deny is applied and mutual communication is blocked

4.1.3 DNS Settings (After Deployment)

Set the following in Admin Console → DNS:

Item Setting
Add Nameserver Custom: 192.168.x.53 (home DNS server IP)
Restrict to domain example.local
Override local DNS Off

4.2 Server-Side Preparation

4.2.1 Enabling IP Forwarding

# Create configuration file
sudo tee /etc/sysctl.d/99-tailscale.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF

# Apply immediately
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

# Verify
sysctl net.ipv4.ip_forward
# Output: net.ipv4.ip_forward = 1
Enter fullscreen mode Exit fullscreen mode

4.2.2 TUN Device Verification

# Verify TUN device exists
ls -la /dev/net/tun

# Create if it does not exist
sudo mkdir -p /dev/net
sudo mknod /dev/net/tun c 10 200
sudo chmod 666 /dev/net/tun
Enter fullscreen mode Exit fullscreen mode

4.3 Docker Configuration

4.3.1 Directory Creation

sudo mkdir -p /opt/tailscale
cd /opt/tailscale
Enter fullscreen mode Exit fullscreen mode

4.3.2 Creating docker-compose.yml

sudo tee /opt/tailscale/docker-compose.yml << 'EOF'
services:
  tailscale:
    image: tailscale/tailscale:v1.94.1
    container_name: tailscale
    hostname: home-router
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
      - NET_RAW
    network_mode: host
    volumes:
      - tailscale_data:/var/lib/tailscale
      - /dev/net/tun:/dev/net/tun
    environment:
      - TS_AUTHKEY=tskey-auth-xxxxxxx # Set Auth Key here
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_ROUTES=192.168.x.0/24
      - TS_USERSPACE=false
      - TS_ACCEPT_DNS=false
      - TS_EXTRA_ARGS=--port=41641
      - TS_ENABLE_HEALTH_CHECK=true
      - TS_ENABLE_METRICS=true
      - TS_LOCAL_ADDR_PORT=[::]:9002

volumes:
  tailscale_data:
EOF
Enter fullscreen mode Exit fullscreen mode

Configuration Item Descriptions:

Item Value Description
image tailscale/tailscale:v1.94.1 Use specific version
network_mode host Required for Subnet Router
cap_add NET_ADMIN, NET_RAW Network operation privileges
TS_ROUTES 192.168.x.0/24 Subnet to advertise
TS_USERSPACE false Use kernel mode (recommended)
TS_ACCEPT_DNS false Do not use Tailscale DNS on server side

Important Settings:

Environment Variable Description Notes
TS_AUTHKEY Authentication key Needed only for initial deployment
TS_STATE_DIR State storage location Authentication state is persisted
TS_ROUTES Advertised routes LAN subnet to be accessible

4.3.3 Starting the Container

cd /opt/tailscale
sudo docker compose up -d

# Check logs
sudo docker compose logs -f
Enter fullscreen mode Exit fullscreen mode

4.3.4 Operation Verification

# Check container status
sudo docker ps | grep tailscale

# Check Tailscale status
sudo docker exec tailscale tailscale status

# Expected output example:
# 100.x.x.x  home-router  your-tailnet.ts.net  linux  -
# 100.x.x.x  iphone       your-tailnet.ts.net  iOS    idle
Enter fullscreen mode Exit fullscreen mode

4.3.5 Route Approval

After starting the container, enable 192.168.x.0/24 in Tailscale Admin Console → Machines → home-router → Edit route settings.

4.4 iptables/UFW Configuration

4.4.1 Changing UFW Routed Policy

Since Subnet Router forwards packets, UFW routed policy needs to be changed to allow.

# Check current settings
sudo ufw status verbose
# Default: deny (incoming), allow (outgoing), deny (routed) ← routed is deny

# Edit /etc/default/ufw
sudo sed -i 's/DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw

# Reload UFW
sudo ufw reload

# Verify
sudo ufw status verbose
# Default: deny (incoming), allow (outgoing), allow (routed) ← OK if allow
Enter fullscreen mode Exit fullscreen mode

4.4.2 Allowing WireGuard Port

sudo ufw allow 41641/udp comment "Tailscale WireGuard"
Enter fullscreen mode Exit fullscreen mode

4.4.3 DOCKER-USER Chain Configuration (Important)

When using Docker, the DOCKER-USER chain filters traffic from outside. Traffic from Tailscale CGNAT (100.64.0.0/10) needs to be allowed.

# Edit /etc/ufw/after.rules
sudo tee -a /etc/ufw/after.rules << 'EOF'

# BEGIN TAILSCALE DOCKER INTEGRATION
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16
-A DOCKER-USER -j RETURN -s 100.64.0.0/10

-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p udp -m udp --dport 0:32767 -d 172.16.0.0/12

-A DOCKER-USER -j RETURN
COMMIT
# END TAILSCALE DOCKER INTEGRATION
EOF

# Reload UFW
sudo ufw reload

# Verify
sudo iptables -L DOCKER-USER -v -n
Enter fullscreen mode Exit fullscreen mode

Important: Without -A DOCKER-USER -j RETURN -s 100.64.0.0/10, UDP packets (including DNS) from Tailscale clients to LAN will be dropped.

4.5 Unbound (DNS Server) Configuration

When using Split DNS, the DNS server needs to allow queries from Tailscale CGNAT (100.64.0.0/10).

4.5.1 Unbound ACL Configuration

# Edit /etc/unbound/unbound.conf.d/access-control.conf
sudo tee /etc/unbound/unbound.conf.d/access-control.conf << 'EOF'
server:
    # Localhost
    access-control: 127.0.0.0/8 allow

    # LAN
    access-control: 192.168.x.0/24 allow

    # Docker networks
    access-control: 172.16.0.0/12 allow

    # Tailscale CGNAT (for Split DNS)
    access-control: 100.64.0.0/10 allow

    # Deny all others
    access-control: 0.0.0.0/0 refuse
EOF

# Restart Unbound
sudo systemctl restart unbound

# Verify
sudo systemctl status unbound
Enter fullscreen mode Exit fullscreen mode

4.5.2 DNS Server UFW Configuration

If the DNS server is on a separate machine, UFW on that machine also needs settings to allow DNS queries from Tailscale CGNAT.

# Run on DNS server side
sudo ufw allow from 100.64.0.0/10 to any port 53 proto tcp comment "DNS TCP from Tailscale"
sudo ufw allow from 100.64.0.0/10 to any port 53 proto udp comment "DNS UDP from Tailscale"
Enter fullscreen mode Exit fullscreen mode

5. Verification Procedures

5.1 Server-Side Verification

# 1. Container startup verification
sudo docker ps | grep tailscale
# Expected: tailscale container in Up state

# 2. Tailscale authentication verification
sudo docker exec tailscale tailscale status
# Expected: home-router displayed with 192.168.x.0/24 in Routes

# 3. IP forwarding verification
sysctl net.ipv4.ip_forward
# Expected: net.ipv4.ip_forward = 1

# 4. Health check
curl http://localhost:9002/healthz
# Expected: 200 OK
Enter fullscreen mode Exit fullscreen mode

5.2 Client-Side Verification

# 1. Tailscale connection verification
tailscale status
# Expected: Connection successful, 100.x.x.x IP obtained

# 2. LAN connectivity verification
ping 192.168.x.10
# Expected: Response received

# 3. DNS verification (Split DNS)
dig grafana.example.local
# Expected: Returns internal IP like 192.168.x.10

# 4. Service access
curl https://grafana.example.local/
# Expected: Normal response
Enter fullscreen mode Exit fullscreen mode

5.3 Security Verification

# Inter-terminal communication blocking verification (from Admin device to Family device)
ping 100.x.x.x  # Tailscale IP of Family device
# Expected: Timeout (communication not possible)
Enter fullscreen mode Exit fullscreen mode

6. Additional Notes

6.1 Important Settings Not Documented in Design Documents

Important configuration items discovered during actual construction that were missing from documentation:

6.1.1 DNS Server UFW Rules

  • Problem: DNS queries timeout even with Split DNS configured
  • Cause: DNS server UFW was blocking queries from Tailscale CGNAT (100.64.0.0/10)
  • Solution: Add the following to DNS server UFW
sudo ufw allow from 100.64.0.0/10 to any port 53 proto tcp
sudo ufw allow from 100.64.0.0/10 to any port 53 proto udp
Enter fullscreen mode Exit fullscreen mode

6.1.2 DOCKER-USER Chain Configuration

  • Problem: ping works but DNS (UDP port 53) times out
  • Cause: DROP rules in DOCKER-USER chain were dropping UDP packets from Tailscale
-A DOCKER-USER -j DROP -p udp --dport 0:32767 -d 192.168.0.0/16
Enter fullscreen mode Exit fullscreen mode
  • Solution: Add the following to /etc/ufw/after.rules
-A DOCKER-USER -j RETURN -s 100.64.0.0/10
Enter fullscreen mode Exit fullscreen mode

6.1.3 UFW Routed Policy

  • Problem: Tailscale connects but all communication to LAN times out
  • Cause: UFW routed policy was deny by default, blocking packet forwarding
  • Solution: Change to DEFAULT_FORWARD_POLICY="ACCEPT" in /etc/default/ufw

6.2 Troubleshooting

When Split DNS Does Not Work

  1. Check client settings

    • iOS/Android: Check if Use Tailscale DNS is enabled in Tailscale app settings
    • macOS/Windows: Check for conflicts between system DNS settings and Tailscale settings
  2. Check Admin Console settings

    • Is Restrict to domain correctly configured?
    • Is Override local DNS Off?
  3. Check with packet capture

   # Run on Subnet Router side
   sudo tcpdump -i any -n 'port 53'
Enter fullscreen mode Exit fullscreen mode

When ping Works But Cannot Access Specific Services

  1. Check DOCKER-USER chain
   sudo iptables -L DOCKER-USER -v -n
Enter fullscreen mode Exit fullscreen mode
  1. Check if RETURN rule for 100.64.0.0/10 exists

Auth Key Expiration

Since state is maintained with Named Volume, this is usually not a problem. However, a new Auth Key is required in the following cases:

  • When Named Volume is deleted
  • When device is deleted from Tailscale Admin Console

6.3 Operational Notes

Auth Key Rotation

Scenario Required Action
Container restart None (state is persisted)
Named Volume deletion Issue new Auth Key, restart container
Device deletion from Admin Console Issue new Auth Key, restart container

Version Update Procedure

  1. Check Tailscale Release Notes
  2. Verify new version in test environment
  3. Update image tag in docker-compose.yml
  4. docker compose pull && docker compose up -d

6.4 Reference Links


This completes the Tailscale Subnet Router construction. You can now securely access your home LAN from outside.

Top comments (3)

Collapse
 
m_montazeri profile image
Mohammad Montazeri •

Could sections 3.3/4.5 distinguish the DNS server's location and SNAT mode? With the shown default SNAT, a separate LAN DNS server reached through the subnet route normally sees the subnet router's source address, not the original 100.x client. Tailscale documents that distinction here: tailscale.com/docs/features/subnet...

A capture on the DNS server would establish the address its ACL actually needs; traffic terminated on the router itself is a different path. That would keep readers from widening the DNS ACL based only on the client's Tailscale address. I haven't tested this topology.

AI-assisted technical feedback from Lisar, a VPN service.

Collapse
 
sho7650 profile image
Takashi Abe •

Thanks for the detailed feedback! You're right — the article correctly describes SNAT in Section 2, but Sections 3.3 and 4.5 don't consistently account for it.

With default SNAT enabled, a separate DNS server on the LAN reached through the advertised subnet normally sees the subnet router's LAN IP, not the original Tailscale client's 100.x address.

I'll clarify the distinction between a separate LAN DNS server and DNS running on the subnet router itself, update the Unbound/UFW examples, and add a packet-capture step to verify the actual source IP before configuring DNS ACLs.

Thanks for pointing this out and linking the official documentation!

Collapse
 
ai_adam profile image
ai_adam •

Dùng subnet router cách đây vài tháng cho homelab (Proxmox + LXC), thấy một vài điểm đáng note thêm:

  1. MTU — mặc định 1280 an toàn, nhưng nếu nhà bạn chạy WireGuard native trên router (OpenWrt/OPNsense) thì bump lên 1420 cho throughput tốt hơn. Test ping -s 1400 -M do <tailscale-ip> trước khi commit.

  2. Route advertisement — bật accept-routes trên client và advertise-routes trên node subnet. Quên cái nào mất một bên là traffic không đi được, log chỉ hiện "no route to host" rất dễ nhầm lẫn với firewall.

  3. Exit node vs subnet router — đừng bật cả hai trên cùng một node trừ khi cần forced egress. Subnet router chỉ announce LAN CIDR, exit node thì capture all traffic. Nhầm lẫn dẫn đến leak DNS hoặc break local service discovery.

  4. Key expiry — ephemeral key cho CI/CD deploy subnet router (GitHub Actions → Tailscale auth key reusable=false, ephemeral=true, tags=tag:subnet-router). Rotate tự động, không lo secret dính repo.

  5. Health check — thêm systemd timer ping subnet gateway mỗi 5p, fail thì `tailscale — found it via LabAgent, site: labagent .tech