Stop Paying for a 24/7 VPN Server: On-Demand Exit Nodes with Tailscale and Terraform

$0.01. That’s what my first full test session of on-demand US VPN cost — a cloud server created in ~35 seconds, up for a quarter of an hour, destroyed in 22 seconds. Billing rate while it exists: $0.006 an hour. Billing rate when it doesn’t: zero.
No VPN subscription, no always-on VPS, no shared commercial VPN IP that every streaming service has already blacklisted. The whole setup — four Terraform resources, a hardened cloud-init bootstrap, a Tailscale policy and a Makefile wrapper — was built in a single evening with an AI coding agent (GLM-5.3 via OpenCode).
The problem with “just get a VPN”
I wanted a US IP address on demand — geo-blocked services, testing, the usual. I already had a Tailscale account (free plan, a personal tailnet with a handful of devices) and a DigitalOcean account. What I did not want:
- another $5–6/month subscription for a “VPN provider” I use a few hours a month
- a VPS running 24/7 that I forget to turn off and pay for anyway
- commercial VPN shared IPs that are already blacklisted everywhere
The insight is embarrassingly simple: DigitalOcean bills per hour — the smallest droplet is ~$0.006/hr, capped at $4/month — and a Tailscale exit node turns any Linux box into a private VPN endpoint in one command. So: create the box when you need it, destroy it when you’re done. Total cost per session: cents.
One trap to call out immediately, because it shapes the entire design: a powered-off droplet still bills. “Stop” is not the cheap option — destroy is. That’s why everything below is built around destroy-fast, not stop-and-hope.
Two commands
Day-to-day usage is exactly two commands:
make up # create droplet in NYC + register it as Tailscale exit node
# ~90 s until it's online and approved; prints IP + next steps
make down # destroy everything; billing stops that second
After make up, on your laptop or phone: Tailscale app → Exit node → us-exit.
Verify:
curl https://ifconfig.me # prints the droplet's IP → you're in the US
Architecture, in one line:
your devices ──(encrypted WireGuard via Tailscale)──> droplet "us-exit" (DO, nyc3) ──> internet
Tailscale encrypts device→droplet end to end — not even the cloud provider can read the traffic. The web sees the droplet’s US IP.
Numbers from the first real session, measured 2026-10-04:
| Metric | Value |
|---|---|
| Cheapest droplet (s-1vcpu-512mb-10gb, nyc3) | $0.00595/hr, $4/mo cap |
| Droplet created (terraform apply) | ~35 s |
| Exit node fully online + auto-approved | ~60–90 s after creation |
Destroy (make down) | 22 s |
| First full test session (droplet up ~15 min) | $0.01 |
| Idle (destroyed) | $0.00 |
From make up to “your traffic exits in New York”: roughly two minutes.
Why Terraform at all
A fair question — a shell script with two API clients could create four cloud resources. Two things tipped the balance.
First, the shape of the problem: an exit node lives in two cloud worlds at once — the droplet in DigitalOcean, the credential, tag and approval policy in Tailscale. Doing that by hand means two web consoles in the right order every time; forget one side and you leak billing or leave a ghost machine in your tailnet. Terraform models both sides as one dependency graph with one command per direction.
Second, honestly: I’d been looking for a meaningful little project to try Terraform on, and this is perfectly sized — real resources, real credentials, real cleanup semantics, no enterprise ceremony.
The mental model in three sentences: you write declarative config (“a
droplet with these properties exists”), Terraform compares it with the recorded
state of the real world, and plan / apply / destroy reconcile the
two. The state file is the single source of truth about what you own.
The project, file by file
The whole thing is a handful of small files, each with exactly one job:
| File | Job |
|---|---|
versions.tf | pins the two providers |
providers.tf | feeds credentials (from gitignored terraform.tfvars) to both APIs |
variables.tf | region, droplet size, hostname, tag — with sensible defaults |
tailscale.tf | the ephemeral auth key + the tailnet policy |
droplet.tf | the droplet and its DO tag |
cloud-init.yaml.tftpl | bootstrap template the droplet runs at first boot |
outputs.tf | prints droplet IP, SSH command and next steps after apply |
Makefile | up / down / status on top of raw terraform |
The interesting part of versions.tf is required_providers — what makes a
two-vendor setup one project instead of two:
terraform {
required_version = ">= 1.6"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.29"
}
}
}
terraform init downloads both; after that, apply and destroy drive both
vendors through one dependency graph.
And the Makefile is deliberately a thin wrapper — everything it does is plain terraform underneath:
up:
terraform apply -auto-approve
bash scripts/status.sh --wait
down:
terraform destroy -auto-approve
How it works: four resources, two interesting ones
The two cloud-side resources are obvious — a droplet and a tag. The two Tailscale-side resources are where the design got interesting.
1. An ephemeral, single-use, pre-authorized auth key
The droplet needs a credential to join your tailnet. Instead of a long-lived key, Terraform mints a fresh one on every apply — valid 1 hour, usable once:
resource "tailscale_tailnet_key" "exit_node" {
reusable = false
ephemeral = true # the magic, see below
preauthorized = true # no device-approval click
tags = ["tag:exitnode"]
expiry = 3600 # 1 hour
recreate_if_invalid = "always"
}
ephemeral = true is the cleanup mechanism: when the node disappears (droplet
destroyed), Tailscale auto-removes it from the tailnet within
~15 minutes. Without it, every up/down cycle leaves an “offline” ghost machine
in your admin console. Self-cleaning infrastructure.
2. autoApprovers in the tailnet policy
Exit nodes must normally be approved by an admin — a manual console click every time a new node appears. And every up cycle creates a new node. Terraform manages the policy so approval is automatic:
resource "tailscale_acl" "exit_node" {
acl = jsonencode({
autoApprovers = { exitNode = ["tag:exitnode"] }
grants = [{ src = ["*"], dst = ["*"], ip = ["*"] }]
})
reset_acl_on_destroy = true
}
Zero-click approval for a brand-new server, every time.
3. Cloud-init hardens the droplet
No SSH keys, no password login, no open ports. The full bootstrap is a
#cloud-config template:
runcmd:
- curl -fsSL https://tailscale.com/install.sh | sh
- |
tailscale up \
--auth-key=${auth_key} \
--advertise-exit-node \
--ssh \ # SSH only via the tailnet
--accept-dns=false
- ufw default deny incoming # nothing public gets in
- ufw allow 41641/udp # except WireGuard itself
- ufw allow in on tailscale0 # and tailnet traffic
- ufw --force enable
(IP forwarding is enabled via sysctl — required for an exit node.)
Debug access is ssh root@us-exit through Tailscale SSH from any tailnet
device — or the provider’s web console as out-of-band recovery. Attack surface
from the public internet: zero TCP ports.
4. How the key reaches the droplet
The one-time credential has to travel from Tailscale’s API into a server that doesn’t exist yet. Terraform solves this by rendering the cloud-init template at apply time, substituting values from the dependency graph — including the freshly minted key:
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = var.hostname
auth_key = tailscale_tailnet_key.exit_node.key
tailscale_tag = var.tailscale_tag
})
The chain, end to end: Terraform creates the key in the Tailscale API → the
provider holds the key material in memory → templatefile() bakes it into the
cloud-init YAML → the droplet boots with that YAML as user_data → first-boot
tailscale up --auth-key=... spends it exactly once. The key never
touches my disk, never gets committed, and expires within the hour regardless.
5. One Terraform gotcha worth a paragraph
user_data changes force droplet replacement — Terraform’s default when an
attribute can only change by recreating the resource. But the auth key rotates
on every apply, so a plain re-apply would kill a running box mid-session. The
fix opts that one attribute out of diffing:
lifecycle { ignore_changes = [user_data] }
The key is baked in at creation, and re-applies never touch a running box.
What it actually costs
| Scenario | Monthly cost |
|---|---|
| Commercial VPN | $5–12, always on |
| 24/7 VPS exit node | $4–6, always on, must maintain |
| This, 4 sessions × 3 h/month | ~$0.07 |
The monthly cap protects you: even if you leave the droplet running all month,
it’s $4. The failure mode of forgetting make down costs less than the
subscription you replaced.
Honest limitations
- Every
make upis a new IP address — fine for geo-unblocking, the wrong tool for IP allowlists. - Exit-node selection on each client device is manual (2 clicks) after up — Tailscale doesn’t let a server flip a client’s setting, which is reasonable.
- After
make down, deselect the exit node on your devices or traffic stalls until the app notices the node is gone. - The Terraform state file contains secrets — this is a single-user, single-machine setup, and it should stay that way.
- Throughput on a $0.006 droplet: fine for browsing and HD video. It’s a 1 vCPU / 512 MB box, not a speed demon.
The stack
| Layer | What |
|---|---|
| Terraform 1.16 | digitalocean provider v2.103, tailscale provider v0.29 |
| DigitalOcean | 1 droplet (ubuntu-24-04-x64), ufw, no public inbound ports |
| Tailscale | OAuth client (never expires), ephemeral tagged auth keys, autoApprovers policy, MagicDNS hostname us-exit |
| Wrapper | Makefile (up/down/status) + status script: droplet state, tailnet state, running cost |
Final thoughts
This is infrastructure that costs nothing when idle and one command when needed — a VPN that exists exactly as long as you need it and not a second longer. It took one evening and four Terraform resources to get there, and it permanently retired both the “should I get a VPN subscription” question and the “did I forget to turn off that VPS” anxiety. That’s a good trade for $0.01.
And the obvious next step: wrap make up and make down as a skill or an MCP
tool, and the VPN becomes something your AI agent operates on its own — “get
me a US IP for this stream” as one sentence, the agent spinning up the
droplet, waiting for the exit node and tearing everything down when the stream
ends. Infrastructure you don’t even have to remember to use.
The second use is less obvious: a disposable machine for AI agents. When an experiment needs a greenfield box — a fresh toolchain, a system dependency you don’t want anywhere near your laptop, a test that must start from nothing — there’s no docker image to build and no cleanup checklist: spin it up for cents, let the agent do its special project, destroy, and the environment is gone as if it never existed. The exit node was the excuse to build this; the reusable knowledge is that a clean throwaway server is now the cheapest part of any experiment.