systemd-enabled Linux distribution container images for Ansible Molecule managed nodes.
These images are test targets. They run systemd as PID 1, ship Python 3, a
working package manager, D-Bus and a small set of tools that Ansible needs to
manage a Linux host. They are built for CI, Molecule and Ansible role
compatibility testing.
They are not production images. They are not hardened, they are not minimal for production workloads, and they intentionally keep a normal distribution userland so that roles behave the way they do on a real host.
A stock debian:13 or fedora:44 container does not run systemd. There is
no init as PID 1 and no /sbin/init, so any role that uses
ansible.builtin.systemd_service, ansible.builtin.service or
ansible.builtin.service_facts fails with:
System has not been booted with systemd as init system (PID 1). Can't operate.
Installing systemd in a container is not enough either. systemd must be the
container's init process, the container needs a cgroup v2 environment it can
manage, and the image has to avoid baking a fixed /etc/machine-id into every
container. These images take care of the image side; the runtime side is
documented below.
| This project | Ansible Execution Environment | |
|---|---|---|
| Purpose | managed node / test target | controller runtime |
| Contains Ansible | no | yes (ansible-core, collections) |
| Contains Python 3 | yes | yes |
Runs systemd as PID 1 |
yes | no |
Runs sshd |
no | no (usually) |
| Managed by | Molecule via container connection plugins | ansible-navigator / ansible-playbook |
| Used for | testing roles against a distribution | running playbooks |
The images deliberately do not install ansible, ansible-core or
openssh-server. Modern Molecule manages containers with the
containers.podman.podman or community.docker.docker connection plugins,
which execute commands inside the container directly. There is no SSH hop and
no controller software needed inside the managed node.
| Distribution | Version | Base image | Aliases | Upstream EOL | Image |
|---|---|---|---|---|---|
| Debian | 13 (trixie) | docker.io/library/debian:13 |
latest |
2030-06-30 | ghcr.io/ak1ra-lab/docker-systemd/debian:13 |
| Debian | 12 (bookworm) | docker.io/library/debian:12 |
- | 2028-06-30 | ghcr.io/ak1ra-lab/docker-systemd/debian:12 |
| Ubuntu | 26.04 (resolute) | docker.io/library/ubuntu:26.04 |
latest, lts |
2031-05-29 | ghcr.io/ak1ra-lab/docker-systemd/ubuntu:26.04 |
| Ubuntu | 24.04 (noble) | docker.io/library/ubuntu:24.04 |
- | 2029-05-31 | ghcr.io/ak1ra-lab/docker-systemd/ubuntu:24.04 |
| Rocky Linux | 10 | docker.io/rockylinux/rockylinux:10 |
latest |
2035-05-31 | ghcr.io/ak1ra-lab/docker-systemd/rocky:10 |
| Rocky Linux | 9 | docker.io/rockylinux/rockylinux:9 |
- | 2032-05-31 | ghcr.io/ak1ra-lab/docker-systemd/rocky:9 |
| AlmaLinux | 10 | docker.io/library/almalinux:10 |
latest |
2035-05-31 | ghcr.io/ak1ra-lab/docker-systemd/almalinux:10 |
| AlmaLinux | 9 | docker.io/library/almalinux:9 |
- | 2032-05-31 | ghcr.io/ak1ra-lab/docker-systemd/almalinux:9 |
| Fedora Linux | 44 | docker.io/library/fedora:44 |
latest |
2027-06-02 | ghcr.io/ak1ra-lab/docker-systemd/fedora:44 |
| Fedora Linux | 43 | docker.io/library/fedora:43 |
- | 2026-12-09 | ghcr.io/ak1ra-lab/docker-systemd/fedora:43 |
| CentOS Stream | 10 | quay.io/centos/centos:stream10 |
latest |
2030-05-31 | ghcr.io/ak1ra-lab/docker-systemd/centos-stream:10 |
| CentOS Stream | 9 | quay.io/centos/centos:stream9 |
- | 2027-05-31 | ghcr.io/ak1ra-lab/docker-systemd/centos-stream:9 |
amd64 and arm64 images are built for every entry. Both architectures are
runtime-tested in CI on native GitHub Actions runners (no QEMU). See
Multi-architecture.
Upstream EOL dates come from endoflife.date and are
recorded in matrix.yaml. When a release goes EOL it is removed from the
matrix; EOL releases are not kept as legacy images in the default maintenance
path.
Images are published to GitHub Container Registry:
ghcr.io/ak1ra-lab/docker-systemd/<distro>:<version>
Examples:
ghcr.io/ak1ra-lab/docker-systemd/debian:13
ghcr.io/ak1ra-lab/docker-systemd/ubuntu:24.04
ghcr.io/ak1ra-lab/docker-systemd/rocky:9
ghcr.io/ak1ra-lab/docker-systemd/centos-stream:10
Tag policy:
-
Version tags (
debian:13,ubuntu:24.04) are immutable in meaning:debian:13always means the Debian 13 test image. The content is rebuilt weekly from the current upstreamdebian:13, so security updates flow in without changing the tag's meaning. -
Alias tags are floating and auto-updated from
matrix.yaml:<distro>:latestpoints to the newest supported release of that distro.ubuntu:ltspoints to the newest supported Ubuntu LTS.
-
Git commit SHAs and build dates are not part of the tag scheme. If you need bit-for-bit reproducibility, pin the image by digest:
ghcr.io/ak1ra-lab/docker-systemd/debian:13@sha256:<digest>
- A Linux host with cgroup v2 (
stat -fc %T /sys/fs/cgroupprintscgroup2fs). systemd containers do not work on cgroup v1 hosts. - Docker or Podman.
- For rootless Podman, the cgroup v2 hierarchy must be delegated to your user (the default on current desktop distributions with a systemd user session).
- Ansible, if you want to run the smoke tests or the Molecule scenario.
On a cgroup v2 host, the least-privilege command that boots systemd was verified with Docker Engine 29 on Debian 13:
docker run -d --name systemd-test \
--cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--cgroupns=host \
--volume /sys/fs/cgroup:/sys/fs/cgroup:rw \
ghcr.io/ak1ra-lab/docker-systemd/debian:13
docker exec systemd-test systemctl is-system-running
docker exec systemd-test systemctl statusWhy each option is needed:
--cap-add SYS_ADMIN— systemd needs to mount filesystems and set up namespacing for services (PrivateTmp=,ProtectSystem=, ...). The systemd documentation explicitly says not to dropCAP_SYS_ADMINfrom containers.--cgroupns=hostand a writable/sys/fs/cgroup— with a private cgroup namespace and a read-only cgroup mount, systemd exits immediately. This combination was tested; it is required on Docker Engine 29 with cgroup v2.--security-opt apparmor=unconfined— on hosts with AppArmor enabled, the defaultdocker-defaultprofile blocks what systemd needs. Omit this flag on hosts without AppArmor.
--privileged also works and is simpler, but grants far more access than
needed. Use it only if a specific role requires it.
Running Ansible directly against the container (no SSH):
ansible -i systemd-test, all -c community.docker.docker -m ansible.builtin.setupPodman needs no extra privileges. This follows the official Molecule systemd container guide:
podman run -d --name systemd-test --systemd=always \
ghcr.io/ak1ra-lab/docker-systemd/debian:13
podman exec systemd-test systemctl is-system-running
podman exec systemd-test systemctl status--systemd=always wires up the cgroup and tmpfs mounts systemd needs. Rootless
Podman works when the cgroup v2 hierarchy is delegated to your user.
Running Ansible directly against the container:
ansible -i systemd-test, all -c containers.podman.podman -m ansible.builtin.setupThis repository contains a working
Ansible-native Molecule
scenario in molecule/systemd/ that uses these images as
managed nodes. It is also what CI runs.
The scenario uses a standard Ansible inventory. Container images are inventory variables, and the lifecycle is driven by ordinary playbooks:
# molecule/systemd/inventory/hosts.yml (generated from matrix.yaml)
all:
children:
molecule:
hosts:
debian-13:
container_image: ghcr.io/ak1ra-lab/docker-systemd/debian:13
rocky-9:
container_image: ghcr.io/ak1ra-lab/docker-systemd/rocky:9# molecule/systemd/inventory/group_vars/molecule.yml
ansible_connection: containers.podman.podman
container_command: /sbin/init
container_systemd: always# molecule/systemd/create.yml (excerpt)
- name: Create containers from the inventory
containers.podman.podman_container:
name: "{{ item }}"
image: "{{ hostvars[item]['container_image'] }}"
command: "{{ hostvars[item]['container_command'] }}"
systemd: "{{ hostvars[item]['container_systemd'] }}"
state: started
loop: "{{ groups['molecule'] }}"# molecule/systemd/converge.yml (excerpt)
- name: Start and enable the integration test service
ansible.builtin.systemd_service:
name: molecule-demo.service
state: started
enabled: trueconverge.yml installs a package with ansible.builtin.package, deploys a
small molecule-demo.service unit and starts it with
ansible.builtin.systemd_service. verify.yml uses service_facts and a
heartbeat file that the service keeps updating to prove it is actually
running, then stops and disables it. No Ansible and no SSH server are
installed inside the image.
To use a Docker connection instead, the same scenario works with
community.docker.docker_container and community.docker.docker. The
container needs the same options as the docker run example above:
- name: Create containers from the inventory
community.docker.docker_container:
name: "{{ item }}"
image: "{{ hostvars[item]['container_image'] }}"
command: /sbin/init
capabilities: [SYS_ADMIN]
security_opts: ["apparmor=unconfined"]
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
state: started
loop: "{{ groups['molecule'] }}"and set ansible_connection: community.docker.docker in the inventory. On
hosts without AppArmor, drop the apparmor=unconfined entry from
security_opts.
The images provide the image-side half of the systemd Container Interface:
| Responsibility | Where |
|---|---|
systemd as PID 1 via CMD ["/sbin/init"] |
image |
STOPSIGNAL SIGRTMIN+3 for clean shutdown |
image |
Empty /etc/machine-id so each container generates its own |
image |
| D-Bus system bus available (socket activated) | image |
Python 3, package manager, sudo, iproute, procps, CA certificates |
image |
| cgroup v2 hierarchy and namespace | runtime |
CAP_SYS_ADMIN / AppArmor profile / cgroup mount |
runtime |
Podman --systemd=always (systemd: always in Molecule) |
runtime |
tmpfs for /run and /run/lock (Podman systemd mode provides these) |
runtime |
Deliberate choices:
- No unit files are deleted or masked. Older Molecule images removed
systemd*udev*andgetty.targetas a workaround for high CPU usage. With current systemd,systemd-udevddoes not start when/sysis read-only, getty services are not spawned for VTs that do not exist in the container, andsystemd-modules-loadis skipped unless the container is given full capabilities. Deleting unit files breakssystemctlfidelity and is no longer necessary. - No
VOLUME ["/sys/fs/cgroup"]. cgroup mounting is a runtime responsibility; declaring anonymous volumes only creates surprises. - No
ENV container=.... Podman setscontainer=podmanitself and systemd detects Docker through/.dockerenv, sosystemd-detect-virt -creportsdockerorpodmancorrectly. degradedis an acceptable boot state. In containers some units are expected to fail or be skipped (for example module loading). Tests acceptrunningordegraded, and never requirerunningalone.
make venv # create .venv with PyYAML, Jinja2, Molecule, linters
make generate # regenerate Dockerfiles from matrix.yaml
make build # build every image with Podman, 4 in parallel
make build RUNTIME=docker # build with Docker insteadhack/build.sh can also build a single image:
hack/build.sh debian 13
hack/build.sh --all --runtime docker --jobs 4 --pullThe build context of every image is just its own directory; there are no shared build artifacts.
Multi-architecture builds use Docker Buildx or podman build --platform; see
.github/workflows/ci.yml for the CI implementation.
A successful build does not prove the image works. Every image is started and exercised:
make smoke # every image, Podman
hack/smoke-test.sh --all --runtime docker
hack/smoke-test.sh ghcr.io/ak1ra-lab/docker-systemd/debian:13The smoke test verifies:
- systemd is PID 1 and reaches
runningordegraded; - systemd detects that it runs in a container (
systemd-detect-virt -c); systemctl list-units,systemctl statuswork;- the system D-Bus is usable (
busctl list); python3runs and/etc/machine-idis not empty;- a package manager is present;
- a generated
molecule-test.servicecan be started, restarted, stopped, enabled and disabled, and the process really exists while running; - Ansible facts can be gathered with the runtime's connection plugin.
ansible-core and the community.docker / containers.podman collections
must be installed for the facts check.
make molecule
# or, step by step:
hack/build.sh --all --runtime podman
molecule test -s systemdCI runs the scenario as root with rootful Podman on GitHub-hosted runners.
Rootful Podman avoids rootless cgroup delegation differences on CI runners and
is the most predictable way to run systemd containers there. The images are
prepared with hack/ci-load-images.sh, which rebuilds them from the Docker
build cache the smoke tests populate and loads them into Podman, so the job
does not pay for a second cold build. Locally, rootless Podman is the
recommended path and is what the examples use. Docker is covered by the
per-image smoke tests plus the documented docker run and community.docker
configuration.
- Edit
matrix.yaml: add or update a distro version with itsbase_image,eoldate and alias tags. - Run
make generate. This updates the Dockerfiles, the Molecule inventory and the table in this README. - Run
make buildandmake smoke. - Run
make moleculeif you want the full integration check. - Commit the changes, including generated files.
Adding a new distribution family (a new package manager or init layout) means
adding a template under templates/ and a families entry; no CI changes are
needed because workflows read matrix.yaml.
Removing an EOL release is the same flow in reverse: delete it from
matrix.yaml, run make generate, and commit. CI fails if generated files
drift from matrix.yaml.
See docs/maintenance.md for the full maintenance
policy.
- Lint, including the generated-file drift check, runs on every pull request and push.
- The build, smoke test and Molecule integration stages run only when the
files they depend on changed:
matrix.yaml,templates/,images/, the build and test scripts underhack/, the Molecule scenario, the Python requirements or the workflow itself. A documentation-only change skips them. - Pushes to
mainpublish multi-architecture manifests to GHCR after those checks, but only when the image inputs changed. Test-only changes run the suite without publishing. - A scheduled run every Monday at 03:17 UTC rebuilds everything from the current upstream base images, regardless of file changes. This is how security updates reach the images even when this repository does not change. Scheduled runs execute lint, build-smoke and molecule before publishing.
workflow_dispatchallows manual rebuilds and also ignores the path gating.
Version tags are updated in place by these rebuilds. Consumers who need an immutable reference should pin by digest.
The workflows assume the default branch is named main; adjust the triggers
in .github/workflows/ci.yml if your default branch differs. The first
publish creates the GHCR packages; set their visibility to public in the
package settings if you want anonymous pulls.
Base images are referenced by tag (debian:13, rocky:9, ...) and not
pinned by digest. The rationale:
- These are CI test images. Automatically picking up upstream security fixes matters more than bit-for-bit reproducibility.
- A weekly scheduled rebuild re-resolves each base tag, so fixes flow in without human intervention.
- BuildKit resolves the base tag to a digest for cache invalidation, and the
published image records its base via
org.opencontainers.image.base.nameplus the build revision label. - Consumers who need reproducibility can pin the published image by digest; digests are visible in the registry.
If this project ever needs digest pinning, the change is local to
matrix.yaml: replace base_image: debian:13 with
base_image: debian:13@sha256:... and add a dependency bot (Renovate
understands Docker digests). It is intentionally not enabled by default because
a bot that is not installed would silently freeze the images on old base
digests.
Downloaded package archives are removed at build time, but package-manager
metadata (apt lists, dnf repository metadata) is kept on purpose: roles
that install packages behave the same way they do on a real host, without
needing an explicit cache update first. The cost is roughly 20 MB per
Debian-family image and 90 MB per RPM-family image, which is an acceptable
trade for test fidelity.
No secrets or credentials are used or stored. GitHub Actions are pinned by
commit SHA.
- Only distributions that upstream still supports are in
matrix.yaml. - EOL dates are recorded per version and reviewed as part of routine maintenance; maintainers remove EOL releases in a dedicated change.
- There is no legacy path for EOL images in the default workflow. If EOL images are ever needed (for example to test a role on a frozen platform), they should be built from a separate, clearly named repository or branch rather than mixed into this matrix.
linux/amd64 and linux/arm64 are built for every image, and both are
runtime-tested natively in CI:
ubuntu-24.04(amd64) andubuntu-24.04-arm(arm64) run the build and the full smoke test.- Multi-architecture manifests are assembled and pushed by Buildx. The publish job runs on amd64 and uses QEMU only to cross-build the arm64 half.
No QEMU runtime testing is used, so the arm64 results reflect native execution.
Note that amd64 images based on RHEL 10 (rocky:10, almalinux:10,
centos-stream:10) require an x86-64-v3 capable CPU, which is an upstream
requirement of RHEL 10; this is not a property of this project.
This project was designed from public documentation and existing projects. The implementation is an independent engineering effort; nothing here is claimed to be original research.
Official documentation:
- Ansible Molecule: Systemd container guide
- Ansible Molecule: Using podman containers
- Ansible Molecule: Ansible-native configuration
- systemd: Container Interface
- containers.podman.podman_container
- containers.podman.podman connection
- community.docker.docker_container
- Docker: resource constraints and cgroup drivers
Prior art:
- geerlingguy/docker-debian12-ansible
and the other
geerlingguy/docker-*-ansiblerepositories (MIT). They are an important reference for systemd containers, but this project deliberately does not copy their architecture: no Ansible inside the image, no/etc/ansible/hosts, noinitctl_faker, no deletion of systemd udev/getty unit files, noVOLUME /sys/fs/cgroup, and no privileged container as the default. Those were sensible workarounds for older Molecule and cgroup v1 environments; they are not needed with current systemd, cgroup v2 and Ansible-native Molecule. - endoflife.date for distribution lifecycle data.
- The Molecule documentation's systemd guide is the basis for the Podman
runtime model and the
container_command/container_systemdinventory variables used by the integration scenario.
MIT. See LICENSE.