This repository manages the infrastructure for multiple hosts using Ansible and Docker Compose.
The project follows a host-based isolation strategy where each top-level directory corresponds to a specific host (e.g., mx1, web1).
Most host directories contain:
ansible/: Playbooks and roles for system configuration.containers/: Docker Compose definitions for services running on that host.
Some hosts are provisioned with OpenTofu instead of Ansible. These contain a terraform/ directory with .tf files and a node-specific docs/ directory.
- Ansible: Configures the host system (OS hardening, users, firewall, etc.).
- Containers: The
system/containersrole copies the localcontainers/directory to/opt/containers/on the remote host. - Services: Services are deployed via
docker composeby iterating over the defined compose files. - Network: All containers typically attach to an external Docker network named
app-infra.
The repository currently manages the following hosts:
| Host | Tool | Purpose |
|---|---|---|
mx1 |
Ansible + Docker | Mail server |
web1 |
Ansible + Docker | Web/app services |
web2 |
Ansible + Docker | Web/app services |
web3 |
Ansible + Docker | Web/app services |
tower |
OpenTofu | ARM VM on OCI — WireGuard VPN endpoint and SSH bastion |
See tower/docs/setup.md for the one-time bootstrap steps for the tower node.
- Ansible: Must be installed on the machine running the playbooks.
- SSH Access: You need SSH access to the target hosts.
Navigate to the host's ansible directory and run the playbook:
cd <host>/ansible
ansible-playbook playbook.ymlSecrets are injected via environment variables using lookup('env', 'VAR_NAME'). Ensure these are set in your environment before running playbooks.
Common required variables include:
BORG_PASSPHRASEBORG_REPOSITORYMYSQL_PASSWORDBORG_HEARTBEAT_URL
The scheduled_run variable (controlled by the SCHEDULED_RUN environment variable) determines the scope of the execution:
SCHEDULED_RUN=true: Runs "heavy" tasks like OS hardening, APT updates, and Docker installation. This is typically used for maintenance runs.SCHEDULED_RUN=false(default): Skips heavy tasks for quick app deployments.
This infrastructure uses a migration system similar to Laravel's database migrations, but for system configuration. Migrations are one-time Ansible tasks that run only once per host.
- One-time execution: Each migration runs only once, tracked in
/opt/ansible/migrations.db - Per-host state: Each host maintains its own migration history
- Error handling: Playbook execution stops if a migration fails
- Versioned: Migrations use timestamped filenames (e.g.,
20240121_0001_setup_database.yml)
- Create a new
.ymlfile in<host>/ansible/migrations/ - Use timestamp format:
YYYYMMDD_NNNN_description.yml - Write standard Ansible tasks in the file
---
# Migration: 20240121_0001_create_app_user
- name: Create application user
user:
name: myapp
system: yes
shell: /bin/bash
- name: Create application directory
file:
path: /opt/myapp
state: directory
owner: myapp
group: myapp
mode: '0755'Migrations run automatically as part of the playbook execution via the system/migrations role. They execute before container deployment to ensure system prerequisites are met.
If a migration fails, the entire playbook stops to prevent inconsistent states.
Every pull request runs .github/workflows/validate.yml, which performs
static checks only. It never connects to a host, reads a state file, or
touches a cloud provider, so a broken change is caught before it can reach
production rather than during a deploy.
| Check | Tool | Catches |
|---|---|---|
| YAML | yamllint |
Syntax errors, duplicate keys, style drift |
| Ansible | ansible-lint |
Broken syntax, missing FQCN, non-idempotent commands, unset file modes |
| OpenTofu | tofu validate / tofu fmt |
Invalid or unformatted configuration for tower |
| Actions | zizmor |
Workflow-level privilege footguns |
ci/validate.shNeeds ansible-lint, yamllint, zizmor and tofu on PATH. The script
runs the same checks as CI, so failures reproduce locally.
The rules in CLAUDE.md (drop all capabilities, add no-new-privileges, bind
ports to loopback, pin image tags) are review-time guidance, not a gate. There
is no automated check, so a new service that omits cap_drop will pass CI and
rely on the reviewer noticing.
This was a deliberate trade-off. An earlier version enforced it with a script
keyed by an exemption list, but the contract is prose in CLAUDE.md and would
have had two places to drift apart. It was removed rather than maintained.
If you want it enforced, the exemption list is the part that needs writing
first — a service name alone is ambiguous, since worker is the
Docker-socket-holding authentik worker in one stack and an ordinary hardened
twenty.crm worker in another.
validate.yml runs on pull_request, so it is configured to hold no
credentials at all — the checks are entirely static analysis:
- It references no
secrets.*and noenvironment:, so the job runs with no access to repository or environment secrets regardless of who opened the pull request. permissions: contents: read, so the automaticGITHUB_TOKENcannot write.- Every
actions/checkoutsetspersist-credentials: false, so the token is not left in.git/configwhere later steps could read it. - Third-party actions are pinned to a full commit SHA, fixing the code that runs with this job's token.
All four checks are required status checks on main, so a pull request cannot
merge until they pass. If a check is retired or renamed, remove it from the
required_status_checks.contexts list in the same pull request — otherwise the
merge blocks waiting on a check that no longer reports.
The deploy workflows do use secrets — they have to. They are triggered by
push to main, schedule and manual dispatch, never by pull_request, so
they never run code from a pull request.
This repository uses Renovate to keep dependencies up-to-date:
- Docker Images: Updates Docker images in
docker-compose.ymlfiles across all hostcontainers/directories. - Ansible Requirements: Manages Ansible collections and roles in
requirements.ymlfiles for each host.
To enable Renovate:
- Install the Renovate GitHub app on the repository.
For private registries, configure hostRules in renovate.json with appropriate credentials.
- Create a new directory in
<host>/containers/<service_name>/. - Add a
docker-compose.ymlfile.- Ensure it uses the
app-infranetwork:networks: app-infra: external: true
- Note: Do not include the top-level
versionproperty indocker-compose.ymlfiles.
- Ensure it uses the
- Add the service name to the
compose_fileslist in the host's Ansible variables (usually indefaults/main.yml) if it is not dynamically discovered.
system/containers: Core deployment role. Copiescontainers/to/opt/containers/and runsdocker compose up.system/config: System-level config including UFW rules andsystemd-resolved.system/backup: Configures Borgmatic backups.system/apt: Handles package updates and installation (usually conditional onscheduled_run).