1. Start from the official image
Suppose you have a Python application that you want to run as a Docker image. Rather than starting from a base operating system image and installing Python and any other tools your application needs, start from the official Python image. It's maintained, patched regularly, and already has the runtime set up correctly.
FROM python:3.12-slim
The same applies to Node, Java, Postgres, Redis and most other mainstream tools: check Docker Hub for the official image before building your own.
2. Pin the base image version
If the FROM line names an image with no tag, or with latest, every build pulls whatever is newest at that moment. Two builds a week apart can produce different images with no change to your code, and you have no record of what changed.
Pin the version instead, and be as specific as you can. Each level adds reproducibility:
# Avoid: whatever is latest today
FROM python
# Better: a minor version
FROM python:3.12
# Better still: version plus OS release
FROM python:3.12-slim-trixie
# Best: exact and immutable
FROM python:3.12.13-slim-trixie@sha256:<digest>
Dockerfile comments must start at the beginning of a line. A # after an instruction is not a comment: FROM python # avoid fails as an invalid image reference.
Pinning the OS release matters as much as the language version. A bare python:3.12-slim tag moved from Debian 12 to Debian 13 when Debian 13 was released, so the operating system under your application can change without a code change. Name the release (bookworm, trixie) explicitly, and check that it's still in regular security support.
A pinned digest also gives the team transparency: anyone reading the Dockerfile can see exactly which base image is in use. Let a tool such as Renovate or Dependabot open pull requests to bump it, so pinning doesn't mean falling behind.
3. Choose a lean base image
Full operating system images ship with many tools you don't need. Every extra package is more storage in the registry, slower pulls and pushes, and a larger attack surface. Choose the smallest image that covers what your application needs; anything missing can be installed explicitly.
For Python the common choices, from largest to smallest, are python:3.12 (full Debian), python:3.12-slim (minimal Debian) and python:3.12-alpine (Alpine Linux). Smaller is generally better, but the smallest option isn't always the right one, because it changes more than just the size.
When Alpine costs more than it saves
Alpine uses musl instead of glibc. Pre-built wheels now exist for most popular packages with native code (PyMuPDF, lxml, Pillow, pydantic-core and cryptography all publish musllinux wheels), so installation is no longer the main concern. The remaining issues are subtler:
- Code that depends on glibc breaks silently. For example, a call to
ctypes.CDLL("libc.so.6")to tune the memory allocator finds no such library on Alpine. If the call is wrapped inexcept OSError: pass, there's no error, just an application that quietly uses several times more memory than expected and sometimes gets killed for running out of it. - Python is generally slower on musl. musl's allocator tends to be slower and to fragment more under threaded workloads. This is widely reported; measure it if it matters to you.
- Rendered output moves further from your current environment. Different font packages and fontconfig defaults mean anything rendered on the server (PDFs, images, charts) is more likely to change. If you're migrating from an Ubuntu VM, Debian is one step away and Alpine is two.
- The size saving narrows quickly. Once you add the native libraries your application needs (pango, cairo, fonts and so on), the gap between Alpine and slim shrinks. Most hosting platforms cache the image on each node and bill for CPU and memory, not image size.
- The real benefit is available another way. The genuine gain from Alpine is fewer known vulnerabilities. You can get most of that without changing the C library, through hardened Debian-based images or by rebuilding slim weekly and scanning it.
For a Python application with native dependencies, Debian slim is the safer default. Alpine is a good choice for small, pure-Python services, Go binaries, and anything where you've confirmed no glibc assumptions.
Other base image options
| Option | Verdict |
|---|---|
python:*-alpine | Use with care. Wheels now exist for most packages, but glibc-dependent code fails silently, Python is generally slower on musl, and rendered output drifts further from a glibc host. Fine for simple services; weigh it carefully for anything with native libraries. |
| Distroless | Usually avoid for Python. It's tied to Debian's own Python version, and native libraries (such as WeasyPrint's) are awkward to add because there's no package manager. |
| Docker Hardened Images (free since Dec 2025, Apache 2.0) | Worth evaluating. Far fewer known vulnerabilities, with an SBOM and build provenance included. But you have to log in to dhi.io, the free tier only patches on a best-effort basis, and the runtime image has no package manager, so native libraries must be copied across from a build stage. Revisit if scanning shows the base image's vulnerabilities are a real burden. |
| Chainguard | Skip unless paying. Pinned versions need a paid subscription. |
4. Order the Dockerfile for layer caching
Every Docker image is made of layers. The base image is itself a stack of layers built from its own Dockerfile (you can see them on the image's Docker Hub page), and each RUN, COPY and ADD instruction in your Dockerfile adds another. Run docker history myapp:1.0 to list the layers and the commands that created them.
Docker caches layers. During a build, any layer whose inputs haven't changed is reused rather than rebuilt, and when pulling or pushing, only layers the other side doesn't already have are transferred. This only helps if you order the instructions so the layers that change least come first.
The common mistake is to copy the whole project and then install dependencies:
COPY . /app
RUN pip install -r /app/requirements.txt # reruns on every source change
Any change to any source file invalidates the COPY layer, so the install runs again on every build. Instead, copy the dependency manifest first, install, then copy the rest:
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt # cached until requirements.txt changes
COPY . /app
Now the install layer is reused until the dependencies actually change, and most builds only rebuild the final COPY. The same pattern applies to package.json with npm install, pom.xml with Maven, and apt-get install for system packages.
5. Exclude files with .dockerignore
COPY . /app copies everything in the build context: the .git directory, local virtual environments, test output, the README, editor settings, and anything else lying around. None of it belongs in the image, and all of it adds size and invalidates the cache.
Add a .dockerignore file at the root of the build context. Docker reads it before sending the context and skips anything that matches:
.git
venv/
.venv/
__pycache__/
*.pyc
.pytest_cache/
.env
.env.*
!.env.example
*.db
README.md
docs/
Secrets files and local databases matter most: anything in the build context can end up in a layer, and a layer is readable by anyone who can pull the image.
6. Use multi-stage builds
Many things are needed to build an image but not to run it: compilers, build tools, dependency manifests, test fixtures and intermediate artifacts. Keeping them in the final image makes it larger and increases the attack surface. A pyproject.toml or package.json is useful during installation and pointless afterwards; Maven or Gradle are needed to compile a Java application and not to run it.
Multi-stage builds separate the two. Each FROM starts a new stage; earlier stages are temporary images that exist only during the build, and only the last stage becomes the final image. Copy the finished artifacts across and leave the tooling behind:
# Build stage: has compilers, headers and build tools
FROM python:3.12.13-slim-trixie AS build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential
COPY requirements.txt .
RUN python -m venv /opt/venv && /opt/venv/bin/pip install -r requirements.txt
# Runtime stage: only what the application needs to run
FROM python:3.12.13-slim-trixie AS runtime
COPY --from=build /opt/venv /opt/venv
COPY . /app
ENV PATH="/opt/venv/bin:$PATH"
CMD ["python", "/app/main.py"]
Use the same base image in both stages. A virtual environment records the path of the Python interpreter that created it, so a venv built on one base and copied into a different one can fail to start, or load native libraries that don't match.
7. Don't run as root
If the Dockerfile doesn't say otherwise, the container's process runs as root. That's rarely necessary, and if the application has a vulnerability it gives an attacker the best possible starting point for escaping to the host.
Create a dedicated, least-privileged user in the image and switch to it before the CMD. Least privilege means the user can read the application code but not change it: keep the code owned by root, and give the app user ownership only of the directories it actually writes to.
RUN groupadd -r app && useradd -r -g app app \
&& mkdir -p /var/lib/app && chown app:app /var/lib/app
# Code stays owned by root: the app can read it but not rewrite it
COPY . /app
USER app
CMD ["python", "/app/main.py"]
If the app user does own the code, a compromised process can modify the application itself. If some copied files must be writable, use COPY --chown=app:app for just those files rather than RUN chown -R afterwards: chown in a separate RUN writes a second copy of every file into a new layer, roughly doubling that part of the image.
Some official images already bundle a non-root user (node in the Node image, for example). Use it if it exists.
8. Scan the image, and keep scanning it
Once the image is built, check it for known vulnerabilities before shipping it. Docker Scout (docker scout cves myapp:1.0) and Trivy (trivy image myapp:1.0) both work on a local image and both are free for this use. The older docker scan command, which used Snyk, has been retired.
New vulnerabilities are published constantly, so scanning once isn't enough. The base image gets security patches, but an image you built in March stays exactly as it was in March. Add a scheduled weekly rebuild plus scan, and promote the result through the normal pipeline. Combined with pinned versions and automated bump PRs (section 2), this keeps the image current without surprises.
9. Pull base images through your own registry
Docker Hub rate-limits anonymous and free-tier pulls, which can break builds at the worst time. Most cloud registries can cache upstream images: Azure Container Registry has cache rules for Docker Hub, and AWS ECR and Google Artifact Registry have equivalents. Pulling through the cache avoids the limits and keeps a copy of every base image you depend on under your own control.
10. Verify environment-dependent output before cutover
Anything the application gets from the operating system (fonts, locales, time zones, SSL certificates) can change when the OS underneath it changes, usually with no error anywhere.
Time zones are the easiest to miss. Containers run in UTC unless told otherwise. Code written on a server set to a local zone such as Australia/Sydney often calls datetime.now() or date.today() without a time zone, and in a container every one of those shifts by 10 to 11 hours: "today's jobs", report dates and cut-offs land on the wrong day overnight. Set the zone explicitly, make sure the zone data is installed, and add a test that fails if it's missing:
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
ENV TZ=Australia/Sydney
The longer-term fix is time-zone-aware code (datetime.now(ZoneInfo("Australia/Sydney"))), but setting TZ keeps existing behaviour during a migration.
PDF generation is the other classic trap. A tool like WeasyPrint renders with whatever fonts are installed, and a slim image typically has only fonts-dejavu-core where a VM had a full font set. The result is different fonts, line breaks moving, or text overflowing.
Before switching, render a sample of each type of output in both the old and the new environment and compare them. Install the fonts, locale and time zone packages the image needs explicitly in the Dockerfile so the result is reproducible.
Summary
Start from the official image, pinned by version and digest. Prefer a slim Debian base unless you've confirmed Alpine suits your dependencies. Order the Dockerfile so dependencies install before source is copied, exclude junk with .dockerignore, and use a multi-stage build to keep tooling out of the final image. Run as a non-root user that can't modify its own code, scan on every build and rebuild weekly, pull through your own registry, set the time zone explicitly, and verify any environment-dependent output before cutover.
