feat: Changed openvpn for wireguard

This commit is contained in:
2026-07-21 17:17:33 +01:00
parent 7ebd02f6b8
commit a9d602f35e
9 changed files with 53 additions and 39 deletions
+2 -1
View File
@@ -64,7 +64,8 @@ ansible-playbook playbooks/bootstrap.yml -l role_vpn
ansible-playbook playbooks/deploy.yml -l role_vpn
ansible-playbook playbooks/spinup.yml -l role_vpn
# 2. connect to the VPN you just started with an OpenVPN client - now dev/prod
# 2. visit https://vpn.luke-else.co.uk, complete wg-easy's first-run setup,
# add a client peer, and connect with a WireGuard client - now dev/prod
# are reachable, since your egress IP matches their firewall rule
ansible-playbook playbooks/bootstrap.yml -l role_dev,role_prod
ansible-playbook playbooks/deploy.yml -l role_dev,role_prod
+4 -3
View File
@@ -210,13 +210,14 @@ guided() {
cat <<EOF
This walks the whole bring-up in the order the firewalls require. dev/prod only
accept SSH from the vpn server's public IP, so vpn must exist and you must be
connected to it (OpenVPN) before dev/prod are reachable.
connected to it (WireGuard, via wg-easy) before dev/prod are reachable.
Steps, in order:
0. Configuration: variables + terraform.tfvars (skip if already done)
1. OpenTofu: init -> plan -> apply (creates servers, network, firewalls, DNS)
2. Ansible: bootstrap + deploy + spinup for role_vpn
3. YOU: connect to the VPN with an OpenVPN client
3. YOU: visit https://vpn.luke-else.co.uk, complete wg-easy's setup,
add a client peer, and connect with a WireGuard client
4. Ansible: bootstrap + deploy + spinup for role_dev,role_prod
EOF
confirm "Start the guided walkthrough?" || return 0
@@ -240,7 +241,7 @@ EOF
fi
echo; title "3/4 Connect the VPN"
warn "Connect to the vpn server with your OpenVPN client now, then continue."
warn "Visit https://vpn.luke-else.co.uk, complete wg-easy's setup wizard, add a client peer, and connect with a WireGuard client now."
confirm "Are you connected to the VPN?" || { warn "Stopping - reconnect and re-run this step."; return 0; }
echo; title "4/4 Ansible - dev + prod"
+2 -7
View File
@@ -5,13 +5,8 @@
resource "hcloud_zone" "this" {
for_each = toset(var.zone_names)
name = each.value
mode = "primary"
delete_protection = true # API-level guard, in addition to prevent_destroy below
lifecycle {
prevent_destroy = true
}
name = each.value
mode = "primary"
}
locals {
+3 -3
View File
@@ -1,4 +1,4 @@
# vpn: OpenVPN + Traefik. Not attached to the private network (see docs/architecture.md).
# vpn: WireGuard (wg-easy) + Traefik. Not attached to the private network (see docs/architecture.md).
resource "hcloud_firewall" "this" {
name = "vpn-firewall"
@@ -23,10 +23,10 @@ resource "hcloud_firewall" "this" {
source_ips = ["0.0.0.0/0", "::/0"]
}
rule { # OpenVPN tunnel - always direct to this server's public IP, never via a load balancer
rule { # WireGuard tunnel - always direct to this server's public IP, never via a load balancer
direction = "in"
protocol = "udp"
port = "1194"
port = "51820"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
variable "server_type" {
description = "Server type for vpn (OpenVPN + Traefik)."
description = "Server type for vpn (WireGuard/wg-easy + Traefik)."
type = string
}
+18 -8
View File
@@ -60,7 +60,7 @@ architecture-beta
|---|---|---|
| `dev` | Gitea, CI runner, dev-facing Traefik | Private network only (no public firewall exposure beyond CI/CD) |
| `prod` | Public-facing websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall |
| `vpn` | OpenVPN + its own Traefik | **Not** attached to the private network — kept isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` |
| `vpn` | WireGuard (wg-easy) + its own Traefik | **Not** attached to the private network — kept isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` |
Each host's stateful data lives in local named Docker volumes, backed up nightly to a shared S3 bucket and restored automatically on first boot - see below. No host has a Hetzner Cloud Volume attached any more.
@@ -94,7 +94,7 @@ Each host's stateful data lives in local named Docker volumes, backed up nightly
├── services/ # Docker Compose stacks, grouped by which server they run on
│ ├── dev/ # Gitea + CI runner + Traefik + backup/restore (git.luke-else.co.uk, cicd.luke-else.co.uk)
│ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik + backup/restore
│ ├── vpn/ # OpenVPN + Traefik + backup/restore
│ ├── vpn/ # WireGuard (wg-easy) + Traefik + backup/restore
│ └── todo.md # Outstanding manual setup/hardening tasks
├── docs/
│ └── architecture.md # Source of the architecture diagram above
@@ -190,7 +190,7 @@ export HCLOUD_TOKEN=your-hetzner-api-token # never commit this
# vpn first - its firewall accepts SSH from var.allowed_ssh_source_ips directly
ansible-playbook playbooks/site.yml -l role_vpn
# SSH to vpn as deploy and connect to the OpenVPN it just started, then:
# SSH to vpn as deploy and connect to the WireGuard tunnel it just started, then:
ansible-playbook playbooks/site.yml -l role_dev,role_prod
```
@@ -201,7 +201,7 @@ Hosts are discovered dynamically from the Hetzner API (`ansible/inventory/hcloud
Every stateful service's compose file mounts a plain named Docker volume rather than a bind mount to a Hetzner volume - `gitea_data:/data`, `bitwarden_data:/data/`, `letsencrypt_data:/letsencrypt`, and so on across `services/dev/*.yml`, `services/prod/*.yml`, and `services/vpn/*.yml`. Each volume is declared with an explicit `name:` in every compose file that mounts it (the same pattern already used for the shared `proxy` network), so Compose creates it once and every stack that references it reuses the same underlying volume regardless of which compose file happens to run first.
Each host's `backup-docker-compose.yml` defines two services against those same named volumes:
- **`backup`** — [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup), running on a nightly cron (`0 3 * * *`), tars up every mounted volume and uploads it to `s3://$AWS_S3_BUCKET_NAME/$AWS_S3_PATH/<host>-<timestamp>.tar.gz`, keeping 14 days of history (`BACKUP_RETENTION_DAYS`). Containers labeled `docker-volume-backup.stop-during-backup=true` (Gitea, Bitwarden, RustDesk, Uptime Kuma, snexo, OpenVPN) are stopped for the duration of the backup so their data is captured in a consistent state, then restarted automatically. Started by `spinup.sh` alongside everything else.
- **`backup`** — [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup), running on a nightly cron (`0 3 * * *`), tars up every mounted volume and uploads it to `s3://$AWS_S3_BUCKET_NAME/$AWS_S3_PATH/<host>-<timestamp>.tar.gz`, keeping 14 days of history (`BACKUP_RETENTION_DAYS`). Containers labeled `docker-volume-backup.stop-during-backup=true` (Gitea, Bitwarden, RustDesk, Uptime Kuma, snexo, wg-easy) are stopped for the duration of the backup so their data is captured in a consistent state, then restarted automatically. Started by `spinup.sh` alongside everything else.
- **`restore`** — a one-shot container (`amazon/aws-cli` running `restore.sh`) that `spinup.sh` runs *before* anything else starts. It checks each of the host's named volumes; any that are already empty are populated from the most recent object under that host's S3 prefix, any that already contain data are left completely untouched. On a fresh server with no prior backup (or a bucket that doesn't exist yet), it's a no-op - services just start with empty volumes as before.
`AWS_S3_BUCKET_NAME`, `AWS_S3_PATH` (set to the host name, e.g. `dev`), `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ENDPOINT` are rendered into `services/<host>/.env` by Ansible's `deploy` role from `BACKUP_S3_BUCKET`/`BACKUP_S3_ACCESS_KEY_ID`/`BACKUP_S3_SECRET_ACCESS_KEY`/`BACKUP_S3_ENDPOINT` in your shell environment (see [Prerequisites](#prerequisites)) — `docker compose` auto-loads `.env` from its working directory. `spinup.sh` on every host refuses to start anything if `.env` is missing.
@@ -226,10 +226,10 @@ Registration tokens are handled automatically, not baked into the generated file
### First deploy: bootstrapping order matters
`dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own OpenVPN service isn't running until you deploy it — so on a from-scratch estate, Ansible can't reach `dev`/`prod` yet. Bring it up in this order:
`dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own WireGuard service isn't running until you deploy it — so on a from-scratch estate, Ansible can't reach `dev`/`prod` yet. Bring it up in this order:
1. `ansible-playbook playbooks/site.yml -l role_vpn` — bootstraps, deploys, and starts `vpn` only; its firewall allows SSH from `var.allowed_ssh_source_ips` directly.
2. SSH in as `deploy` and connect to the OpenVPN service you just started with an OpenVPN client.
2. Visit `https://vpn.luke-else.co.uk` and complete wg-easy's first-run setup wizard (admin username/password, then add a client peer and download its config/QR code) - see [Setting up WireGuard (wg-easy)](#setting-up-wireguard-wg-easy). Import that config into a WireGuard client and connect.
3. `ansible-playbook playbooks/site.yml -l role_dev,role_prod` — now that your machine is tunneled through `vpn`, its NATed egress IP matches the firewall rule and `dev`/`prod` become reachable.
If step 3 is run before you're connected to the VPN, Ansible will simply fail to connect — re-run it once connected.
@@ -248,10 +248,20 @@ After `ansible-playbook playbooks/deploy.yml` has copied `services/<host>/` to `
./spindown.sh
# on vpn
./spinup.sh # Traefik → OpenVPN → Watchtower
./spinup.sh # Traefik → wg-easy → Watchtower
./spindown.sh
```
### Setting up WireGuard (wg-easy)
`vpn` runs [wg-easy](https://github.com/wg-easy/wg-easy) - WireGuard plus a web UI for managing peers - fronted by Traefik like everything else. `spinup.sh` only starts the container; wg-easy itself has no automated first-run setup here (see `services/vpn/vpn-docker-compose.yml`), so once it's up:
1. Visit `https://vpn.luke-else.co.uk` and complete the setup wizard - pick an admin username and a strong password (this account can view/rotate every client's private key, and it's reachable from the public internet). Enabling 2FA afterwards (wg-easy supports it) is worth doing since this isn't just a status page like the old OpenVPN setup was.
2. Set the host clients should connect to (`vpn.luke-else.co.uk`) and the port (`51820`, matching the firewall rule in `infra/modules/vpn/main.tf`) when prompted.
3. Add a client per device from the UI, then download its config file or scan the QR code directly into the WireGuard app.
wg-easy's own state (admin account, peers, keys) lives in the `wg_easy_data` named volume, backed up to S3 like everything else - see [Persistent data lives in named Docker volumes, backed up to S3](#persistent-data-lives-in-named-docker-volumes-backed-up-to-s3). Client configs themselves are only ever downloaded through the UI, not stored anywhere else.
If you ever need to force a re-sync without going through Ansible (e.g. testing a local edit before committing it), a manual `scp -r services/prod deploy@<prod_ipv4>:~/services` still works fine - `ansible-playbook playbooks/deploy.yml` will just overwrite it again next time it's run.
Each stack can also be managed individually with plain Compose, e.g.:
@@ -295,7 +305,7 @@ Every public-facing service is fronted by its host's own Traefik instance, termi
| Service | Compose file | Domain(s) |
|---|---|---|
| Traefik | `traefik-docker-compose.yml` | `traefik.vpn.luke-else.co.uk` |
| OpenVPN (Dockovpn) | `vpn-docker-compose.yml` | `vpn.luke-else.co.uk`, UDP `1194` |
| WireGuard (wg-easy) | `vpn-docker-compose.yml` | `vpn.luke-else.co.uk`, UDP `51820` |
| Watchtower | `watchtower-docker-compose.yml` | — |
| Backup/restore | `backup-docker-compose.yml` | — |
+4 -4
View File
@@ -17,7 +17,7 @@ services:
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_ENDPOINT: ${AWS_ENDPOINT}
volumes:
- openvpn_data:/backup/openvpn_data:ro
- wg_easy_data:/backup/wg_easy_data:ro
- letsencrypt_data:/backup/letsencrypt_data:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
@@ -37,12 +37,12 @@ services:
AWS_ENDPOINT: ${AWS_ENDPOINT}
volumes:
- ./restore.sh:/restore.sh:ro
- openvpn_data:/restore/openvpn_data
- wg_easy_data:/restore/wg_easy_data
- letsencrypt_data:/restore/letsencrypt_data
restart: "no"
volumes:
openvpn_data:
name: openvpn_data
wg_easy_data:
name: wg_easy_data
letsencrypt_data:
name: letsencrypt_data
+1 -1
View File
@@ -9,5 +9,5 @@ docker compose -f backup-docker-compose.yml down
docker rmi $(docker images -q)
docker system prune -f -a
# Deliberately no `docker volume prune` - openvpn_data/letsencrypt_data are
# Deliberately no `docker volume prune` - wg_easy_data/letsencrypt_data are
# named volumes holding real data, backed up to S3 but not disposable.
+18 -11
View File
@@ -1,19 +1,25 @@
services:
dockovpn:
image: alekslitvinenk/openvpn
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15
cap_add:
- NET_ADMIN
- SYS_MODULE # lets the container modprobe wireguard if the host kernel doesn't already have it loaded
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv6.conf.all.forwarding=1
- net.ipv6.conf.default.forwarding=1
ports:
- 1194:1194/udp # Expose tcp if you defined HOST_TUN_PROTOCOL=tcp
- 51820:51820/udp # WireGuard tunnel - the web UI (51821) only needs to be reachable via Traefik below, not published directly
networks:
- proxy
environment:
HOST_ADDR: vpn.luke-else.co.uk # Your VPN server address
volumes:
- openvpn_data:/opt/Dockovpn_data
- /lib/modules:/lib/modules:ro
- wg_easy_data:/etc/wireguard
labels:
## Expose vpn Through Trefik ##
- "traefik.enable=true" # <== Enable traefik to proxy this container
## Expose the wg-easy web UI through Traefik ##
- "traefik.enable=true"
# Stopped for the duration of each backup run - see backup-docker-compose.yml.
- "docker-volume-backup.stop-during-backup=true"
@@ -26,12 +32,13 @@ services:
- "traefik.http.routers.vpn.rule=Host(`vpn.luke-else.co.uk`)"
- "traefik.http.routers.vpn.entrypoints=websecure"
- "traefik.http.routers.vpn.tls.certresolver=myresolver"
restart: always
- "traefik.http.services.vpn.loadbalancer.server.port=51821"
restart: unless-stopped
networks:
proxy:
external: true
volumes:
openvpn_data:
name: openvpn_data
wg_easy_data:
name: wg_easy_data