Customising the images¶
Add your own tools, scripts and defaults on top of the published images, so your team gets one image with everything it needs.
Pick an approach¶
flowchart TD
Q{"What do you need?"} -->|"Add tools, scripts or config"| E["Extend the published image<br/>FROM all-devops:latest"]
Q -->|"Different versions of built-in tools,<br/>or remove tools"| F["Fork the repo and<br/>edit the Dockerfile"]
Q -->|"A few binaries in a small image"| M["Multi-stage copy<br/>into a fresh base"]
classDef neutral fill:#334155,stroke:#1e293b,color:#fff
classDef all fill:#059669,stroke:#047857,color:#fff
classDef base fill:#0891b2,stroke:#0e7490,color:#fff
classDef ai fill:#d97706,stroke:#b45309,color:#fff
class Q neutral
class E all
class F base
class M ai
-
Extend (recommended)
A few lines of Dockerfile, a build that takes minutes, and you pick up upstream updates on each rebuild. You can't make the image smaller this way.
-
Fork
Full control over every step and build argument, at the cost of a long cold build and merging upstream changes yourself.
-
Multi-stage copy
The smallest result, but only for self-contained binaries. See Optimisation.
Things to know about the base¶
- The images run as
rootwithHOME=/root, and the default command iszsh. The final stages setSHELL ["/bin/bash", "-c"], which child images inherit. python3is Python 3.14, built from source in/usr/local. Usepython3 -m pip install …so packages land in that interpreter.dnfworks, and EPEL is already enabled./usr/local/lib/detect-arch.shexportsARCH_VALUE(amd64orarm64) and aget_arch_value <arm-name> <x86-name>helper. Use it so your downloads work on both architectures.
Don't hard-code amd64
The published images are multi-arch. A download URL with linux_amd64 in it gives Apple Silicon and ARM runners a binary they can't run. Every example below picks the right architecture automatically.
Quick recipes¶
Complete examples¶
FROM ghcr.io/jinalshah/devops/images/all-devops:latest
# Python and Node.js linters
RUN python3 -m pip install --no-cache-dir black pylint mypy checkov && \
npm install -g prettier markdownlint-cli && rm -rf /root/.npm
# hadolint names its assets x86_64 / arm64
ARG HADOLINT_VERSION=2.12.0
RUN . /usr/local/lib/detect-arch.sh && \
HADOLINT_ARCH="$(get_arch_value arm64 x86_64)" && \
curl -fsSL -o /usr/local/bin/hadolint \
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-${HADOLINT_ARCH}" && \
chmod +x /usr/local/bin/hadolint
LABEL org.opencontainers.image.title="acme-devops-lint"
Trivy is already included. This adds SBOM and policy tools:
FROM ghcr.io/jinalshah/devops/images/all-devops:latest
RUN python3 -m pip install --no-cache-dir checkov bandit
# The Anchore install scripts detect the architecture themselves
RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin && \
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
An ARG declared before FROM is only visible in FROM lines. Declare it again after FROM to use it in RUN and LABEL.
ARG BASE_IMAGE=ghcr.io/jinalshah/devops/images/all-devops:latest
FROM ${BASE_IMAGE}
ARG TERRAFORM_VERSION=1.9.8
ARG PACKER_VERSION=1.11.2
# tfswitch is in the image; this replaces the latest Terraform with a pinned one
RUN tfswitch "${TERRAFORM_VERSION}" && terraform version
RUN . /usr/local/lib/detect-arch.sh && \
curl -fsSL -o /tmp/packer.zip \
"https://releases.hashicorp.com/packer/${PACKER_VERSION}/packer_${PACKER_VERSION}_linux_${ARCH_VALUE}.zip" && \
unzip -o -q /tmp/packer.zip -d /usr/local/bin && rm /tmp/packer.zip
COPY requirements.txt /tmp/requirements.txt
RUN python3 -m pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
LABEL audit.terraform="${TERRAFORM_VERSION}" \
audit.packer="${PACKER_VERSION}"
FROM ghcr.io/jinalshah/devops/images/all-devops:latest
# Go (arch-aware)
ARG GO_VERSION=1.23.4
RUN . /usr/local/lib/detect-arch.sh && \
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH_VALUE}.tar.gz" | tar -C /usr/local -xz
ENV PATH="/usr/local/go/bin:/root/.cargo/bin:${PATH}"
# Rust (rustup detects the architecture)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
# Ruby and Java 21 from the Rocky Linux 10 repositories
RUN dnf install -y ruby java-21-openjdk-devel && dnf clean all && rm -rf /var/cache/dnf
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk
RUN go version && rustc --version && ruby --version && java -version
Pin every version you add (package==1.2.3, tool@1.2.3, ARG X_VERSION) so rebuilds only change what you meant to change.
Scripts, config and defaults¶
Never bake in secrets
Anything in ENV, ARG or a copied file can be read by anyone who can pull the image. Pass credentials at runtime with -e or mounts, or use RUN --mount=type=secret for build-time secrets.
Test your image¶
#!/usr/bin/env bash
set -euo pipefail
IMAGE="$1"
docker run --rm "$IMAGE" bash -c '
set -e
terraform version
kubectl version --client
hadolint --version
python3 -c "import checkov"
command -v acme-deploy
'
echo "All checks passed for $IMAGE"
Scan it with the Trivy that's already in the image, talking to your host's Docker daemon:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/jinalshah/devops/images/all-devops:latest \
trivy image --severity HIGH,CRITICAL acme/devops:latest
Build and publish in CI¶
A weekly rebuild picks up the upstream image's refreshed tools. Recording the base in a label makes it easy to see what you built on.
name: Custom DevOps image
on:
push:
branches: [main]
schedule:
- cron: "0 4 * * 1" # Mondays 04:00 UTC, after the upstream Sunday rebuild
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
pull: true
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/devops-custom:latest
ghcr.io/${{ github.repository_owner }}/devops-custom:${{ github.sha }}
labels: |
org.opencontainers.image.base.name=ghcr.io/jinalshah/devops/images/all-devops:latest
This uses QEMU to build arm64 on an amd64 runner, which is simple but slow. The upstream project builds each architecture natively instead; see Multi-platform images.
Troubleshooting¶
A pip package won't install
pip search no longer works (PyPI disabled the API). List the published versions instead:
docker run --rm ghcr.io/jinalshah/devops/images/all-devops:latest \
python3 -m pip index versions checkov
Then pin one that exists, or install from Git: python3 -m pip install "git+https://github.com/org/pkg.git@v1.2.3".
exec format error when running a tool I added
You downloaded a binary for the wrong architecture. Use ARCH_VALUE or get_arch_value from /usr/local/lib/detect-arch.sh, as in the examples above.
My ARG is empty inside RUN
It was declared before FROM. Add ARG NAME (no value needed) again after the FROM line.
The custom image is bigger than expected
Clean caches in the same RUN (dnf clean all, --no-cache-dir, rm -rf /root/.npm) and check docker history <image>. See Optimisation.