A Dockerfile that builds fast and runs safely
By the end: You can write a small, cached, non-root image for a Python AI service, and run it with its dependencies locally.
- Layers and the build cache
- Multi-stage builds
- Running as a non-root user
- What not to copy in
- Images for GPU work
- Compose for local development
New to containers? Software depends on the exact versions of the software around it, which is why code that runs on your laptop often fails on a server with slightly different versions. A container packages your code together with everything it needs, libraries and system tools and settings, into one image that behaves the same wherever it runs. Docker is the tool nearly everyone uses to build and run them. Why it matters
A Dockerfile is a recipe, and the order of its lines decides both how long every rebuild takes and how much damage a compromised container can do. This section writes one for a Python AI service around the two things that matter most: installing dependencies before copying the code, so a one-line change rebuilds in seconds rather than minutes, and creating a user so the process is not root. It also covers what must never go in, which is secrets, model weights and your .git directory.
A good starting Dockerfile#
FROM python:3.12-slim AS base
WORKDIR /app
# Dependencies first: this layer is reused until requirements change
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Code last: changes here do not reinstall dependencies
COPY src/ ./src/
RUN useradd --create-home app
USER app
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]Order is speed#
Each instruction makes a layer, and Docker reuses a layer if nothing it depends on changed. Copy the dependency list and install first, then copy the code. Editing a line of code then rebuilds in seconds instead of reinstalling everything. Measured on a service with fastapi, numpy, pandas and scikit-learn, the same one-character edit costs 18.4 seconds with the code copied first and 0.2 seconds with it copied last.
There are a few other ordering rules worth knowing, all of them the same idea applied in different places.
# Combine apt lines: two RUNs make two layers, and the second cannot
# remove files the first put in the layer below it.
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
# Pin versions. An unpinned install makes the cached layer a lie: it was
# built in March and you think it holds what pip would give you today.
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
# Put the line most likely to change as far down the file as it will go.
COPY src/ ./src/Build in one stage, run in another#
A multi-stage build compiles or installs in a full image with build tools, then copies only the results into a slim final image. Compilers, headers and package caches are needed to install and useless afterwards, so they are dead weight in every pull and an extra toolkit for anyone who gets in.
FROM python:3.12 AS build
RUN apt-get update && apt-get install -y build-essential
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
COPY --from=build /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
WORKDIR /app
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app
USER app
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]What the second stage removes is the compiler toolchain and the full Python base image underneath it. The numbers below are the same application built both ways on the same machine.
$ docker images demo --format '{{.Tag}}\t{{.Size}}'
single 1.52GB # python:3.12 plus build-essential, kept
multi 532MB # python:3.12-slim plus the venvDo not spend the afternoon chasing the last fifty megabytes. The distance from 1.5 gigabytes to 500 megabytes is worth an hour; the distance from 500 to 450 is not. Alpine is the common wrong turn at this point. It is a genuinely tiny base image, but it uses the musl C library rather than glibc, and a project only ships a wheel for musl if it chose to. numpy and pandas now do. scikit-learn does not, so pip falls back to the source tarball and tries to compile it, in an image with no compiler.
$ docker build -f Dockerfile.alpine .
Downloading numpy-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl # fine
Downloading pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl # fine
Downloading scikit_learn-1.6.0.tar.gz (7.1 MB) # not fine
Preparing metadata (pyproject.toml): finished with status 'error'
../meson.build:1:0: ERROR: Unknown compiler(s): [['cc'], ['gcc'], ...]
ERROR: failed to solve: pip install ... did not complete successfully: exit code: 1Keep things out#
# .dockerignore
.git
.env
.venv
__pycache__
data/
*.ipynbThe build context is everything in the directory, and it is sent to the daemon before a single instruction runs. Without a .dockerignore, a repository with a checkout history and a local dataset in it sends all of that over, every build, and a COPY . . then bakes it into the image.
# no .dockerignore, with a 60 MB parquet file and a .git directory present
#6 transferring context: 80.02MB 1.4s done
$ docker images demo --format '{{.Size}}'
609MB
# same repository, .dockerignore added
#6 transferring context: 123B 0.0s done
$ docker images demo --format '{{.Size}}'
529MBA secret in a layer stays in the layer
Copying a key in and deleting it in a later RUN does not remove it: the layer that added it is still in the image, and docker history plus a tar extraction gets it back. The same is true of a key passed as an ARG, which is recorded in the image metadata. If a secret has been in a build, rotate it. For a build that genuinely needs a private token, use a build secret mount, which is never written to a layer.
Do not run as root#
By default a container runs as root. It is easy to prove and easy to forget.
$ docker run --rm demo id
uid=0(root) gid=0(root) groups=0(root)
# after adding: RUN useradd --create-home --uid 10001 app / USER app
$ docker run --rm demo id
uid=10001(app) gid=10001(app) groups=10001(app)Root inside a container is not root on the host, so this is not a catastrophe on its own. What it costs is every layer of defence after the first. A remote code execution in your service becomes root in the container, which can install tooling, read every file in the image including any configuration you thought was internal, and write as root into any mounted volume. That last one bites teams in normal operation, not just under attack: a container that writes its output into a mounted directory leaves files owned by uid 0, and the next process, or the developer on the host, cannot delete them.
# as root
$ docker run --rm -v "$PWD/out:/data" demo sh -c 'touch /data/x; ls -ln /data'
-rw-r--r-- 1 0 0 0 Sep 13 13:28 x # owned by uid 0
# as the app user, the obvious damage simply fails
$ docker run --rm demo sh -c 'touch /etc/evil'
touch: cannot touch '/etc/evil': Permission denied
$ docker run --rm demo sh -c 'apt-get install -y curl'
E: Could not open lock file /var/lib/dpkg/lock-frontend (13: Permission denied)Two things break when you add USER and catch people out. Anything the process writes at runtime must be in a directory the user owns, so chown it in the Dockerfile. And a port below 1024 needs root to bind, which is why containers listen on 8000 or 8080 and the platform maps 443 to it from outside.
GPU images are big#
Images with CUDA and PyTorch are often several gigabytes. Start from an official base image that matches your CUDA version, keep model weights out of the image and download or mount them at startup, and expect slow first pulls on new machines.
Two specifics save real time here. Install the wheel that matches your hardware rather than the default: the plain pip install of torch pulls the full CUDA runtime, and on a CPU-only serving box the CPU wheel is a fraction of the size. And treat weights as data, not code. A ten gigabyte checkpoint in the image means every deploy transfers ten gigabytes and changing the model needs a rebuild, where fetching it at start-up from object storage means changing an environment variable.
# Serving on CPU: the index URL is the whole optimisation.
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
# Weights come from outside, chosen at run time.
ENV MODEL_URI=s3://models/classifier/v7
CMD ["python", "-m", "src.serve"]Compose for local development#
services:
api:
build: .
ports: ["8000:8000"]
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_PASSWORD: local-only
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
retries: 15Two things in that file are the whole reason to use compose rather than two terminal windows. The first is that services reach each other by service name on a private network: the api container connects to db on port 5432, not to localhost, because localhost inside a container is that container. Getting this wrong produces a connection refused that looks like the database is down when it is running perfectly.
$ docker compose exec api python -c "import socket; print(socket.gethostbyname('db'))"
172.19.0.2
$ docker compose exec api python -c "import socket; socket.create_connection(('localhost',5432),2)"
ConnectionRefusedError: [Errno 111] Connection refusedThe second is the healthcheck condition. A plain depends_on waits for the container to have started, which for Postgres is several seconds before it accepts connections, so the api comes up, fails to connect and exits. Adding condition service_healthy makes compose wait for the check to pass, and the difference shows up in the start-up log.
# depends_on: [db]
Container db-1 Started
Container api-1 Starting <- immediately, db not accepting yet
# depends_on: db: condition: service_healthy
Container db-1 Started
Container db-1 Waiting
Container db-1 Healthy
Container api-1 Starting <- only nowdocker compose up -d --build rebuild changed images and start
docker compose logs -f api follow one service
docker compose exec api sh shell into a running service
docker compose down -v stop and delete the volumes tooWhat to skip#
- Alpine for Python. It makes builds slower, not images smaller, once wheels have to be compiled.
- Distroless and scratch images, until something is actually scanning you. They complicate debugging because there is no shell to exec into.
- ONBUILD, and clever base images your team maintains. Repeat the six lines.
- Hand-tuning below about 500 megabytes for a Python service. The wins are gone.
Where people go wrong
Baking model weights and secrets into the image. The first makes every build slow and every image huge; the second leaks the secret to everyone with registry access.
If this comes up in an interview
Your Docker build takes eight minutes on every code change. Why, and how do you fix it?
The code is probably copied before dependencies are installed, so every change invalidates the install layer. Copy the requirements file and install first, then copy the code. Add a .dockerignore so unrelated files do not bust the cache.
Then they push further
It is still slow in CI. Why?
CI machines often start with an empty cache. Use your CI's Docker layer caching, or push and pull a cache image from the registry.
What gets marked up. Marked up for explaining layer caching and ordering, not just 'use a smaller image'.
Why should a container not run as root?
If the application is compromised, root inside the container makes it easier to damage mounted data or exploit a container escape. A normal user limits what an attacker gets.
Build this: Containerise an AI service with a slim base, dependency caching, a non-root user and a .dockerignore. Measure rebuild time after a one-line code change before and after reordering. Add a compose file with Postgres and pgvector.
Watch it explained
This is one section of Containers, 9 hours across 2 sections. Read it in full to get the whole topic in order.
Stop 41 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.