This guide builds a WireGuard server you can create in one command, use for a trip or a test, and destroy when you are done. Terraform creates the Scaleway instance and firewall. Ansible installs WireGuard, generates keys and writes a client configuration to your machine. Two shell scripts run the whole sequence.
It fits one person who wants a short-lived exit point in a cloud region. It does not fit a team VPN, an always-on service or anything that needs more than one client. The limitations section lists what the code does not handle.
The guide follows the repository at commit
db6226f.
Later commits may differ.
Before you start
You need:
- A Scaleway project and API key. Note the access key, secret key, organization ID and project ID. See Create API keys.
- An SSH key registered in that project. Scaleway copies a project’s
public SSH keys onto an instance when it boots, and keys are scoped per
project. Terraform then connects with the private key at
~/.ssh/id_ed25519. If your key lives elsewhere, editmain.tf. See Create an SSH key. - A Linux or WSL control machine with
terraform,ansible-playbook,wg(fromwireguard-tools),opensslandssh. The playbook runswg genkeylocally, so the WireGuard tools are needed on your machine, not just on the server. - A WireGuard client on the device that will use the VPN.
Costs: the instance and its public IPv4 address are billed while they exist. Check Scaleway instance pricing for the instance type you choose. Nothing stops billing until you run the teardown step.
How the pieces fit
Each tool owns one layer, and only small values pass between them:
- Terraform creates the firewall, a public IPv4 address and an Ubuntu 24.04 instance, then outputs the address.
deploy.shturns that output into an Ansible inventory and generates a random private IPv6 prefix for the tunnel.- Ansible configures the server and generates both key pairs. Each private key is generated on the machine that uses it, and only public keys are copied between the server and your machine.
- Jinja2 templates render the server’s
wg0.confand yourclient.conf.
automated-wireguard-vpn/
├─ terraform/
│ ├─ provider.tf # pins the Scaleway provider version
│ ├─ main.tf # variables, firewall, IP, instance, output
│ └─ scaleway.auto.tfvars # your credentials (gitignored)
├─ ansible/
│ ├─ playbooks/wireguard.yml
│ └─ templates/ # wireguard.conf.j2, client.conf.j2
└─ scripts/ # deploy.sh, destroy.sh
1. Add your credentials
git clone https://github.com/cusable/automated-wireguard-vpn.git
cd automated-wireguard-vpn
cp terraform/scaleway.auto.tfvars.example terraform/scaleway.auto.tfvars
chmod 600 terraform/scaleway.auto.tfvars
Fill in the four values. Terraform loads any *.auto.tfvars file
automatically, and .gitignore excludes it. The variables are marked
sensitive, which keeps them out of plan output, but Terraform still records
them in terraform.tfstate. Treat the state file as a secret too.
2. Read what Terraform will create
Defaults in main.tf: zone nl-ams-1, instance type STARDUST1-S, image
ubuntu_noble, WireGuard port 51820.
The security group drops inbound traffic by default and opens three things, each for IPv4 and IPv6:
inbound_default_policy = "drop"
outbound_default_policy = "accept"
# accept: TCP 22 (SSH), UDP 51820 (WireGuard), ICMP — from anywhere
SSH is open to the whole internet because Ansible needs it. For anything
longer than a quick test, change that rule’s ip_range to your own address.
The instance gets a routed public IPv4 address, and a remote-exec
provisioner connects to it over SSH:
provisioner "remote-exec" {
inline = ["apt-get update", "apt-get install -y ansible"]
...
}
Two things are worth knowing. First, terraform apply only finishes once this
SSH connection succeeds, so the provisioner also acts as a wait until the
server is reachable. Second, installing Ansible on the server is not
necessary. Ansible is agentless and only needs Python on the target, which
Ubuntu already includes. HashiCorp recommends
provisioners only as a last resort.
A smaller version would wait for SSH and skip the install.
Preview the changes before you spend anything:
cd terraform
terraform init
terraform plan
cd ..
3. Read what Ansible will configure
The playbook has two plays: one on the server, and one on your machine that reaches back to the server where needed.
On the server it installs wireguard, turns on IPv4 and IPv6 forwarding, and
creates /etc/wireguard with mode 0700. It generates the server key pair
only if it does not already exist. It then copies the public key back to
ansible/keys/server_public.key on your machine.
The server template gives the tunnel a private address range and NATs tunnel traffic out through the server’s public interface using nftables:
[Interface]
PrivateKey = {{ wireguard_server_private_key }}
Address = 192.168.100.1/24,{{ ula_prefix }}::1/64
PostUp = nft add table ip wireguard; ... masquerade; (same for ip6)
PostDown = nft delete table ip wireguard; nft delete table ip6 wireguard
ListenPort = {{ wireguard_port }}
ula_prefix is a random IPv6 unique local prefix: fd followed by 40 random
bits, created by openssl rand in deploy.sh. A new one is generated on every
deploy.
On your machine, the second play generates the client key pair in
ansible/keys/ and copies the client public key to the server. It adds a
[Peer] block to wg0.conf for 192.168.100.2, then enables and restarts
wg-quick@wg0. Last, it renders your client configuration:
[Interface]
Address = 192.168.100.2/32,{{ ula_prefix }}::2/128
PrivateKey = {{ peer_private_key }}
DNS = 1.1.1.1,2606:4700:4700::1111
[Peer]
PublicKey = {{ wireguard_server_public_key }}
Endpoint = {{ wireguard_server_ip }}:{{ wireguard_port }}
AllowedIPs = 0.0.0.0/0,::/0
PersistentKeepalive = 25
AllowedIPs = 0.0.0.0/0,::/0 sends all of the client’s traffic through the
tunnel, and DNS points name lookups at Cloudflare’s resolver through the
tunnel. PersistentKeepalive = 25 sends a packet every 25 seconds so NAT
devices between you and the server keep the connection mapping open.
4. Deploy
Run the script from inside scripts/. It uses paths relative to that folder,
such as cd ../terraform:
cd scripts
./deploy.sh
The script:
- runs
terraform initandterraform apply -auto-approve - reads the server IP from
terraform output - writes
ansible/inventorywith that IP and the userroot - waits for SSH, connecting with
StrictHostKeyChecking=no - generates the IPv6 prefix and runs the playbook
-auto-approve creates billable resources without asking for confirmation.
That is why you ran terraform plan first. When it finishes, the client
configuration is at ansible/config/client.conf.
5. Verify the tunnel
Import ansible/config/client.conf into your WireGuard client and connect.
On Linux:
sudo wg-quick up ./ansible/config/client.conf
sudo wg show client
curl -4 https://ifconfig.me
Expect:
wg showlists alatest handshakea few seconds old. If it does not appear, no packets are reaching UDP 51820 on the server.curlprints the server’s IP, which you can also get withterraform -chdir=terraform output -raw wireguard_server_ip. If it prints your home address, traffic is not going through the tunnel.
To check the server side:
ssh root@<server-ip> wg show wg0
The peer should show a recent handshake and nonzero transfer counters.
IPv6 behaves differently. The client sends IPv6 into the tunnel, but the instance only has a public IPv4 address, so IPv6 destinations are expected to fail rather than leak around the VPN. Most applications fall back to IPv4 automatically.
6. Tear down
Disconnect first. Otherwise your device keeps sending all its traffic into a tunnel whose server no longer exists:
sudo wg-quick down ./ansible/config/client.conf
cd scripts
./destroy.sh
destroy.sh removes the server IP from ~/.ssh/known_hosts, so a reused
address will not trip a host-key warning next time. It then runs
terraform destroy -auto-approve and deletes ansible/config,
ansible/keys and ansible/inventory.
This is irreversible: the server, its keys and the client configuration are gone. Afterwards, check the Scaleway console to confirm no instance or IP is left running.
If terraform.tfstate is missing or broken, terraform output fails and the
script stops before destroying anything, because it uses set -e. Delete any
leftover resources in the console in that case. Keep the state file until
teardown is complete, since losing it leaves billable resources that
Terraform no longer tracks.
Limitations
These are properties of the code at db6226f. Know them before relying on it.
- One client. The peer address
192.168.100.2is fixed. Supporting more clients means looping over a list of peers in the template. - SSH is open to the internet as root, and the first connection skips host-key verification. Restrict port 22 to your IP for anything longer than a test.
- The WireGuard port is set in two places. Terraform’s
wireguard_portsets the firewall rule, but the playbook hard-codes51820for the service. Changing only the Terraform variable leaves the server listening on a port the firewall blocks. - Verbose runs leak the private key. The playbook derives public keys
with
echo '<private key>' | wg pubkey, which puts the private key on a command line. Runningansible-playbook -vprints that command, including the key. Do not run verbose output into shared logs. The fix isno_log: trueplus passing the key on stdin. - Re-runs are not idempotent. Each run re-renders
wg0.conf, appends the peer again, restarts WireGuard and generates a new IPv6 prefix. The result is correct, but Ansible reports changes every time. - Providers are pinned but not locked.
provider.tfpins the Scaleway provider to2.43.0, but.terraform.lock.hclis gitignored, so provider checksums are not recorded. HashiCorp recommends committing the lock file.
Where to go next
- Limit SSH to your address, or drop SSH entirely by moving the server setup into cloud-init user data.
- Move the peer list into a variable, and generate one config per device.
- If you want an always-on network for several people, a maintained mesh VPN or WireGuard manager costs less effort than extending these scripts.