14 KiB
Server
Infrastructure-as-code and service definitions for luke-else.co.uk's self-hosted server estate: three Hetzner Cloud VPS instances provisioned with OpenTofu, each running a set of Docker Compose stacks behind Traefik.
Contents
- Architecture
- Repository layout
- Prerequisites
- Provisioning the infrastructure
- Deploying the services
- Service inventory
- First-time setup
- Development container
- Security notes
Architecture
Three servers, one shared private network:
architecture-beta
group cloud(cloud)[Hetzner]
group network(cloud)[network] in cloud
service disk1(mdi:disk)[Storage] in cloud
service disk2(mdi:disk)[Storage] in cloud
service dev(mdi:server)[dev] in network
service prod(mdi:server)[prod] in network
service prodfirewall(mdi:firewall)[firewall] in cloud
service vpn(mdi:server)[vpn] in cloud
service vpnfirewall(mdi:firewall)[firewall] in cloud
service gateway(mdi:web)[gateway] in cloud
dev:L -- R:prod
disk1:B -- T:prod
disk2:B -- T:dev
prod:B -- T:prodfirewall
vpn:B -- T:vpnfirewall
prodfirewall: L -- R: gateway
vpnfirewall: B -- T: gateway
gateway represents the public internet, not a provisioned resource.
| Server | Purpose | Network | Volume |
|---|---|---|---|
dev |
Gitea, CI runner, dev-facing Traefik | Private network only (no public firewall exposure beyond CI/CD) | dev-storage |
prod |
Public-facing websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall | prod-storage |
vpn |
OpenVPN + its own Traefik | Not attached to the private network — kept isolated so a compromised VPN endpoint can't pivot to dev/prod |
none |
dev and prod share a private Hetzner network (10.0.1.0/24 by default) so CI/CD on dev can reach deployment targets on prod without exposing that traffic publicly. vpn is deliberately kept off this network. Each server has its own Hetzner Cloud Firewall (see infra/modules/<host>/main.tf) that only opens the ports actually used by the compose stacks running on it, plus SSH restricted to var.allowed_ssh_source_ips.
Repository layout
.
├── infra/ # OpenTofu (Terraform-compatible) config — provisions the 3 servers, network, firewalls, volumes
│ ├── main.tf # root module: wires network + dev/prod/vpn modules together
│ ├── variables.tf # shared inputs (sizes, locations, IP ranges, SSH key names)
│ ├── outputs.tf # pass-through outputs from each host module
│ ├── ssh.tf # looks up each SSH key already uploaded to Hetzner Cloud
│ ├── versions.tf # provider requirements
│ ├── terraform.tfvars.example
│ ├── scripts/
│ │ └── bootstrap.sh.tftpl # post-install script run on every host right after creation
│ └── modules/
│ ├── network/ # shared private network + subnet (used by dev and prod)
│ ├── dev/ # dev server + firewall + volume + renders services/dev/Runners/docker-compose.yml
│ │ └── templates/
│ │ └── runners-docker-compose.yml.tftpl # shape of each generated runner service
│ ├── prod/ # prod server + firewall + volume
│ └── vpn/ # vpn server + firewall (no private network, no volume)
├── services/ # Docker Compose stacks, grouped by which server they run on
│ ├── dev/ # Gitea + CI runner + Traefik (git.luke-else.co.uk, cicd.luke-else.co.uk)
│ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik
│ ├── vpn/ # OpenVPN + Traefik
│ └── todo.md # Outstanding manual setup/hardening tasks
├── docs/
│ └── architecture.md # Source of the architecture diagram above
├── .devcontainer/ # Git submodule: shared devcontainer for working on this repo (OpenTofu tooling)
└── assets/
Each of services/dev, services/prod, services/vpn follows the same convention: one *-docker-compose.yml (or subdirectory, e.g. Runners/) per logical service, plus a spinup.sh / spindown.sh pair that brings up or tears down every stack on that host in the right order. infra/modules/ mirrors this same dev/prod/vpn split on the provisioning side, so a given host's cloud resources and its compose stacks are easy to find side by side.
Prerequisites
- A Hetzner Cloud project and API token
- One or more SSH keys uploaded to that project (Console → Security → SSH Keys) - all are installed on every server - plus the private key matching one of them available locally (OpenTofu uses it once per server to run the post-install bootstrap — see below)
- OpenTofu
>= 1.6.0 - DNS records for the domains in Service inventory pointed at the relevant server's public IP
Docker + the Compose plugin no longer need installing by hand — the bootstrap script below handles that.
Provisioning the infrastructure (infra/)
cd infra
export HCLOUD_TOKEN=your-hetzner-api-token # never commit this
cp terraform.tfvars.example terraform.tfvars
$EDITOR terraform.tfvars # set ssh_key_names and ssh_private_key_path at minimum
tofu init
tofu plan
tofu apply
This creates, via module.network / module.dev / module.prod / module.vpn in infra/main.tf:
hcloud_network+ subnet, shared bydevandprod(modules/network)- one
hcloud_server+hcloud_firewallper host, scoped to the ports each host actually uses (modules/dev,modules/prod,modules/vpn) - one
hcloud_volumeeach fordevandprod(modules/dev,modules/prod;vpnhas none)
Useful outputs: tofu output dev_ipv4, tofu output prod_ipv4, tofu output vpn_ipv4.
terraform.tfvars and any *.tfvars file are gitignored — never commit real values there. Defaults for server sizes, locations, and IP ranges live in infra/variables.tf and are passed down into the modules from infra/main.tf; override them per-environment via terraform.tfvars.
Post-install bootstrap
Immediately after each hcloud_server is created, OpenTofu uploads and runs infra/scripts/bootstrap.sh.tftpl over SSH as root (connection + file/remote-exec provisioners in each modules/<host>/main.tf). It:
- installs Docker Engine + the Compose plugin
- creates a non-root sudo user (
var.deploy_user, defaultdeploy) with the same SSH keys as root, in thesudoanddockergroups - hardens
sshd: password authentication off, root login restricted to key-only (PermitRootLogin prohibit-password) - installs and enables
unattended-upgradesfor automatic security patches
After bootstrap, SSH in as var.deploy_user for day-to-day work — root key-based login still works as a fallback.
Scaling Gitea Actions runners
The number of Gitea Actions runner containers on dev is an OpenTofu input, var.dev_runner_count (default 3). On every tofu apply, modules/dev's local_file.runners_compose resource renders modules/dev/templates/runners-docker-compose.yml.tftpl straight into services/dev/Runners/docker-compose.yml in your working tree — one runner-N service per count, each with its own container name and /data volume so their registrations don't collide. That file is generated: change dev_runner_count in terraform.tfvars and re-run tofu apply rather than hand-editing it.
This only updates your local working tree — tofu apply doesn't reach out and start containers on dev itself. Redeploy as usual (re-copy services/dev/ to the server and re-run spinup.sh) to apply a count change.
Registration tokens are handled automatically, not baked into the generated file: services/dev/spinup.sh waits for Gitea to come up, runs gitea actions generate-runner-token inside the Gitea container, and writes the result to Runners/.env, which Compose loads automatically. There's no manual admin-UI step for this anymore.
First apply: bootstrapping order matters
dev and prod's firewalls only accept SSH from vpn's public IP (see Security notes), but vpn's own OpenVPN service isn't running until you deploy it — so on a from-scratch tofu apply, the dev/prod bootstrap provisioners can't connect yet. Bring the estate up in this order:
tofu apply -target=module.vpn— createsvpnonly; its firewall still allows SSH fromvar.allowed_ssh_source_ips, so its own bootstrap provisioner runs fine.- Deploy and start the VPN service on
vpn(see Deploying the services), then connect to it with an OpenVPN client. tofu apply— createsnetwork,dev,prod. Now that your machine is tunneled throughvpn, its NATed egress IP matches the firewall rule and thedev/prodbootstrap provisioners can connect.
If step 3 is run before you're connected to the VPN, the dev/prod provisioners will fail and Terraform will mark those servers tainted — re-running tofu apply once connected will recreate and re-bootstrap them.
Deploying the services (services/)
Once a server exists, copy the relevant services/<host>/ directory to it (e.g. scp -r services/prod user@<prod_ipv4>:~/services) and run the matching script from inside that directory:
# on dev
./spinup.sh # Traefik → Gitea → (waits, generates a runner token) → Runners → Watchtower
./spindown.sh # reverse order, then prunes images/volumes
# on prod
./spinup.sh # Traefik, → Watchtower → status → websites → Bitwarden → RustDesk
./spindown.sh
# on vpn
./spinup.sh # Traefik → OpenVPN → Watchtower
./spindown.sh
Each stack can also be managed individually with plain Compose, e.g.:
cd services/prod
docker compose -f bitwarden-docker-compose.yml up -d
docker compose -f bitwarden-docker-compose.yml down
All three hosts run Watchtower polling every 60s with cleanup enabled, so images are kept current automatically once deployed — the spinup scripts only need to be re-run after adding/removing a service or changing compose files.
Every public-facing service is fronted by its host's own Traefik instance, terminating TLS via Let's Encrypt (tlschallenge, port 80/443). Each stack joins an external proxy Docker network and opts in via traefik.enable=true labels rather than publishing ports directly (RustDesk and the Gitea SSH port are the deliberate exceptions, since they aren't HTTP).
Service inventory
dev
| Service | Compose file | Domain(s) |
|---|---|---|
| Traefik | traefik-docker-compose.yml |
traefik.cicd.luke-else.co.uk |
| Gitea | gitea-docker-compose.yml |
git.luke-else.co.uk (HTTP), SSH on 222 |
| Gitea Actions runner(s) | Runners/docker-compose.yml (generated — see Scaling Gitea Actions runners) |
cicd.luke-else.co.uk |
| Watchtower | watchtower-docker-compose.yml |
— |
prod
| Service | Compose file | Domain(s) |
|---|---|---|
| Traefik | traefik-docker-compose.yml |
traefik.luke-else.co.uk |
| Websites | web-docker-compose.yml |
luke-else.co.uk, dev.luke-else.co.uk, metarius.luke-else.co.uk, www.divine-couture.co.uk, snexo.co.uk |
| Status page (Uptime Kuma) | status-docker-compose.yml |
status.luke-else.co.uk |
| Bitwarden (Vaultwarden) | bitwarden-docker-compose.yml |
bitwarden.luke-else.co.uk |
| RustDesk relay (hbbs/hbbr) | rd-docker-compose.yml |
rd.luke-else.co.uk, ports 21115-21119 |
| Watchtower | watchtower-docker-compose.yml |
— |
vpn
| 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 |
| Watchtower | watchtower-docker-compose.yml |
— |
First-time setup
A few things need manual attention before a stack is fully live — tracked in services/todo.md, summarized here:
- Traefik dashboard auth: the committed basic-auth hash is a placeholder. Generate your own with
echo $(htpasswd -nb user password) | sed -e 's/\$/\$\$/g'and replace thetraefik-authmiddleware value in eachtraefik-docker-compose.yml. - General host hardening: non-root user, Docker, and unattended-upgrades are now handled automatically by the post-install bootstrap; UFW is the remaining manual item in
services/todo.md(the Hetzner Cloud Firewalls already allowlist per-host ports — see Security notes).
Development container
.devcontainer is a git submodule providing a ready-to-use OpenTofu development environment (VS Code + OpenTofu/Docker/Mermaid extensions). After cloning:
git submodule update --init --recursive
Then reopen the repo in VS Code with the Dev Containers extension.
Security notes
- Real secrets (
HCLOUD_TOKEN,*.tfvars, your SSH private key) must never be committed — see.gitignore. - SSH to
devandprodis restricted to thevpnserver's own public IP — you must be tunneled into the VPN to reach them over SSH. SSH tovpnitself is gated byvar.allowed_ssh_source_ips; narrow this from the default0.0.0.0/0once you know your own egress IP(s). vpnis intentionally excluded from the private network so that a compromised VPN endpoint cannot reachdevorproddirectly over it — SSH access still works because the VPN's egress traffic is NATed through its own public IP.- Firewalls are allowlists scoped per host in
infra/modules/<host>/main.tf— only ports actually used by that host's compose stacks (plus the cross-host private network range) are open. - Every host's post-install bootstrap disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (
var.deploy_user) for day-to-day access.
