VPS as Code: Terraform + cloud-init to Go from Nothing to a Tailscale-Joined Server in One Command

2026-11-05 · Unix3r

Stop clicking through provider dashboards. Define your VPS in Terraform, have cloud-init harden it and join it to your tailnet on first boot, and hand off to Ansible for the rest — so a replacement server is a 'terraform apply' away

The Ansible post covered configuring a server. This one covers the step before it: creating the server. Clicking through a provider's dashboard works until you need to rebuild at 1 AM, or want a second identical box, or can't remember which firewall group you attached last time.

Terraform (or its open-source fork OpenTofu — same syntax, same providers) turns "create a VPS" into a text file. Combined with cloud-init, the standard first-boot configuration system every provider supports, a new server comes up already hardened, already on your tailnet, and already waiting for Ansible — without you ever SSHing in as root.

What this gives you

Both Hetzner and Vultr are shown; pick your provider's block and delete the other.

Repo layout

providers.tf

variables.tf

Adding a server is adding four lines to this map.

cloud-init.yaml.tftpl

This runs on first boot, before you ever log in. It's a template so Terraform can inject the Tailscale key, SSH key and port.

Public SSH on ${sshport} is left open so you can get in if Tailscale ever misbehaves; Ansible can close it later once you've confirmed Tailscale SSH works.

main.tf

outputs.tf

Run it

About 60–90 seconds later the servers exist. cloud-init takes another minute or two. Watch a box join the tailnet:

When it appears, you're in — no root password, no public SSH needed:

Then write the inventory and hand off:

Tailscale ACL for the tags

The pre-auth keys use tag:edge, tag:apps, tag:mail. Those need to exist in your tailnet policy, and the API key's owner must be allowed to assign them:

That last rule lets the edge proxy reach the backends; nothing else on the tailnet needs to talk server-to-server.

Day-to-day

Add a server: four lines in terraform.tfvars, terraform apply, run Ansible.

Replace a broken server: terraform taint hcloudserver.server["apps"] (or -replace= on apply), terraform apply. A new box comes up with the same name, firewall, DNS and tailnet tag. Restore data from backup, run Ansible, done.

Resize: change type, apply. Hetzner resizes in place (brief reboot); Vultr likewise for upgrades.

Destroy an experiment: remove it from the map, apply. The DNS record and firewall go with it.

Never edit in the dashboard. The next terraform plan will show the drift and offer to undo it, which is the point.

Where the line is

Terraform owns: the server, its firewall, its network attachments, its DNS, its Tailscale identity. cloud-init owns: the first five minutes — user, SSH, firewall, Docker, tailnet join. Ansible owns: everything after that, repeatably.

Keeping those three separate means a template change doesn't recreate servers, an Ansible change doesn't touch DNS, and you can read each layer on its own. It also means that when a provider has a bad week, moving a server to the other one is changing one word in a map.