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

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:

MetricValue
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:

FileJob
versions.tfpins the two providers
providers.tffeeds credentials (from gitignored terraform.tfvars) to both APIs
variables.tfregion, droplet size, hostname, tag — with sensible defaults
tailscale.tfthe ephemeral auth key + the tailnet policy
droplet.tfthe droplet and its DO tag
cloud-init.yaml.tftplbootstrap template the droplet runs at first boot
outputs.tfprints droplet IP, SSH command and next steps after apply
Makefileup / 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

ScenarioMonthly 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 up is 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

LayerWhat
Terraform 1.16digitalocean provider v2.103, tailscale provider v0.29
DigitalOcean1 droplet (ubuntu-24-04-x64), ufw, no public inbound ports
TailscaleOAuth client (never expires), ephemeral tagged auth keys, autoApprovers policy, MagicDNS hostname us-exit
WrapperMakefile (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.