Provision a WireGuard VPN server on any supported cloud with Terraform, configure it with Ansible, and manage DNS through Cloudflare — all from a single Docker-based toolchain. No local Terraform/Ansible install required (works on Windows too).
Multi-cloud Terraform has no native polymorphism, so each provider is hidden behind a
module that honors one common contract, and the rest of the system only ever sees a
normalized server_ipv4. Three concerns are deliberately decoupled:
| Layer | Tool | Notes |
|---|---|---|
| Provision server + firewall | Terraform, one module per cloud | the only cloud-specific part |
| Configure WireGuard | Ansible (roles/wireguard) |
cloud-agnostic; just needs SSH to a Debian box |
| DNS | Cloudflare | provider-agnostic, so switching clouds never breaks DNS |
This replaces the old user_data: curl | bash bootstrap: provisioning and configuration
are now separate, which is what lets the same WireGuard setup run on every provider.
| Cloud | Status | Stack | Credentials (.env) |
|---|---|---|---|
| DigitalOcean | ✅ | terraform/stacks/digitalocean |
DIGITALOCEAN_TOKEN |
| Hetzner | ✅ | terraform/stacks/hetzner |
HCLOUD_TOKEN |
| AWS | ✅ | terraform/stacks/aws |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
| GCP | ✅ | terraform/stacks/gcp |
GOOGLE_CREDENTIALS, GOOGLE_PROJECT |
| Azure | ✅ | terraform/stacks/azure |
ARM_CLIENT_ID/SECRET/TENANT_ID/SUBSCRIPTION_ID |
| Linode | ✅ | terraform/stacks/linode |
LINODE_TOKEN |
DNS (any cloud) also needs CLOUDFLARE_API_TOKEN. All stacks are complete and pass
terraform validate against the real provider schemas. As with any IaC, run a
plan with real credentials as a final smoke test — see each provider's guide in
docs/providers/ for region/size/image specifics.
Full guides live in docs/:
- Architecture — design, diagrams, the module contract, decisions
- Getting started — install, credentials, first deploy
- DNS (Cloudflare) · WireGuard / Ansible · State & backends
- Adding a provider · Troubleshooting
- Per-cloud guides: DigitalOcean · Hetzner · AWS · GCP · Azure · Linode
docker/ Dockerfile (pinned Terraform + Ansible)
compose.yaml single "iac" tooling service
run.ps1 / Makefile wrappers: .\run.ps1 deploy -Cloud hetzner
.env.example all provider tokens in one place
terraform/
modules/
digitalocean/ hetzner/ aws/ gcp/ azure/ linode/ compute + firewall per cloud
dns-cloudflare/ provider-agnostic DNS
ansible-inventory/ writes ansible/inventory/<cloud>.ini
stacks/<cloud>/ thin root that wires the modules (own state, own providers)
ansible/
playbook.yml
roles/wireguard/ idempotent WireGuard install/config
inventory/ generated per cloud (gitignored)
Each stack keeps its own state and its own provider set, so deploying to Hetzner never asks for AWS credentials, and you can switch clouds freely.
- Docker (Desktop on Windows/macOS, or Engine on Linux)
- An SSH key pair (the public key goes to the cloud; the private key lets Ansible connect)
- A Cloudflare-managed domain (for DNS) — or set
dns_enabled = false
-
Configure secrets
cp .env.example .env
Fill in the token(s) for the cloud you're targeting,
CLOUDFLARE_API_TOKEN, andSSH_DIR(the host folder holding your private key — e.g.C:\Users\you\.ssh). It is mounted read-only into the container at/root/.ssh. -
(Per-cloud) set variables
cp terraform/stacks/<cloud>/terraform.tfvars.example terraform/stacks/<cloud>/terraform.tfvars
For DigitalOcean the defaults work out of the box. Hetzner/AWS/etc. need
ssh_public_key. Also point Ansible at your key:ssh_private_key_file = "/root/.ssh/id_ed25519". -
Build the toolchain image (once)
.\run.ps1 build # or: make build -
Deploy — provision, write inventory, then configure WireGuard:
.\run.ps1 deploy -Cloud digitalocean # Linux/macOS: make deploy CLOUD=digitalocean
-
Get the server IP
.\run.ps1 output -Cloud digitalocean
.\run.ps1 init -Cloud hetzner # terraform init
.\run.ps1 plan -Cloud hetzner # terraform plan
.\run.ps1 apply -Cloud hetzner # terraform apply
.\run.ps1 configure -Cloud hetzner # ansible-playbook (WireGuard)
.\run.ps1 destroy -Cloud hetzner # tear it down
.\run.ps1 shell # drop into the tooling containerEach stack defaults to a distinct Cloudflare record so multiple clouds can coexist:
*.0 (DO), *.hz, *.aws, *.gcp, *.az, *.ln under dns_zone (default xeze.org).
Override dns_record_name per stack, or set dns_enabled = false to skip DNS entirely.
The record is never proxied (Cloudflare's proxy doesn't forward WireGuard's UDP).
Edit wg_clients in ansible/roles/wireguard/defaults/main.yml,
give each a unique /32 in the 10.0.0.0/24 range, then re-run configure. Keys are
generated once and never regenerated. Client configs land in /etc/wireguard/clients/ on
the server. Show one as a QR code for mobile:
ssh <user>@<ip> 'qrencode -t ansiutf8 < /etc/wireguard/clients/client1.conf'State is local per stack (mounted into the container, so it persists). For teams or
durability, switch to a remote backend — see the commented example in each stack's
backend.tf, then run terraform init -migrate-state.
- Create
terraform/modules/<cloud>/withmain.tf,variables.tf,outputs.tf. - Honor the contract — inputs
name/region/size/image/wireguard_port/...and outputsserver_ipv4,server_ipv6,server_id,ssh_user. - Add a thin
terraform/stacks/<cloud>/that wiresmodules/<cloud>+dns-cloudflareansible-inventory(copy an existing stack).
- Add the provider's credential vars to
.env.example.
The Ansible role needs no changes — it only requires SSH to a Debian/Ubuntu host.
MIT