Project Setup

Publishing a Python CLI as a Docker Image from CI

Ship a Python CLI as a container image: a small, non-root Dockerfile, multi-arch builds and tags from git in GitHub Actions, provenance and SBOMs, and run-time ergonomics.

Updated

Some users never want to install Python at all. A CI pipeline that runs your linter, a platform team standardising tools across languages, a Kubernetes job that runs a data export every night — for all of them, docker run ghcr.io/acme/mytool:1.6 … is the natural way to consume a CLI. A container image also gives you something no wheel can: a fully pinned, reproducible environment including the interpreter, built once and run identically everywhere. Publishing one well takes a little care — a small image, a non-root user, sensible tags, multi-architecture builds for Apple silicon and ARM servers, provenance attached — and some thought about how a CLI behaves inside a container, where the user's files, terminal and identity are all on the other side of a boundary. This guide covers both. It belongs to the CI/CD topic.

Prerequisites

  • A CLI that builds as a wheel, with a uv.lock; see building and publishing a CLI with uv.
  • A GitHub repository (the examples publish to GitHub Container Registry, ghcr.io).
  • Docker with Buildx locally, for testing the image before CI does.

The image

# Dockerfile
FROM python:3.13-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/uv
WORKDIR /src
COPY pyproject.toml uv.lock ./
RUN uv export --frozen --no-dev --no-emit-project --format requirements.txt -o /requirements.txt
COPY . .
RUN uv build --wheel --out-dir /dist

FROM python:3.13-slim
ARG VERSION=dev
LABEL org.opencontainers.image.title="mytool" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.source="https://github.com/acme/mytool"
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONUTF8=1
COPY --from=build /requirements.txt /dist/ /tmp/install/
RUN python -m pip install --no-cache-dir --require-hashes --no-deps -r /tmp/install/requirements.txt \
 && python -m pip install --no-cache-dir --no-deps /tmp/install/*.whl \
 && rm -rf /tmp/install
USER 65532:65532
WORKDIR /work
ENTRYPOINT ["mytool"]
CMD ["--help"]

The build stage exports a hashed requirements file from the lock and builds the wheel; the final stage contains only Python, your dependencies — installed with their hashes enforced, as described in pinning dependencies with hashes — and your package. uv and the source tree stay behind in the build stage.

What ends up in the final image A two-stage Docker build where uv and the source tree stay in the build stage and the final image holds only Python, locked dependencies and the wheel. What ends up in the final image final image (python:3.13-slim) USER 65532 Python runtime from the base image Dependencies --require-hashes from uv.lock mytool wheel ENTRYPOINT ["mytool"] uv stays in the build stage No source tree, no tests No secrets baked in CMD ["--help"] means a bare docker run prints help instead of nothing.

A few lines deserve explanation. ENTRYPOINT ["mytool"] makes the image behave like the command: docker run image status runs mytool status. CMD ["--help"] is the default argument list, so running the image with no arguments prints help instead of doing nothing. USER 65532:65532 runs as an unprivileged user — a compromised dependency inside the container then cannot modify the image's files. WORKDIR /work gives users a conventional mount point. PYTHONUNBUFFERED=1 makes output appear immediately in CI logs instead of in bursts.

Publishing from CI

# .github/workflows/image.yml
name: image
on:
  push:
    tags: ["v*"]
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write
  attestations: write

jobs:
  image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=edge,branch=main
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: VERSION=${{ steps.meta.outputs.version }}
          provenance: mode=max
          sbom: true
          cache-from: type=gha
          cache-to: type=gha,mode=max

The metadata action turns a v1.6.2 tag into the image tags 1.6.2, 1.6 and 1, and pushes to main into an edge tag. Users choose their stability: pin 1.6.2 for reproducibility, follow 1.6 for patch releases, or track 1. QEMU plus Buildx produce linux/amd64 and linux/arm64 variants under one tag, so the image runs natively on Apple silicon laptops and ARM servers. provenance: mode=max and sbom: true attach a build provenance attestation and a software bill of materials to the image — the container counterpart of generating an SBOM for a Python CLI. The GitHub Actions cache keeps rebuilds fast. Pin each action to a commit SHA in a real workflow.

Tags from one git tag Image tags produced by the metadata action from a release tag and from pushes to the main branch, and who should use each. Tags from one git tag Image tag Moves when For 1.6.2 never reproducible pipelines 1.6 patch releases safe fixes 1 minor releases following a major edge every push to main testing unreleased work No "latest": its meaning is ambiguous and it changes under scripts silently.

A plain latest tag is deliberately absent. "Latest" is ambiguous — latest release, or latest build of main? — and it silently changes under scripts. The semver tags say what they mean.

Running a CLI in a container

A CLI in a container sees its own filesystem, its own user and, by default, no terminal. Document the invocation that makes it feel native:

docker run --rm -it \
  --user "$(id -u):$(id -g)" \
  -v "$PWD:/work" -w /work \
  -e MYTOOL_TOKEN \
  ghcr.io/acme/mytool:1.6 export --to report.csv
  • --rm removes the container afterwards; nobody wants hundreds of stopped containers.
  • -it attaches a terminal, so colours, prompts and progress bars work. Drop it in CI, where there is no TTY — the CLI's own TTY detection then switches to plain output.
  • --user "$(id -u):$(id -g)" makes files written to the mounted directory belong to the user, not to UID 65532.
  • -v "$PWD:/work" -w /work makes relative paths behave as on the host.
  • -e MYTOOL_TOKEN passes a secret from the host environment by name, without writing it on the command line.

Many users wrap that in a shell alias or function — publish one in the README.

Running the CLI in a container Terminal session running a containerised command line tool with the working directory mounted, the caller’s user ID and a secret passed by name. Running the CLI in a container bash $ docker run --rm -it --user "$(id -u):$(id -g)" \ > -v "$PWD:/work" -w /work -e MYTOOL_TOKEN \ > ghcr.io/acme/mytool:1.6 export --to report.csv wrote 1,204 rows to report.csv (csv) $ ls -l report.csv -rw-r--r-- 1 ann ann 48211 Oct 2 12:00 report.csv The file belongs to the caller, not to the container’s user.

UX considerations

  • Keep stdout clean. In containers, logs and data share the same streams as on the host. Diagnostics on stderr, data on stdout — as everywhere — so docker run … > out.json works.
  • Never bake secrets into the image. Configuration and tokens come from environment variables or mounted files at run time; see reading secrets from env and files.
  • Handle signals. docker stop sends SIGTERM to the entrypoint process. Because mytool is PID 1 in the container, default signal handling differs; add --init to docker run, or handle SIGTERM explicitly as in handling SIGTERM and graceful shutdown.
  • Disable update notices. A container is immutable; telling its user to pipx upgrade is wrong. Set ENV MYTOOL_NO_UPDATE_CHECK=1 in the image, as covered in showing non-blocking update notices.
  • Pin the base image by digest for releases, and rebuild regularly so OS security fixes reach users.

Testing the behaviour

Test the image itself before pushing, in the same workflow: build for the runner's architecture, load it, and run a few commands that exercise the entrypoint, the non-root user and mounted files:

#!/usr/bin/env bash
# scripts/test-image.sh IMAGE
set -euo pipefail
image=${1:?usage: test-image.sh IMAGE}

docker run --rm "$image" --version
docker run --rm "$image" | grep -q "Usage"                       # CMD defaults to --help
test "$(docker run --rm --entrypoint id "$image" -u)" != "0"     # not running as root

workdir=$(mktemp -d)
echo '{"name": "web-1"}' > "$workdir/in.json"
docker run --rm --user "$(id -u):$(id -g)" -v "$workdir:/work" "$image" \
    convert in.json --to out.csv
test -s "$workdir/out.csv"
test "$(stat -c %u "$workdir/out.csv")" = "$(id -u)"              # files owned by the caller
echo "image OK"

Run it with docker buildx build --load -t mytool:test . followed by scripts/test-image.sh mytool:test before the multi-arch push step. Container scanners (docker scout, Trivy, Grype) can also run here to flag vulnerable OS packages in the base image.

Conclusion

A container image is the most reproducible way to ship a Python CLI and the most convenient for CI-heavy users. Build it in two stages with hash-locked dependencies, run as a non-root user with ENTRYPOINT set to the command, publish multi-arch images with semver tags, provenance and an SBOM from a tag-triggered workflow, and document the docker run flags that make files, terminals and secrets behave. Test the image like any other artefact before it is pushed.

Frequently asked questions

Should I use Alpine for a smaller image?

Usually not for Python. Alpine uses musl instead of glibc, so many binary wheels do not apply and packages build from source, making builds slower and sometimes failing. python:3.x-slim or a distroless Python image is a better size–compatibility trade-off.

Can the image be built without Docker in CI?

Yes — Buildah, Kaniko and similar tools build OCI images without a Docker daemon, and registries accept their output. The Dockerfile above works unchanged.

How do users get shell completion for a containerised CLI?

They do not, in any convenient way: completion runs on the host shell, which cannot see inside the image. Container users are usually scripts and CI, where completion does not matter. Point interactive users at the pipx or uv install instead.

Is a PyInstaller binary or a container image better?

They serve different people. Binaries suit developers on laptops who want a single file; images suit CI systems, servers and organisations that standardise on containers. Many projects publish both from the same release workflow, alongside the PyPI package.

How big will the image be?

A python:3.x-slim base is a little over 100 MB uncompressed; a typical CLI and its dependencies add 10–50 MB on top. Registries and Docker transfer compressed layers, and the base layers are shared with every other image built on the same base, so the download for users who already have it is mostly your own layer. Keep that layer small by installing with --no-cache-dir, leaving build tools in the build stage and not copying the source tree into the final image.