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.
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.
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
--rmremoves the container afterwards; nobody wants hundreds of stopped containers.-itattaches 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 /workmakes relative paths behave as on the host.-e MYTOOL_TOKENpasses 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.
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.jsonworks. - 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 stopsends SIGTERM to the entrypoint process. Becausemytoolis PID 1 in the container, default signal handling differs; add--inittodocker run, or handle SIGTERM explicitly as in handling SIGTERM and graceful shutdown. - Disable update notices. A container is immutable; telling its user to
pipx upgradeis wrong. SetENV MYTOOL_NO_UPDATE_CHECK=1in 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.