Ansible collection specsnl.specsops — reusable roles for provisioning and hardening
Ubuntu 26.04 (Resolute) hosts at Specs.
| Role | Description | Docs |
|---|---|---|
specsnl.specsops.base |
apt update/upgrade, core packages, locale, timezone, sysctl | roles/base |
specsnl.specsops.hardening |
sshd drop-in hardening + fail2ban | roles/hardening |
specsnl.specsops.firewall |
ufw baseline + parameterized extra rules | roles/firewall |
specsnl.specsops.unattended_upgrades |
chrony + unattended-upgrades + apt config | roles/unattended_upgrades |
specsnl.specsops.postgresql |
PGDG repo, PostgreSQL install, tuning, pg_hba, ufw port |
roles/postgresql |
specsnl.specsops.swap |
swap file create/format/persist/activate + sysctl | roles/swap |
specsnl.specsops.logrotate |
global logrotate maxsize + compression | roles/logrotate |
specsnl.specsops.cleanup |
apt autoremove/clean, wipe temp dirs (build-time) | roles/cleanup |
specsnl.specsops.podman |
Podman from the Ubuntu universe repo (Quadlet included) |
roles/podman |
specsnl.specsops.caddy |
Cloudsmith apt repo, Caddy install, service, ufw 80/443 | roles/caddy |
specsnl.specsops.specsdeployd |
deploy receiver scaffolding; binary install is opt-in | roles/specsdeployd |
specsnl.specsops.ansible_pull |
ansible-pull service + timer, disabled and inert by default | roles/ansible_pull |
specsnl.specsops.cloud_init_user |
cloud-init default user: non-root sudo login for images | roles/cloud_init_user |
Every role documents its variables in its own README; docs/README.md carries the condensed index and the notes on container safety.
All roles detect container environments and skip the steps that cannot work there
(ufw enable, systemctl start, swapon, sysctl reload), so the same roles run
unchanged in a Packer build container, in Molecule, and on a live VM.
- Ansible Core >= 2.16
- Ubuntu 26.04 LTS (Resolute Raccoon)
- Collections:
community.general,ansible.posix(installed automatically via Galaxy)
# requirements.yml
collections:
- name: git+https://github.com/specsnl/specsops-ansible-collection.git
type: git
version: mainansible-galaxy collection install -r requirements.yml- hosts: all
become: true
roles:
- specsnl.specsops.base
- specsnl.specsops.hardening
- specsnl.specsops.firewall
- specsnl.specsops.unattended_upgrades
- specsnl.specsops.swap
- specsnl.specsops.logrotateIf you use both base and swap, note that they each manage vm.swappiness in their
own /etc/sysctl.d/ drop-in. Set it in one of them, not both.
The app-image roles layer on top. Order matters twice: firewall before caddy, which
skips its ufw rules with a warning when ufw is absent, and podman before
specsdeployd, whose sudoers drop-in escalates to podman pull.
- hosts: app
become: true
roles:
- specsnl.specsops.base
- specsnl.specsops.firewall
- specsnl.specsops.podman
- specsnl.specsops.caddy
- specsnl.specsops.specsdeployd
- specsnl.specsops.ansible_pullspecsdeployd and ansible_pull ship inert by default — no binary and no repository —
so the same playbook bakes a golden image and provisions a live host. Set
specsdeployd_version and ansible_pull_repo to make either one live.
Everything runs in containers via Task and Docker Compose —
you need Docker and task on your machine, but no local Ansible, Python or Molecule.
The ansible service (ghcr.io/specsnl/ansible) mounts the repo at /workspace and
the Docker socket, so Molecule can start test containers from inside it.
# Lint (yamllint + ansible-lint)
task ansible:lint
task ansible:lint:ansible:fix # auto-fix what ansible-lint can
# Test a single role (destroy → syntax → create → converge → idempotence → verify → destroy)
task ansible:test:base
# Test all roles
task ansible:test
# Iterate on one role without tearing the container down
task ansible:converge:base
task ansible:verify:base
task ansible:destroy:base
# Markdown
task md:checkstyle
task md:fixstyle
# Interactive shell in the Ansible container
task shell
# Build / install the collection tarball into ./dist
task galaxy:build
task galaxy:install:localtask --list shows every task.
Molecule tests run against geerlingguy/docker-ubuntu2604-ansible, pinned by digest,
with systemd as PID 1.
The Molecule driver is docker, and compose.yml bind-mounts /var/run/docker.sock
into the ansible service. With Podman you need a Docker-API-compatible socket at that
path, so run Compose against the Podman socket and expose it:
podman system service --time=0 &
export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock
task ansible:test:baseIf the socket lives elsewhere, override the bind mount in a compose.override.yml
rather than editing compose.yml.
- Add a changelog fragment under
changelogs/fragments/ - Bump
version:ingalaxy.yml - Run
task changelog:release:<version>— regeneratesCHANGELOG.rstand consumes the fragments - Commit, tag
<version>, push
Tags carry no v prefix — 0.1.0, not v0.1.0 — matching the upstream Ansible
collections. The Tags must not have v-prefix ruleset under
.github/rulesets/ enforces this.
The tag workflow first asserts that the GALAXY_API_KEY secret is set and that the tag
matches version: in galaxy.yml, reporting both problems at once if both are wrong.
It then builds the tarball and publishes it to Ansible Galaxy.
| Workflow | Trigger | Runs |
|---|---|---|
| pr.yml | pull request | Lint + Molecule, only for the roles the PR touches |
| main.yml | push to main |
Lint + Molecule for all roles |
| md.yml | **.md changes |
markdownlint + table formatting |
| tag.yml | X.Y.Z tag |
Build + publish to Ansible Galaxy |
.github/rulesets/ holds snapshots of the repository rulesets. They are
not applied automatically — import them under Settings → Rules → Rulesets. Main requires a
pull request and the Check and Markdown gate jobs; the other jobs are conditional and
would deadlock a PR that does not touch their paths.