# Ansible Bootstraps, syncs, and starts/stops the Docker Compose stacks in `../services/` on the three servers OpenTofu provisions (see `../infra/`). OpenTofu still owns the actual cloud resources (servers, network, firewalls, DNS); everything downstream of "the server exists" - installing Docker, creating the `deploy` user, hardening SSH, copying `services//`, rendering `.env` / the Gitea runner compose file, and running `spinup.sh`/`spindown.sh` - lives here instead. Persistent service data lives in named Docker volumes backed up to S3, not on a Hetzner volume - see `../readme.md`. ## Contents - `inventory/hcloud.yml` - dynamic inventory, queries the Hetzner Cloud API directly (not Terraform state) and groups servers by their `role` label (`role_dev`, `role_prod`, `role_vpn` - set in `infra/modules//main.tf`). - `roles/bootstrap/` - Docker install, `deploy` user creation, SSH hardening, unattended-upgrades. Replaces `infra/scripts/bootstrap.sh.tftpl`. - `roles/deploy/` - copies `services//` to the server and renders `.env` (S3 backup credentials, see `../readme.md`) and, for `dev`, `Runners/docker-compose.yml`. - `playbooks/` - `bootstrap.yml`, `deploy.yml`, `spinup.yml`, `spindown.yml`, `site.yml` (all three in order), and `group_vars/` (`all.yml` - shared: `deploy_user`, SSH key path, `backup_s3_*` S3 backup credentials; `role_dev.yml` / `role_prod.yml` / `role_vpn.yml` - per-role vars, e.g. `service_group`). `group_vars/` lives here rather than at the `ansible/` root because Ansible only auto-loads `group_vars/`/`host_vars/` from directories adjacent to the playbook file or the inventory source - not from an arbitrary sibling directory. ## Prerequisites ```sh cd ansible ansible-galaxy collection install -r requirements.yml export HCLOUD_TOKEN=your-hetzner-api-token # never commit this export BACKUP_S3_BUCKET=your-bucket-name export BACKUP_S3_ACCESS_KEY_ID=your-access-key-id export BACKUP_S3_SECRET_ACCESS_KEY=your-secret-access-key export BACKUP_S3_ENDPOINT=your-s3-endpoint # omit for real AWS S3 ``` `../control.sh`'s **Set variables** menu does the same thing interactively and persists your answers to `../.control.env` (gitignored) so you don't have to re-export them every session. `deploy_user` (see `playbooks/group_vars/all.yml`) defaults to `deploy` - override with the `DEPLOY_USER` env var if you want a different non-root user created on each host. `ansible_ssh_private_key_file` (same file) defaults to `~/.ssh/id_ed25519` - override with `ANSIBLE_SSH_PRIVATE_KEY_FILE` if you use a different key. It must match one of the SSH keys OpenTofu installed on the servers (`var.ssh_key_names`). ## Usage Same chicken-and-egg as OpenTofu's own first-apply ordering (see `../readme.md`): `dev`/`prod`'s firewalls only accept SSH from `vpn`'s own public IP, so you must bring `vpn` up and connect to it before `dev`/`prod` are reachable at all. ```sh # 1. vpn first - its firewall accepts SSH from var.allowed_ssh_source_ips directly 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 # 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 ansible-playbook playbooks/spinup.yml -l role_dev,role_prod # ...or once already bootstrapped and connected, do everything in one go: ansible-playbook playbooks/site.yml -l role_dev,role_prod ``` Re-run `deploy.yml` + `spinup.yml` any time a compose file changes - `spinup.sh` only starts/updates what's changed, same as before. `dev` always runs three Gitea Actions runners (fixed in `roles/deploy/templates/runners-docker-compose.yml.j2`). `spindown.yml` runs `spindown.sh`, which does a full `docker system/volume prune -a` on the host - it prompts for a `yes` confirmation before running, since the dynamic inventory makes it trivial to target multiple hosts at once (`-l role_dev,role_prod`). Inspect what the dynamic inventory currently sees: ```sh ansible-inventory --graph ansible-inventory --list ```