WireGuard hub-and-spoke VPN using AWS EC2 as a relay, designed for CGNAT environments. Connects multiple devices (Arch Linux desktop, Android tablet, smartphone, etc.) through a central EC2 relay with per-peer preshared keys (post-quantum), 2FA on the desktop SSH daemon, and explicit iptables rules to prevent VPN peers from pivoting into the home LAN.
Arch Desktop (CGNAT) EC2 Relay (sa-east-1) Android Tablet
10.10.0.2 10.10.0.1 (EIP) 10.10.0.3
│ │ │
└── WireGuard + PSK ───────────────┤──────────── WireGuard + PSK ─┘
Hub / relay only
(peers cannot reach home LAN)
| Property | Implementation |
|---|---|
| Transport encryption | WireGuard (Noise Protocol, Curve25519, ChaCha20-Poly1305) |
| Post-quantum resistance | Per-peer preshared keys (PSK) add a symmetric layer |
| Secret storage | All private keys and PSKs stored in AWS SSM Parameter Store (SecureString) |
| Secret retrieval | EC2 fetches keys at boot via IAM role — never embedded in user_data |
| Desktop authentication | SSH key + Google Authenticator 2FA (KbdInteractive) — configured by the operator on the home machine, not provisioned by this repo |
| EC2 access | Key-only SSH on non-standard port 50022, fail2ban, non-root user (wgadmin) |
| SSH access control | Configurable CIDR allowlist (ssh_allowed_cidrs variable) |
| LAN isolation | iptables FORWARD DROP on desktop: VPN peers cannot reach home LAN |
| EC2 disk | EBS volume encrypted at rest |
| EC2 metadata | IMDSv2 enforced (SSRF protection) |
| IPv6 | Disabled on EC2 to prevent bypass leaks |
| Network forensics | VPC Flow Logs to CloudWatch (30-day retention) |
| CPU billing | standard credit mode prevents unexpected billing spikes |
Note: The EC2 relay decrypts and re-encrypts traffic between peers (hub-and-spoke). It is not end-to-end transparent. For content privacy over the relay, run SSH sessions to
10.10.0.2from the tablet — the EC2 sees opaque SSH ciphertext.
- AWS CLI v2 configured (
aws configure) with permissions to EC2, EIP, SSM, IAM, and CloudWatch Logs - Terraform ≥ 1.3
- Arch Linux desktop (packages installed by
01-bootstrap.sh) - Google Authenticator already configured on the desktop for SSH 2FA
jqfor JSON processing (installed by01-bootstrap.sh)
bash scripts/01-bootstrap.shThis will:
- Install
wireguard-tools,aws-cli,qrencode,terraform,jqvia pacman - Verify AWS CLI v2 is installed
- Generate a dedicated ED25519 SSH key at
~/.ssh/wg_ec2_ed25519(enter a strong passphrase) - Add a
wg-bastionalias to~/.ssh/config - Generate WireGuard keypairs and preshared keys in
~/wireguard-keys/for server + 3 default peers (desktop, tablet, smartphone) - Write
terraform/peers.auto.tfvars.jsonwith all peer public keys, PSKs, and IPs - Write
terraform/terraform.tfvarswith SSH public key and server private key
Safe to re-run — all steps are idempotent. Existing keys and config are never overwritten.
By default, state is stored locally. For production use, configure an S3 backend:
# 1. Create the S3 bucket and DynamoDB table first
aws s3 mb s3://YOUR-GLOBALLY-UNIQUE-BUCKET-NAME --region sa-east-1
aws dynamodb create-table --table-name wg-bastion-tf-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST --region sa-east-1
# 2. Configure the backend
cp terraform/backend.tf.example terraform/backend_override.tf
# Edit the bucket name to match the bucket you created
# 3. Migrate state
cd terraform && terraform init -migrate-stateBoth terraform.tfstate and peers.auto.tfvars.json contain sensitive data — remote state with encryption is strongly recommended.
By default, SSH (port 50022) is open to 0.0.0.0/0. To restrict to known networks:
# In terraform.tfvars, add:
ssh_allowed_cidrs = ["203.0.113.0/24", "198.51.100.50/32"]cd terraform
terraform init
terraform applyThis creates:
- VPC, subnet, internet gateway, hardened security group
- SSM parameters for server private key and all peer PSKs (SecureString, encrypted at rest)
- IAM role with least-privilege access to only the specific SSM parameters
- EC2 t3.nano (Debian 12) with instance profile attached
- Elastic IP associated with the instance
- VPC Flow Logs to CloudWatch
The instance self-configures via cloud-init on first boot (~3–5 min):
- Installs packages (WireGuard, UFW, fail2ban, AWS CLI)
- Fetches server private key and peer PSKs from SSM
- Writes WireGuard config with secrets resolved from SSM
- Configures UFW, SSH hardening, fail2ban
- Reboots if security updates require it
bash scripts/02-configure-clients.shThis will:
- Read all peer IPs from
peers.auto.tfvars.json - Detect your physical network interface
- Write
/etc/wireguard/wg0.confon the desktop (prompts before overwriting) - Print QR codes for all non-desktop peers — scan with the WireGuard app
bash scripts/vpn-up.sh- Starts the EC2 instance (if stopped)
- Waits for EC2 status checks to pass
- Starts the local WireGuard interface
- Polls for WireGuard handshake (up to 180s)
bash scripts/vpn-down.sh- Stops the local WireGuard interface
- Stops the EC2 instance to halt billing
The Elastic IP is retained while the instance is stopped (~$0.005/hr). This keeps the WireGuard endpoint stable so clients never need reconfiguration.
To add a new peer (e.g., a laptop):
bash scripts/add-device.sh laptopThis will:
- Validate the device name (lowercase letters, numbers, hyphens only)
- Generate WireGuard keypair and PSK in
~/wireguard-keys/ - Calculate the next available IP in the 10.10.0.0/24 subnet
- Check for IP conflicts
- Add the device to
terraform/peers.auto.tfvars.json
Then apply and generate a QR code:
cd terraform && terraform apply
# The add-device.sh output includes the exact QR command to runThe terraform apply will:
- Create a new SSM parameter for the device's PSK
- Update the IAM policy to grant access to the new parameter
- Replace the EC2 instance (new user_data with the additional peer)
The desktop's wg0.conf includes:
PostUp = iptables -I FORWARD -i wg0 -o <PHYS_IF> -j DROP
PostDown = iptables -D FORWARD -i wg0 -o <PHYS_IF> -j DROPThis ensures no VPN peer (e.g. the tablet) can forward traffic from wg0 onto the physical home network — protecting devices like a Raspberry Pi running Immich/Jellyfin. The rule is inserted at the top of the FORWARD chain (-I) so it takes precedence over any permissive rules.
<PHYS_IF> is detected and hardcoded at the time 02-configure-clients.sh runs. If you permanently change your primary interface (e.g. eth0 → wlan0), re-run 02-configure-clients.sh.
If WireGuard fails to come up, SSH directly to the EC2 using the public Elastic IP:
# Get the EIP
terraform -chdir=terraform output wg_elastic_ip
ssh -i ~/.ssh/wg_ec2_ed25519 -p 50022 wgadmin@<EIP>
# Diagnose on the instance
sudo journalctl -u wg-quick@wg0
sudo journalctl -u startup-update
sudo wg showThe wg-bastion SSH alias (added to ~/.ssh/config by 01-bootstrap.sh) routes through 10.10.0.1 and only works when the tunnel is already up.
To rotate all WireGuard keys (e.g. after a suspected compromise):
rm ~/wireguard-keys/*
rm terraform/terraform.tfvars
rm terraform/peers.auto.tfvars.json
bash scripts/01-bootstrap.sh # regenerates all keys, peers, and tfvars
terraform -chdir=terraform apply # replaces SSM params + instance
bash scripts/02-configure-clients.sh # writes new desktop config and QR codesImportant: You must delete
peers.auto.tfvars.jsonbefore re-running01-bootstrap.sh, otherwise the old peer keys will be reused and won't match the newly generated key files.
To remove all AWS resources and stop billing entirely:
terraform -chdir=terraform destroyThis deletes the EC2 instance, Elastic IP, VPC, SSM parameters, IAM roles, and flow logs. Your local keys in ~/wireguard-keys/ and the generated tfvars are left untouched, so you can re-provision later with terraform apply.
Note: Re-provisioning allocates a new Elastic IP, so the WireGuard endpoint changes. Re-run
02-configure-clients.sh(and re-scan the mobile QR codes) to update the endpoint in every client config.
.
├── scripts/
│ ├── config.sh # Shared configuration (AWS region)
│ ├── 01-bootstrap.sh # Local setup: keys, SSH alias, tfvars, peers JSON
│ ├── 02-configure-clients.sh # Desktop wg0.conf + QR codes for all mobile peers
│ ├── add-device.sh # Add a new peer dynamically
│ ├── vpn-up.sh # Start EC2 + tunnel
│ └── vpn-down.sh # Stop tunnel + EC2
├── terraform/
│ ├── main.tf # VPC, SG, EC2, EIP, SSM, IAM, Flow Logs
│ ├── variables.tf # Input variables (keys, PSKs, CIDRs, region)
│ ├── init-ec2.sh.tftpl # EC2 cloud-init: fetches secrets from SSM, configures WireGuard
│ ├── backend.tf.example # S3 remote state backend template
│ ├── .terraform.lock.hcl # Provider version lock (committed)
│ ├── terraform.tfvars # Generated by 01-bootstrap.sh — gitignored
│ └── peers.auto.tfvars.json # Generated by 01-bootstrap.sh — gitignored
└── .gitignore
- Secret storage: The WireGuard server private key and all peer PSKs are stored in AWS SSM Parameter Store (SecureString, encrypted with AWS-managed KMS key). They are never embedded in user_data or visible via the EC2 API.
- IAM least privilege: The EC2 instance's IAM role can only read the specific SSM parameters for this bastion — not any other parameters in the account.
- Local secrets:
terraform.tfvarscontains the server private key.peers.auto.tfvars.jsoncontains all peer PSKs and public keys. Both are gitignored. Protect them:chmod 600 terraform/terraform.tfvars terraform/peers.auto.tfvars.json. - Remote state: Use the S3 backend (
backend.tf.example) to avoid storing state locally. State files contain sensitive data. - Tablet private key: Generated on the desktop and displayed as a QR code. It never leaves
~/wireguard-keys/except via the QR scan. For maximum security, generate the keypair on the tablet and only transfer the public key. - Startup updates: The EC2 runs
startup-update.serviceon every boot, which installs security updates. If a reboot is required, it happens at the end of cloud-init (after WireGuard is configured). This is whyvpn-up.shpolls for up to 180s.