One Ansible image for GitLab CI, built right from a Paris basement
Every CI runner that runs Ansible has the same problem: the job needs ansible-core, the collections for your platforms, git, ssh and it needs all of it now, on a machine that will be thrown away after the job. The official images exist, but they drift: latest changes under you, the version you pinned gets rebase-rolled and installing eight Galaxy collections per job burns minutes on every pipeline.
So I wrote my own, alpine/ansible, an Ansible CI toolkit image for my GitLab runners. And it was not born yesterday: I put it together for myself around 2016 and in 2018 it replaced, at a Paris employer, a CI that dumbly reinstalled Ansible on every run. In 2022 I am finally making it public and taking the chance to reread its history, early sins included.
The one decision I got right on day one: the venv stage
The Dockerfile opens with the pattern that survived every later rewrite:
FROM alpine:3.15.0 as builder
RUN apk add --virtual .build-deps gcc musl-dev libffi-dev ...
RUN python3 -m venv /opt/venv && \
pip install --no-compile ansible-core==$VERSION PyMySQL docker pyvmomi ...
FROM alpine:3.15.0 as default
COPY --from=builder /opt/venv /opt/venv
The builder compiles what needs compiling (cffi, lxml, cryptodome friends); the runtime copies only the finished venv. No gcc in the shipped image, no build headers, a smaller attack surface. Years later, that stage shape is still the skeleton: only the pins got stricter.
ENTRYPOINT ["ansible"] and CMD ["--version"] so that a CI job is one image: line and docker run without arguments tells you what it shipped.
The sins I shipped anyway
Reading that first Dockerfile today is archaeology with flinching:
- Collections installed unpinned, one
ansible-galaxy collection installper line. Every build pulledcommunity.generalat whatever HEAD was current that morning. Two builds of the same commit could produce different images. I knew it; I let it slide because “it worked”. - Base image pinned by tag,
alpine:3.15.0, not by digest. A tag is a promise someone can break. MAINTAINERinstruction (deprecated years before I wrote it), anorg.opencontainers.image.licenseslabel readingGPL3, which is not a license identifier.- The registry path in the labels says
6admin/docker/...while the real registry is6admin.io/.... That typo survived until somebody finally grepped for it in 2026. set xeinstead ofset -xe, silently: the RUN line still worked because nothing failed loudly.- Two separate images, one for ansible, one for ansible-lint, because I could not decide what belonged in “the image”. Every pipeline that needed both pulled both.
What CI actually needs
The interesting part of writing a CI-specific image is that it is not a desktop: runs are ephemeral, hosts change identity constantly and nobody is watching a progress bar. That drove the defaults that came with time: HOST_KEY_CHECKING=False for fresh known_hosts-less runners, SSH_PIPELINING=True because CI jobs are small and frequent, retry files off, smart gathering. A handful of ANSIBLE_* environment variables baked into the image so that no pipeline YAML ever repeats them.
The boring lesson, learned early for once
Pin the interpreter, pin the collections, pin the base. This image pins exactly one of the three and I already know it as I write this. Finishing the ladder, every layer at a digest or an exact version with a lock file to prove it, is the work I promise myself and will not deliver for a good while.
This image ships at a fraction of the official size, rebuilds in minutes and has run my pipelines in production since 2018 while I slowly notice it is lying to me about reproducibility. Which is, I suspect, how every infra project actually grows: it works first, it becomes honest later.
[edit: 2026] This image is now ten years old and has run in production for at least eight. The CVE that turned pinning into a discipline, the merge of the two images and the signed chain of trust, I told them in episode two, the Ansible CI Toolkit v1 .