Terraform vs cloud-init: What's the Difference?
Table of Contents
Teams adopting VPN automation often ask whether they need Terraform, cloud-init, or both. The short answer: they solve different problems. Terraform (and similar tools) declare cloud resources. cloud-init declares guest OS bootstrap on first boot. Treating them as competitors creates gaps; treating them as layers creates a clean pipeline.
This article clarifies the boundary with DigitalOcean droplets and self-hosted VPN servers in mind, then shows how a practical stack combines them without double-managing the same settings.
What You'll Learn
- What Terraform owns vs what cloud-init owns
- Where overlap causes drift and how to avoid it
- A reference split for VPN nodes on DigitalOcean
- When you might skip Terraform or skip heavy cloud-init
- How agents and control planes fit after first boot
- Best practices, common mistakes, and FAQ
Two Different Control Planes
Terraform: the infrastructure control plane
Terraform talks to provider APIs. For DigitalOcean it creates and updates:
- Droplets (size, region, image, VPC)
- Reserved IPs / floating IPs
- Cloud firewalls and tag-based rules
- DNS records (if you manage them there)
- SSH keys registered in the account (sometimes)
- Projects, tags, and related metadata
State files (or a remote state backend) record what Terraform believes exists. terraform apply reconciles API objects toward your .tf definitions.
Terraform does not inherently manage the long-term contents of /etc inside the VM unless you bolt on provisioners—and provisioners are a well-known footgun for VPN fleets.
cloud-init: the guest bootstrap plane
cloud-init runs inside the instance when it first boots (and for some modules on later boots, depending on frequency). It configures:
- Users and SSH authorized keys on the OS
- Packages and updates
- Files written to disk (
write_files) - Early firewall policy (UFW)
- sysctl, hostname, timezone
- One-shot
runcmdinstall hooks
cloud-init does not create the droplet, attach a reserved IP, or open DigitalOcean cloud firewall rules. If user data never arrives, cloud-init has nothing to apply.
The Clean Split for VPN Servers
| Concern | Prefer |
|---|---|
| Droplet region, size, image | Terraform / API |
| Cloud firewall UDP 51820 / TCP 22 | Terraform / API |
DNS A record for vpn-fra-01 |
Terraform / API |
OS user deploy + SSH keys |
cloud-init |
apt install wireguard |
cloud-init |
net.ipv4.ip_forward |
cloud-init |
| Host UFW basics | cloud-init |
| Day-2 peer add/remove | Agent / control plane |
| Key rotation campaigns | Agent / control plane |
Terraform passes user data into the droplet resource. That user data is the cloud-init document. Terraform’s job ends when the API objects exist and the instance has the correct bootstrap payload. cloud-init’s job ends when the machine matches the bootstrap role. Everything that changes weekly (VPN users) should not require terraform apply.
Why People Confuse Them
Both can “install WireGuard.” That overlap is the trap.
- A Terraform
remote-execprovisioner can SSH in andapt install wireguard. - cloud-init can also
packages: [wireguard].
If you do both, you get races, non-idempotent applies, and state that Terraform cannot see. Prefer cloud-init (or a baked image) for package install. Prefer Terraform for “this droplet exists in FRA with firewall X.”
Similarly, both can manage SSH keys:
- DigitalOcean account SSH keys injected by the provider
- cloud-init
ssh_authorized_keyson a named user
Pick one primary path per role and document it. Duplicate keys are harmless; duplicate policies (“Terraform removes key A, cloud-init re-adds it”) are not.
Provisioners vs User Data
Terraform provisioners (remote-exec, file) feel convenient. For VPN nodes they age poorly:
- They require temporary SSH reachability during apply
- They hide configuration outside Terraform state
- They fail partially and leave snowflakes
- They encourage imperative scripts instead of rebuildable bootstrap
Use provisioners only as a last resort (legacy constraints). Prefer:
- Terraform creates droplet + firewall + DNS + user data
- cloud-init bootstraps OS/VPN role
- Agent registers with TunnelFleet (or your control plane) and reconciles peers
That pattern matches how DigitalOcean-oriented platforms automate install without making humans SSH for routine setup.
State, Secrets, and Blast Radius
Terraform state may contain sensitive attributes (depending on resources). cloud-init user data may contain secrets if you put them there. Design explicitly:
- Terraform: manage non-secret structure; pass secret references or render user data from a CI secret store at apply time without storing raw keys in git.
- cloud-init: receive injected secrets once, write mode
0600files, or generate keys on box and export public halves only.
Destroying a droplet in Terraform should also remove DNS and firewall attachments. Revoking a VPN user should not require destroying the droplet—that is control-plane work.
When Terraform Alone Is Enough
Small labs: one droplet, manual wg0.conf, no team. You might create the droplet with Terraform and skip elaborate cloud-init beyond an SSH key. That is fine until the second region and the third operator appear.
When cloud-init Alone Is Enough
Click-ops droplet create in the DigitalOcean UI with pasted user data works for a prototype. You already used the cloud-init half without Terraform. The cost is unreproducible clicking for firewalls, DNS, and sizing.
When You Want Images Instead of Fat cloud-init
If bootstrap takes forever (large package sets), bake a golden image with Packer or similar, then use Terraform to launch that image with a thin cloud-init (hostname, secrets, agent token). cloud-init still matters; it just does less.
Mapping to a Mental Model
Think in timelines:
- Apply time (Terraform): API calls create resources; instance powers on with user data attached.
- First boot (cloud-init): OS becomes a
vpnrole machine. - Steady state (agent): peers, protocols, and repairs converge continuously.
- Replace time (Terraform + cloud-init): destroy/recreate for OS role changes or compromise; agent re-enrolls.
If you find yourself using Terraform apply to add a single WireGuard peer, the boundaries are wrong.
Worked Mini-Pipeline
A concrete DigitalOcean flow:
- Terraform module
vpn_nodeacceptsregion,size,hostname,tunnel_cidr,ssh_keys. - Module renders
user_data = templatefile("cloud-init/vpn.yaml", { ... }). - Module creates
digitalocean_droplet,digitalocean_firewall(or tag-based rules), anddigitalocean_recordA/AAAA. - cloud-init installs WireGuard tools, UFW, sysctl forwarding, and the management agent.
- Agent registers with the control plane; Terraform never learns peer public keys.
- Operators add VPN users in the control plane;
terraform applyis not involved.
If step 5 fails, Terraform still thinks the droplet is fine—which is correct. Health is a separate signal (agent heartbeat). Do not overload Terraform state with application readiness.
Ownership Matrix for Incidents
When UDP 51820 is closed:
- If the DigitalOcean firewall lacks the rule → Terraform/API ownership
- If UFW lacks the rule → cloud-init/host ownership (rebuild or config management)
- If the daemon is down → agent/service ownership
Clear ownership cuts MTTR. Blaming “the VPN” wastes the first fifteen minutes of every outage.
Migrating Off Provisioners
If you already SSH from Terraform:
- Move package install and file drops into cloud-init.
- Keep Terraform apply green without
remote-exec. - Delete provisioners and the temporary security group holes they required.
- Rebuild nodes once so reality matches the new bootstrap path.
Migration is a replace exercise, not a live sed across production.
Environments and Workspaces
Use separate DigitalOcean projects (or accounts) for prod and staging. Terraform workspaces or separate state files should map cleanly to those projects. Never point a “dev” apply at prod state because the cloud-init template looked harmless.
Pass environment into user data (env: staging) so banners, agent endpoints, and CIDRs cannot silently cross wires. The guest should know which world it belongs to without guessing from hostname folklore.
Cost of Blurring the Lines
Teams that put peer public keys in Terraform variables create:
- Oversized plans on every user change
- Secrets and PII-adjacent material in state
- Slow revoke paths gated on apply permissions
Teams that put droplet sizing in ad-hoc cloud-init curl scripts create unreproducible capacity. Respect the layer boundary even when a shortcut would work today.
Best Practices
- Pass cloud-init as user data from Terraform—one pipeline, two layers.
- Never use Terraform provisioners for routine VPN config.
- Keep peer inventory out of
.tffiles. - Open ports in both cloud firewall (Terraform) and UFW (cloud-init) deliberately and document both.
- Version module interfaces: inputs like
region,vpn_cidr,role,user_data_template. - Plan replaces for bootstrap changes (
terraform taint/ replace) instead of SSHing forever. - Remote state with locking when more than one person applies.
- CI plan on pull requests; apply from a controlled path.
Common Mistakes
Editing UFW live and also declaring conflicting UFW in cloud-init. Next rebuild surprises you.
Storing WireGuard private keys in Terraform state via heredoc user data without treating state as sensitive.
Using terraform apply as a peer management UI. Slow, risky, wrong abstraction.
Assuming cloud-init re-applies full package policy every boot like a config management agent.
Duplicating DigitalOcean firewall rules only in the UI so Terraform cannot recreate them after disaster.
Giant remote-exec scripts that nobody can run outside Terraform.
Forgetting that user data changes often require instance replace on DigitalOcean workflows—plan capacity accordingly.
FAQ
Is Pulumi or the raw DigitalOcean API different from Terraform here?
Same split. Any tool that manages cloud objects is the “Terraform-shaped” layer. cloud-init remains the guest bootstrap layer.
Can cloud-init manage DigitalOcean firewalls?
No. Firewalls are provider-side. Configure them via API/Terraform (and still configure host UFW if you use it).
Which should I learn first?
If you only learn one for VPN bootstrap, learn cloud-init—you need a predictable first boot. If you run many droplets across regions, add Terraform (or equivalent) next so create/destroy is reproducible.
Does TunnelFleet replace Terraform?
TunnelFleet focuses on provisioning and managing VPN infrastructure on your cloud provider (DigitalOcean today) with minimal manual setup—droplet creation, agent install, protocols, and VPN users. You may still use Terraform for broader org infrastructure; you should not need to hand-maintain every peer block.
What about Ansible?
Ansible can manage day-2 OS state. It is neither Terraform nor cloud-init. Many fleets use Terraform + cloud-init + a specialized VPN agent, and keep Ansible for bastions or shared hardening—if they use it at all.
How do I pass different cloud-init per region?
Terraform variables/templates: same role module, different vpn_cidr and hostname inputs rendered into user data.
Why do my Terraform applies keep SSHing?
Because provisioners require it. Move install steps into cloud-init and delete provisioners.
Summary
Terraform and cloud-init are complementary. Terraform creates and wires DigitalOcean resources; cloud-init turns a blank Ubuntu image into a VPN-ready host; a control plane manages peers over time. Keep secrets and peer churn out of Terraform state and out of committed user data. When the split is clear, automation stays boring—the goal for any tunnel fleet.
If you want to automate VPN server deployment instead of configuring everything manually, TunnelFleet helps you provision and manage VPN infrastructure on your own cloud provider with minimal manual setup.
Share this article
Practical guides on VPN infrastructure, server automation, and self-hosted networking from the TunnelFleet team.
View all articles by TunnelFleet Editorial →