Ansible configuration for TEC's servers. It currently manages two hosts,
tec-rigs, which runs PyRIGS
(rigs.nottinghamtec.co.uk) and techost (a general purpose host of
various services).
This repository is public. Never commit plaintext secrets: anything sensitive goes in the Ansible Vault file (see Secrets).
| Role | Purpose |
|---|---|
users |
Local users with SSH key login only (passwords locked), optional passwordless sudo |
ssh |
Disables SSH password authentication via a drop-in in /etc/ssh/sshd_config.d/ |
fail2ban |
Bans addresses that repeatedly fail SSH logins |
ufw |
Firewall: denies incoming traffic except ports 22, 80 and 443 (configurable per host) |
unattended_upgrades |
Upgrades at ~02:00, reboot at 03:30 (server time), allows Docker's apt repo |
debug_utils |
Generic debug utilities: wtmpdb, emacs-nox, etc. |
swap |
2 GB swap file with vm.swappiness=10 |
docker |
Docker Engine from Docker's official apt repository |
postgres |
PostgreSQL, the rigs role and database, and password login over the local unix socket |
pyrigs |
PyRIGS (ghcr.io/nottinghamtec/pyrigs) via compose, deploy script and user, cron jobs |
nginx |
nginx reverse proxy; serves /static/ directly |
certbot |
Requests the certificate (webroot), renews via certbot.timer, reloads nginx on renewal |
ingress |
nginx and certbot containers in /root/ingress on techost: reverse proxy, redirects and certificates for its containers |
Housekeeping and safety nets in the pyrigs role: every deploy sends a request to the new container and rolls back
if it doesn't answer, and each week pyrigs-prune-images removes old image tags (keeping the newest few, the deployed
tag and anything in use) and pyrigs-prune-static removes static files that no deploy in the last 30 days used (the
current files are never removed, and nothing is removed until 30 days of deploy manifests exist). Both accept
--dry-run. pg_stat_statements is enabled in Postgres for query statistics.
Roles run in the order above (see playbooks/site.yml). users runs before
ssh on purpose, so keys exist before password login is disabled.
ansible.cfg Ansible settings (inventory, vault password file)
inventory/hosts.yml Hosts (group: servers)
inventory/group_vars/all/*.yml Variables for every host
inventory/group_vars/all/vault.yml ENCRYPTED secrets (Ansible Vault)
playbooks/site.yml The playbook that applies every role
roles/<name>/ One role per concern
requirements.yml Ansible collections
pyproject.toml / uv.lock Python dependencies (managed with uv)
.pre-commit-config.yaml Lint hooks (run by prek)
You need uv and SSH access to the host.
uv sync # Python deps (ansible-core, ansible-lint, prek)
uv run ansible-galaxy collection install -r requirements.yml -p .ansible/collections
uv run prek install # git pre-commit hookThen put the vault password in .vault_pass at the repository root (it is
gitignored). Get it from Bitwarden. ansible.cfg points at this
file, so Ansible will refuse to run without it.
Always preview first:
uv run ansible-playbook playbooks/site.yml --check --diffApply everything, or just one part with a tag:
uv run ansible-playbook playbooks/site.yml
uv run ansible-playbook playbooks/site.yml --tags pyrigsNotes on --check: some tasks are skipped in a dry run because they depend on
something an earlier task would only create for real (a new apt repo, a newly
installed package, a file that hasn't been written yet). That is expected.
PyRIGS runs from a small docker compose project in /opt/pyrigs, driven by one
script, /usr/local/sbin/pyrigs-deploy [tag]. It pulls the image, copies its
static files to /var/www/pyrigs/static, recreates the container and waits for
the health check. If the new container doesn't become healthy, it restores the
previous tag. The deployed tag is recorded in /opt/pyrigs/deploy.env.
Releases normally happen automatically (see Automatic deployments). To deploy by hand:
ssh <you>@tec-rigs sudo pyrigs-deploy sha-1a2b3c4 # a specific commit
ssh <you>@tec-rigs sudo pyrigs-deploy # redeploy the current tagTo roll back, deploy the previous tag the same way. Re-running the Ansible
pyrigs role calls the same script with the recorded tag, so it never reverts a
deploy.
On every container start, manage.py migrate runs before gunicorn. Static files
are served by nginx (falling back to the app) and old files are kept, so pages
loaded before a deploy still get their assets. The container restarts
automatically (restart: always).
CI in the PyRIGS repo builds and pushes latest and sha-<short commit> images
to GHCR. A final job SSHes to the server as the deploy user, whose key can run
only pyrigs-deploy <tag>: the key is locked to that forced command with
restrict, sudo allows that one script, and the script only accepts plain tag
characters.
4. Update the workflow in the PyRIGS repo (.github/workflows/ci.yml). The
docker job exposes the exact commit tag that docker/metadata-action
generated, and a new deploy job uses it:
docker:
# ...existing job...
outputs:
tag: ${{ steps.sha-tag.outputs.tag }}
steps:
# ...existing steps, including the `meta` step and the build/push step...
- id: sha-tag
name: Pick the commit tag
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
ref="$(grep -m1 ':sha-' <<< "$TAGS")"
test -n "$ref"
echo "tag=${ref##*:}" >> "$GITHUB_OUTPUT"
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
environment: production
concurrency: deploy-production
steps:
- name: Deploy the image built for this commit
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
TAG: ${{ needs.docker.outputs.tag }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh deploy@rigs.nottinghamtec.co.uk "$TAG"The tag comes from the metadata step's own tags output rather than being
rebuilt from GITHUB_SHA, so it stays correct if the tag format changes. It is
passed through an environment variable, not interpolated into the script. The job
fails if the deploy fails, including when the health check fails and the script
rolls back.
Caveats: a deploy replaces the container, so requests are interrupted for a few seconds. Migrations run on start and are not reversed by a rollback, so keep them backward-compatible.
- Edit variables in
inventory/group_vars/all/or the relevant role. - Run
uv run prek run --all-files(hooks plusansible-lint,productionprofile; the commit hook runs the same checks). - Preview with
--check --diffagainst the host, then apply with the narrowest--tagsthat covers the change. - Commit.
Common changes:
| To... | Edit |
|---|---|
| Add or remove a user | users_accounts in inventory/group_vars/all/users.yml (state: absent removes a user) |
| Add a docker group member | docker_users in inventory/group_vars/all/docker.yml |
| Add a website | nginx_sites and certbot_certificates in inventory/group_vars/all/web.yml |
| Add a database or DB role | postgres_roles / postgres_databases in inventory/group_vars/all/postgres.yml |
| Change PyRIGS settings | pyrigs_env (plain) or the vault (secrets); see below |
| Change the reboot time | unattended_upgrades_reboot_time |
| Change the SSH config | roles/ssh/templates/00-ansible-hardening.conf.j2 |
Each role's defaults/main.yml documents every variable it accepts.
mkdir -p roles/<name>/{tasks,defaults,meta}Add tasks/main.yml, defaults/main.yml and meta/main.yml, and register the
role in playbooks/site.yml with a tag. Variables defined in a role need the
role name as a prefix (for example pyrigs_port), which ansible-lint enforces.
Secrets live in inventory/group_vars/all/vault.yml, encrypted with Ansible
Vault. The ciphertext is public, so the vault password must be long and random.
uv run ansible-vault view inventory/group_vars/all/vault.yml # read
uv run ansible-vault edit inventory/group_vars/all/vault.yml # changeThe vault holds vault_postgres_rigs_password (the rigs database password) and
vault_pyrigs_secret_env (PyRIGS secrets such as SECRET_KEY).
- Rotate the database password: edit the vault, then run
--tags postgres,pyrigs. - Rotate the vault password:
uv run ansible-vault rekey inventory/group_vars/all/vault.yml, then share the new password with the team and update.vault_pass.
docker ps --filter name=pyrigs
docker logs -f pyrigs
journalctl -t pyrigs-deploy # deploy history
cat /opt/pyrigs/deploy.env # currently deployed tag
docker inspect --format '{{.State.Health.Status}}' pyrigsThe environment is in /etc/pyrigs/pyrigs.env (root only). The app listens on
127.0.0.1:8000 and reaches PostgreSQL through the mounted unix socket
(/var/run/postgresql). If a migration fails during a deploy, the health
check fails and pyrigs-deploy rolls back to the previous tag; the cause will be
in the deploy output or docker logs pyrigs. A container that crashes later
restarts automatically.
pyrigs_cron_jobs (in roles/pyrigs/defaults/main.yml) defines the cron jobs,
written to /etc/cron.d/pyrigs. Each runs docker exec pyrigs ... and logs to
the journal. Times are server time (UTC).
| Job | When | Command |
|---|---|---|
| Cleanup | daily at 00:00 | manage.py cleanupregistration then manage.py usercleanup |
| Reminders | daily at 08:00 | manage.py send_reminders |
| Check-out nags | daily at 17:00 | manage.py send_checkout_nags |
| Badges | hourly, 07:00–23:00 | manage.py award_badges --quiet |
journalctl -t pyrigs-cleanup
journalctl -t pyrigs-reminders
journalctl -t pyrigs-checkout-nags
journalctl -t pyrigs-badgesCertbot's systemd timer renews automatically, and a deploy hook reloads nginx.
certbot certificates
certbot renew --dry-run
systemctl list-timers certbot.timersudo -u postgres psql -d rigsunattended-upgrades installs security updates (and Docker's) daily from 02:00,
with up to 30 minutes of random delay, and reboots at 03:30 server time (UTC)
when an update requires it. The reboot is skipped if someone is logged in.
The ingress role runs nginx and certbot as containers from /root/ingress on techost. Sites and redirects are listed in inventory/group_vars/all/ingress.yml (ingress_sites,
ingress_redirects). nginx joins the proxy docker network and forwards to the other containers by name, looking the
names up per request so a recreated container is picked up. It also answers to every public hostname on that network
(network aliases), so containers can call each other through the proxy by public name.
Certificates live in /root/ingress/letsencrypt and are requested with certbot (webroot) when a site has none. Certbot renews from cron (03:17 and 15:17, /etc/cron.d/ingress-renew) and reloads
nginx. A site without a certificate is served over plain HTTP until one has been issued.