10 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 name)
│ ├── outputs.tf # pass-through outputs from each host module
│ ├── ssh.tf # looks up the SSH key already uploaded to Hetzner Cloud
│ ├── versions.tf # provider requirements
│ ├── terraform.tfvars.example
│ └── modules/
│ ├── network/ # shared private network + subnet (used by dev and prod)
│ ├── dev/ # dev server + firewall + volume
│ ├── 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
- An SSH key uploaded to that project (Console → Security → SSH Keys)
- OpenTofu
>= 1.6.0 - Docker + the Compose plugin on each target server
- DNS records for the domains in Service inventory pointed at the relevant server's public IP
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_name 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.
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 Runner → Watchtower
./spindown.sh # reverse order, then prunes images/volumes
# on prod
./spinup.sh # Traefik, then (after a 20s cert/registry settle) 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 | Runners/docker-compose.yml |
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:
- Gitea Actions runner: set a real
GITEA_RUNNER_REGISTRATION_TOKENinservices/dev/Runners/docker-compose.yml(generate one from the Gitea admin UI) before starting the runner. - 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, UFW, unattended-upgrades, Docker install — see
services/todo.md.
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,.envfiles) must never be committed — see.gitignore. - SSH is restricted to
var.allowed_ssh_source_ipson every host; 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.- 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.
