Tailscale Replaced My VPN
Written on October 18th, 2025 by Cody SniderI run services on AWS, on Hetzner, and on a home server, and the piece of the stack that always broke first was the network layer. Connect three networks and you get site-to-site VPNs: IPsec tunnels, routing tables, firewall rules, and the standing fear of a misconfig that locks you out of your own infrastructure. Tailscale deleted that part of the stack.
Skip the VPN Gateway
A traditional VPN is hub and spoke. Everything routes through a gateway, and when the gateway has a bad day, the whole multi-cloud setup goes with it. Tailscale does not have that shape. It is WireGuard between your devices: your laptop talks directly to the EC2 instance, and the Hetzner box talks directly to the home server. Tailscale’s control plane only coordinates who is allowed to talk to whom, and the traffic itself goes peer to peer.
WireGuard is the right protocol for this: fast, secure, and a spec that fits on a few pages. Tailscale adds the part you would otherwise do by hand, NAT traversal, key distribution, and access control. You get the WireGuard performance without ever managing keys yourself.
Getting It on the Boxes
Install on the EC2 instance, and advertise the VPC’s CIDR:
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --advertise-routes=10.0.0.0/16
That flag is the whole trick. Anything else on your Tailscale network can now reach any resource in that VPC, not just the box running Tailscale.
Same move on the bare metal server, with whatever CIDR your LAN actually is:
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --advertise-routes=192.168.1.0/24
And on a development laptop:
tailscale up
That’s it. SSH to any of the servers, hit the internal databases, call the private APIs, all of it as if everything was on one LAN. No gateway, no routing tables, no firewall gymnastics.
Access Control
The Tailscale admin console takes an ACL in JSON, and it is version controlled like anything else you care about:
{
"acls": [
{
"action": "accept",
"src": ["group:devs"],
"dst": ["tag:aws-prod:22", "tag:aws-prod:443"]
},
{
"action": "accept",
"src": ["tag:aws-prod"],
"dst": ["tag:database:5432"]
}
],
"groups": {
"group:devs": ["user@example.com"]
},
"tagOwners": {
"tag:aws-prod": ["group:devs"],
"tag:database": ["group:devs"]
}
}
The dev group gets SSH and HTTPS on the production tag, and production can reach the database. A developer’s laptop still cannot reach the database directly. Tags get attached when the device comes up:
tailscale up --advertise-tags=tag:aws-prod
Access control becomes declarative. You define who reaches what in JSON, and the change applies across the whole network the moment you save.
Subnet Routing
The --advertise-routes flag turns a node into a gateway. Approve the route in the admin console, and the entire cloud network comes through that one machine. On AWS, run this on a small instance in the VPC:
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
tailscale up --advertise-routes=10.0.0.0/16 --accept-routes
The forwarding lines let Linux route between interfaces. Tailscale does the rest. After that, every resource in the VPC is reachable from your laptop with no public IP and no bastion host. Databases, internal APIs, monitoring dashboards, all behind the tailnet.
Exit Nodes
A Tailscale node can route your entire internet connection through itself, which is what I want on untrusted networks. On a box you trust:
tailscale up --advertise-exit-node
Approve it in the admin console, then on the laptop:
tailscale up --exit-node=trusted-server
Your ISP sees encrypted WireGuard traffic going to Tailscale, and the sites you visit see the exit node’s IP. No per-region configuration, works from wherever you happen to be.
No Public IPs for Services
If a service only needs to be reachable by your infrastructure, it should not have a public IP at all. Bind it to the Tailscale interface instead. PostgreSQL, for instance:
listen_addresses = '100.64.x.x'
That is the database server’s Tailscale IP. PostgreSQL is now unreachable from the internet, and reachable only through the tailnet. No security groups to configure, no allowlist to maintain. Redis, internal APIs, admin panels, monitoring, same move: if it does not need the internet, it does not get a public address.
Containers
Docker services get the official sidecar:
version: '3'
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: api-server
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_STATE_DIR=/var/lib/tailscale
- TS_EXTRA_ARGS=--advertise-tags=tag:api
volumes:
- ./tailscale:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- SYS_MODULE
restart: unless-stopped
api:
image: your-api:latest
network_mode: "service:tailscale"
depends_on:
- tailscale
The API container shares the sidecar’s network namespace, so it is reachable on the tailnet and invisible to the internet. The auth key is one line from the admin console, parked in .env:
TS_AUTHKEY=tskey-auth-xxxxxxxxx
The container signs in when it boots and you never touch it again.
Where It Falls Down
If everything lives in one cloud and nothing is outside it, use the provider’s VPC, and Tailscale is a tax you do not need to pay. If you need a bandwidth or latency SLA, run a dedicated line, because peer to peer with relays in the middle is not the thing you contract against. And if compliance will not let a third party coordinate your network, self-host Headscale, which speaks the same control protocol on infrastructure you own.
How I Would Roll It Out
Start with one development server and your laptop. Verify you can reach it, then add ACLs that restrict by user and port. Add a production server under a different tag, tighten the ACLs, and verify that developers reach development but not production. Put subnet routing on a gateway in the cloud VPC, verify you can reach the internal resources, and start moving services to their Tailscale IPs instead of public addresses, one at a time.
Every new server gets Tailscale on the day it comes up, and every new service binds to the tailnet. The old VPN configuration gets deleted when nothing needs it anymore, and after that the network layer stops being something you think about.