Every container image you publish is a debt: CVEs accumulate, upstream releases pile up, base images drift, and eventually someone asks “why is this still on PHP 7.4?”. If you maintain more than 2 or 3 images, the debt compounds faster than you service it.
This post is about how we keep 13 Docker images fresh without anyone running docker build by hand — and why every artifact ships with an SBOM and a Sigstore attestation.
The fleet
Pulls on Docker Hub (last check, 2026-05-02 — numbers will drift):
| Container | Pulls | Role |
|---|---|---|
| sslh | 79 606 | SSH/HTTPS/OpenVPN port multiplexer (2 MB scratch image) |
| postgres | 29 429 | PostgreSQL 16/17/18 with pgvector, paradedb, timescale, postgis, citus |
| terraform | 20 398 | Terraform CLI + tflint + terragrunt + trivy + per-cloud flavors |
| github-runner | 15 509 | Self-hosted runners: Ubuntu, Debian, Windows Server 2022 |
| wordpress | 2 459 | Immutable WordPress with SQLite plugin |
| ansible | 2 452 | Ansible controller with pinned cryptography stack |
| php | 2 160 | PHP-FPM with Composer + APCu baked in |
| openresty | 1 758 | OpenResty built from source with 30 compile flags |
| openvpn | 1 709 | 15 MB OpenVPN server, PKCS11-capable |
| web-shell | 1 119 | ttyd-based browser terminal, multi-distro |
| debian | 973 | Debian base with a host-to-container migration tool |
| jekyll | 814 | Jekyll with pre-pinned gems for reproducible builds |
| vector | 597 | 52 MB vendor-free observability pipeline |
Built daily, delivered to GHCR and Docker Hub, multi-arch (amd64 + arm64 for Linux, ltsc2022 for Windows), each one signed.
The problem we set out to solve
Three years ago, our release process looked like this:
- See upstream release somewhere (GitHub, mailing list, RSS if lucky)
- Pull the repo, bump the version, commit,
docker build,docker push - Forget to rebuild the downstream images that depend on it
- Realize 3 months later that pg_cron is two versions behind pgvector
Multiply by 13 containers, 3 PostgreSQL major versions, 5 Terraform cloud flavors, 6 github-runner OS×flavor variants, and you have a full-time maintenance job.
The fix is automation. Not “manual with a reminder” — actual automation where humans only intervene on majors.
The pipeline
┌──────────────────────┐
│ upstream-monitor │ daily @ 06:00 UTC
│ GitHub releases API │
│ PyPI / RubyGems / DH │
└───────────┬──────────┘
│ bumps variants.yaml / config.yaml
▼
┌──────────────────────┐
│ peter-evans/create- │ opens PR per container
│ pull-request │
└───────────┬──────────┘
│ auto-merge if minor/patch
│ human review if major
▼
┌──────────────────────┐
│ auto-build.yaml │ triggered by push to master
│ detect-containers │
│ build matrix │
│ multi-arch buildx │
└───────────┬──────────┘
│
▼
┌──────────────────────┐
│ syft → SPDX SBOM │
│ cosign/Sigstore attestation
│ trivy scan (advisory)│
└───────────┬──────────┘
│
▼
┌──────────────────────┐
│ push to GHCR & DH │
│ multi-arch manifest │
│ update dashboard │
└──────────────────────┘
Each stage is a few dozen lines of YAML plus shell. The clever parts are in what counts as a version, when to auto-merge, and how to tell dashboard reality from what’s deployed.
What counts as a version
A helper function (helpers/latest-github-release) resolves the latest stable version for any GitHub repo. Three strategies in cascade:
/releases/latest— the repo-declared “latest.” Fast, but some projects (like Vector) publish their CLI sub-project here.- Releases list with
prerelease == falsefilter — catches per-commit prereleases. - Tags endpoint fallback — for projects like git-for-windows that tag but don’t release formally.
Each stage validates the tag against a whitelist regex:
^([a-zA-Z]+-|v)?[0-9]+(\.([0-9]+|windows|linux|darwin|macos|alpine))*$
Accepts 1.7.1, v7.5.1, jq-1.8.1, 2.49.0.windows.2. Rejects 1.6rc2, vdev-v0.3.1, 2.0-beta. (The vdev-v case shipped a release candidate to production once. Hence the whitelist.)
When to auto-merge
The bot classifies every bump as patch / minor / major:
- Patch (1.2.3 → 1.2.4): auto-merge, no questions.
- Minor (1.2 → 1.3): auto-merge after CI passes.
- Major (1.x → 2.x): PR opens with
major-updatelabel. Human reads the changelog, merges if boring.
The bot doesn’t merge anything until auto-build.yaml passes — no “green because there are no tests” shortcut. Build, scan, manifest, all green.
Supply-chain assurance
Every image push generates:
- SPDX 2.3 SBOM via syft — every package, every binary, every license.
- Sigstore attestation via
actions/attest-sbom— cryptographic binding of SBOM to image digest, verifiable without our keys. - Trivy scan — CVE scan runs in advisory mode (doesn’t block) with 15-minute timeout (the full Terraform flavor with 4 cloud SDKs takes a while).
Verify anything we publish:
gh attestation verify \
oci://ghcr.io/oorabona/postgres:18-alpine-vector \
--repo oorabona/docker-containers
The response tells you: who built it, when, with which source commit, and that the SBOM matches the image bytes byte-for-byte.
Why all these images exist
Not every image in the fleet is a “competing” Docker Hub image. Some are specialised, some are foundations.
- sslh, openvpn, vector, jekyll, ansible — we use them. They exist because the “official” Docker Hub versions weren’t minimal enough, multi-arch enough, or pinned enough.
- postgres — the official
postgresimage is great, but doesn’t bundle pgvector, paradedb, timescale, and pgcron. We rebuild Alpine PostgreSQL and ship a flavor for each common workload. - terraform, github-runner — the upstream images exist but are single-cloud / single-OS. We bundle.
- debian — a wrapper with an
export.shtool that migrates a host Linux system into a container image. Niche but we needed it. - wordpress — an immutable WordPress with SQLite pre-installed. Designed for deployments that don’t want the plugin-editor attack surface.
- web-shell — a ttyd-based browser terminal across four distros (Debian/Alpine/Ubuntu/Rocky) with SSH optional. For orchestration without CLI access.
- openresty — built from source with custom compile flags. 30 options you can’t change on the official image.
- php — a PHP-FPM base with Composer and APCu baked in, saving two Dockerfile layers in downstream images.
Each has a distinct reason. No image exists “because we could.”
The dashboard
All of this lives at oorabona.github.io/docker-containers. It’s a Jekyll site generated by the same pipeline that builds the images. Every container has a page with:
- Current version (from Docker Hub)
- Pull count and image sizes per arch
- Build lineage (last successful digest, base image used)
- Dependency health (which upstream tools are behind)
- Recent change log
- Direct links to SBOM and attestation
Built daily. If a container’s variants show warning, something broke and we know about it.
Lessons we learned the hard way
- Apt mirrors 5xx randomly. Build retries cost ~30 min per Windows run. Worth it; false negatives cost more.
- Chocolatey’s Community API returns 503 frequently. Direct downloads from vendor release pages are reliable. All Windows tools install from the vendor, not via choco.
- Jekyll’s
future: falseis how we stagger blog posts — write them all today, dated in the future, Jekyll ignores them until their date arrives, daily rebuild picks them up. - GitHub Pages caches aggressively. After a deploy, expect 2–5 minutes before the CDN updates.
- Matrix job concurrency cancellation cancels your own in-flight builds. We learned that the hard way. The fix is “no job-level concurrency, workflow-level removal is enough.”
- Multi-arch builds on GitHub’s ARM runners are natively fast. No QEMU emulation.
If you’re building your own fleet
Start with: actions/create-pull-request, syft, cosign, and one Dockerfile. Every other piece — the version helper, the whitelist regex, the dashboard — evolves from running the thing in production.
Our full .github/workflows/ is MIT-licensed. Read, copy, adapt.
TL;DR
13 containers, ~160 000 pulls/month, zero manual release step. Every image signed, every version tracked, every CVE scanned. Not because we’re disciplined — because we automated the discipline away.
⭐ Star on GitHub if this is the kind of pipeline you’d steal.