# Prod host provisioning (Stage 18) Idempotent Ansible that prepares the two production hosts. It installs Docker, a non-sudo `deploy` service account, SSH hardening, a default-deny firewall, fail2ban, unattended security upgrades and time sync. It does **not** deploy the application — that is `.gitea/workflows/prod-deploy.yaml`'s job, running as the `deploy` account this playbook creates. Hosts are referenced by `~/.ssh/config` aliases (`scrabble-main-ops`, `scrabble-tg-ops`), so no IPs or key paths live in the repo. ## Prerequisites (controller) - `ansible` with the bundled collections (`community.general`, `community.docker`, `ansible.posix`). - The two hosts reachable as root via the ssh-config aliases, host keys already accepted into `known_hosts` (`host_key_checking = True`). ## One-time: the CI deploy key The CI prod-deploy workflow logs into the hosts as `deploy` using a dedicated key. Generate it once on the controller, authorize its public half via the playbook, and store its private half **only** in the Gitea `PROD_SSH_KEY` secret: ```sh ssh-keygen -t ed25519 -N '' -C scrabble-ci-deploy \ -f ~/.ssh/scrabble_ci_deploy_ed25519 # private half -> Gitea secret PROD_SSH_KEY (set via API); never commit it ``` ## Run ```sh cd deploy/ansible ansible-playbook site.yml ``` The playbook reads the public key from `~/.ssh/scrabble_ci_deploy_ed25519.pub` by default; override with `-e deploy_ci_pubkey_path=/path/to/key.pub`. Re-running is safe (idempotent) and survives a host resize. ## What each host gets - **both** (`common`): docker-ce + compose plugin, `daemon.json` (live-restore, 10m×3 log rotation), `deploy` user (docker group, no sudo), key-only sshd, `ufw` default-deny incoming + allow SSH, fail2ban sshd jail, unattended upgrades, chrony, `/opt/scrabble/{config,certs,dumps,images}`. - **main**: `ufw` opens 80/443/9443; the external `edge` docker network. - **tg**: verifies direct `api.telegram.org` egress (the no-VPN assumption).