elektrine/deploy/terraform/vpn-fleet
Maxfield Luke 454e80a8f8
Some checks failed
Deploy Docker Images / Build, push, and deploy (push) Failing after 19m8s
Smoke / Hello from Forgejo Actions (push) Successful in 1s
Initial commit
Fresh repository on git.elektrine.com/elektrine/elektrine.
2026-07-30 01:36:18 -04:00
..
examples Initial commit 2026-07-30 01:36:18 -04:00
modules Initial commit 2026-07-30 01:36:18 -04:00
.gitignore Initial commit 2026-07-30 01:36:18 -04:00
README.md Initial commit 2026-07-30 01:36:18 -04:00

Elektrine VPN fleet (Terraform)

Provisions WireGuard exit nodes across providers and lets them self-register with the control plane. Nothing here talks to Elektrine's database or API — each node registers itself on first boot via the fleet agent in scripts/vpn.

The shape

One cloud-init blob is rendered per region, not per node. Everything node-specific resolves on the box itself:

Field Where it comes from
NODE_NAME the instance hostname, which is the instance name
PUBLIC_IP autodetected from the default route
ENDPOINT_HOST defaults to PUBLIC_IP
internal /24 allocated by the control plane at registration

So adding ten nodes in Falkenstein is ten entries in a map, not ten config files. Only user-facing region metadata (location, country_code, city) varies, and that varies by region.

Layout

modules/user-data/       provider-agnostic; renders cloud-init per region
modules/hetzner/         hcloud_server + firewall
modules/digitalocean/    digitalocean_droplet + firewall
modules/vultr/           vultr_instance + firewall group
examples/hetzner/        complete root, single provider
examples/digitalocean/   complete root, single provider
examples/multi-provider/ complete root, all three

modules/user-data reads scripts/vpn/elektrine-vpn-agent.sh and its unit file directly, so the agent stays single-source-of-truth exactly as it is for gen-cloud-init.sh. Only the cloud-config envelope is mirrored between the two — if you change one, change the other.

Adding a provider is a new directory with the same contract: take nodes (name → region) and user_data (region → blob), create instances, open the UDP port, output name → IPv4.

Use

Copy the example closest to your setup and edit it — these are roots, not things to reference in place.

cp -r deploy/terraform/vpn-fleet/examples/hetzner ~/my-vpn-fleet
cd ~/my-vpn-fleet
export HCLOUD_TOKEN='...'
export TF_VAR_fleet_registration_key='...'   # keep it out of tfvars
terraform init
terraform plan
terraform apply

Set the same key as VPN_FLEET_REGISTRATION_KEY on the control plane first, or every node will boot and fail to register.

If you copy the examples elsewhere, fix source and agent_diragent_dir must point at this repo's scripts/vpn, since that is where the agent is read from at plan time.

Drop providers you don't use. Terraform requires configuration for every provider a referenced module declares, even one that creates nothing — an empty node map is not enough. To go Hetzner-only in the multi-provider example, delete the digitalocean/vultr provider blocks, their required_providers entries, and their module blocks together. (The single-provider example is already this.)

Scaling a region is editing its map:

nodes = {
  fsn-edge-01 = "fsn1"
  fsn-edge-02 = "fsn1"
  fsn-edge-03 = "fsn1"   # new node, no other change needed
}

A node pointed at a region with no regions entry fails at plan time with a named error, rather than booting with empty user-data and never registering.

Before you grow the fleet

Widen the supernet first. VPN_WG_SUPERNET defaults to 10.8.0.0/16, which is 256 /24 allocations — a hard ceiling on node count. Widen it before nodes claim ranges; doing it afterwards is far messier. The node_count output is there to compare against that ceiling.

Node names must be unique fleet-wide, not just per provider — the name becomes the hostname, which becomes NODE_NAME.

Destroying a node strands its record. Registration is keyed on public IP and never re-issues credentials for an IP that already has a record, so once the provider recycles that address the next node handed it cannot register. The control plane reaps long-offline records daily (Elektrine.VPN.StaleNodeReaperWorker); after a deliberate teardown you can run it immediately:

mix elektrine.vpn.reap_stale_nodes --grace-hours 1        # dry run
mix elektrine.vpn.reap_stale_nodes --grace-hours 1 --yes

The fleet key is exposed to every node. It lives in user-data, readable via each provider's metadata API. It only grants registration, but rotate it periodically; at larger scale, fetch it at boot from a secrets store instead of baking it in.

Terraform state holds the key. fleet_registration_key is marked sensitive so it stays out of CLI output, but it is still in state — use a remote backend with encryption. .gitignore here keeps local state and real tfvars out of the repo.

Verifying

terraform output nodes gives name → public IPv4. Diff that against the control plane's server list to spot nodes that booted but never registered.

Note that providers store user_data in state as a hash rather than the blob (Hetzner base64 SHA-1, DigitalOcean hex SHA-1), so a plan diff shows an opaque change when the agent script or region metadata changes. Equal hashes across nodes in a region is the expected state, not a bug. To read what a node will actually receive:

echo 'nonsensitive(module.user_data.user_data["fsn1"])' | terraform console

On a node:

journalctl -u elektrine-vpn-agent -f
wg show wg0