Docker basics · Lesson 7 of 8 · about 30 minutes

Smaller, safer images

Pick a smaller base, keep secrets and junk out with .dockerignore, leave build tools behind with a multi-stage build, stop running as root, and tell Docker how to check your app's health.

By the end you'll know

  • Why image size and contents matter for speed and for security
  • How to choose a base image, and what slim and alpine mean
  • How a .dockerignore file keeps secrets out of your image
  • What a multi-stage build is, and why it leaves build tools behind
  • How to run your app as a normal user instead of root
  • How to tell Docker how to check whether your app is healthy

Why it matters

Your image from Lesson 4 works. But "works" isn't the only thing that matters once an image goes anywhere real:

  • Size. Every server that runs your image downloads it first. A 1 GB image takes much longer to pull than a 150 MB one, every time a new server starts it.
  • What's inside. Every program in an image is something that can have a security flaw. A full Linux system with compilers, editors and network tools has far more of them than one that only has what your app needs. Anything you don't ship can't be attacked.
  • Who it runs as. By default, the program in a container runs as root, the all-powerful admin user. If someone breaks into your app, they get whatever power the app has.
Think of packing for a flight. You could bring your whole closet, just in case. But it costs more, takes longer at every stop, and gives a thief more to take. Packing light means bringing only what the trip needs, leaving the tools you used to pack at home, and not handing the bag to someone with keys to your house.

This lesson works through each of these with your hello-app from Lesson 6.

Choose a smaller base

The FROM line decides most of your image's size. Official images usually come in several variants:

BaseWhat's in itUse it when
python:3.13A full Linux system, with compilers and lots of toolsYou need to compile things and don't mind the size
python:3.13-slimThe same Linux, with only the basicsAlmost always: a safe default
python:3.13-alpineA different, tiny Linux called AlpineYou want the smallest image and your libraries work on Alpine

See the difference for yourself. In your hello-app folder, change the first line of your Dockerfile to FROM python:3.13, build, and change it back:

BASH
docker build -t hello-app:full .
docker image ls hello-app

Your hello-app:full image is roughly 1 GB. The same app on slim is a fraction of that. Same code, same libraries: the difference is all tools your app never uses.

A word on Alpine: it's tiny, but it's built differently from most Linux systems, and some Python libraries have to be compiled from scratch to run on it, which can make builds slow and images bigger than expected. That's why slim is the usual default for Python. Change your FROM line back to python:3.13-slim before you go on.

Keep secrets out with .dockerignore

When you run docker build ., Docker sends the whole folder to the build. Anything in it can end up in the image if a COPY picks it up. Your folder already has something that doesn't belong in an image: settings.env from Lesson 5. In a real project there'd also be your Git history, local virtual environments, and maybe a file with real passwords.

A .dockerignore file lists what Docker should leave out of the build, the same way .gitignore works for Git. Save this as .dockerignore in your hello-app folder:

TEXT
# Never send these to the build
.git
*.env
.env
__pycache__
.venv
compose.yaml

Now nothing in that list can be copied into an image, even by accident. You'll see why that matters in the break-it section.

Multi-stage builds: leave the tools behind

Some apps need tools to build that they don't need to run: a compiler to build a library, for example. If you install those in your image, they ship to production, adding size and things to attack.

A multi-stage build fixes that. The Dockerfile has two (or more) FROM lines. The first stage, the builder, has all the tools and builds what's needed. The final stage starts fresh from a small base and copies in only the results from the builder. Everything else in the builder is thrown away.

How a multi-stage build worksStage 1, the builder, starts from python:3.13-slim and installs the app's libraries into a folder called /opt/venv. It may also hold build tools, caches and other leftovers. Stage 2, the final image, starts fresh from python:3.13-slim and copies in only /opt/venv and the app's code. It runs as a normal user, not root. The builder stage, with its tools and leftovers, is thrown away and never ships.Stage 1: builder (thrown away)FROM python:3.13-slim AS builderBuild tools, caches,leftovers from installing/opt/venvthe installed librariesNever shippedCOPY --from=builderStage 2: the final imageFROM python:3.13-slimapp.pyyour code/opt/venvcopied from the builderRuns as user app, not rootBuild with every tool you need. Ship only what your app needs to run.
The builder stage does the messy work. The final image starts clean and copies in only the results, so build tools and leftovers never ship.

Run as a normal user

Check who the hello-app:full image you just built runs as. Like every image you've built so far, it doesn't say:

BASH
docker run --rm hello-app:full whoami

root. Nothing about a web app needs admin rights. If an attacker found a flaw in it, running as root would give them the most power possible inside the container, and a much better chance of breaking out of it. The fix is two lines: create a normal user, and switch to it with USER.

Try it: the improved Dockerfile

Your app needs one small addition first: a page that only says "I'm alive", for the health check later. Add this to the end of app.py:

PYTHON
@app.get("/healthz")
def healthz():
    return "ok\n"   # no database involved: only "is the app itself answering?"

Now replace your Dockerfile with this one. It puts everything in this lesson together, with a comment above each step. (In a Dockerfile, comments must be on their own line: a # at the end of an instruction would become part of it.)

DOCKERFILE
# ---------- Stage 1: the builder (thrown away) ----------

# Start from slim Python, and name this stage "builder" so stage 2 can copy from it
FROM python:3.13-slim AS builder

# Work in a scratch folder
WORKDIR /build

# Copy only the list of libraries first, so the slow install stays cached (Lesson 4)
COPY requirements.txt .

# Create a virtual environment: a self-contained folder of Python libraries at /opt/venv,
# then install the libraries into it. "\" continues the command on the next line,
# and "&&" means "then, if that worked, run this".
RUN python -m venv /opt/venv \
 && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

# ---------- Stage 2: the image that actually ships ----------

# Start again from a clean slim Python: nothing from stage 1 comes along unless copied
FROM python:3.13-slim

# Look for programs in /opt/venv/bin first, so "python" and "flask" are the venv's
ENV PATH="/opt/venv/bin:$PATH"

# Create a normal user called "app": --system for a service account with no password,
# --uid 1000 to give it a fixed ID number, --create-home to give it a home folder
RUN useradd --system --uid 1000 --create-home app

WORKDIR /app

# Copy the finished libraries out of the builder stage, and nothing else
COPY --from=builder /opt/venv /opt/venv

# Copy the app's code, owned by the "app" user instead of root
COPY --chown=app:app app.py .

# From here on, and when the container runs, act as "app", not root
USER app

# A note that the app listens on 8000 (it doesn't publish anything; -p does that)
EXPOSE 8000

# Every 10 seconds, ask the app for /healthz. If it doesn't answer within 3 seconds,
# or answers with an error, 3 times in a row, mark the container unhealthy.
# The check is a one-line Python program, because slim images don't include curl.
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"

# Start the app when the container starts
CMD ["flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000"]

Two ideas in there are worth a closer look:

  • Why a virtual environment? Normally pip scatters libraries across Python's own folders, which makes them hard to copy as one piece. A virtual environment puts them all in one folder, /opt/venv, so stage 2 can take them with a single COPY --from=builder.
  • How does the health check decide? The one-line Python program requests /healthz. If the page answers normally, the program finishes with exit code 0 (Lesson 2), which Docker counts as healthy. If the page fails or doesn't answer, the program fails with a non-zero exit code, which counts as a failed check. Any command can be a health check this way.

For this small app, the builder stage only saves a little, because Flask and Redis don't need compiling. The pattern is what matters: the day you add a library that needs a compiler, the compiler stays in the builder. Build it and check:

BASH
docker build -t hello-app:3.0 .
docker run --rm hello-app:3.0 whoami

app. Now run it with its health check:

BASH
docker run -d -p 8000:8000 --name hello hello-app:3.0
docker ps

At first the STATUS column says (health: starting). Run docker ps again after about 10 seconds, and it says (healthy). The home page needs Redis, which isn't running here, but /healthz doesn't, so the container is still healthy. That's deliberate.

Health checks and Kubernetes

If you've done the Kubernetes track: Kubernetes ignores a Dockerfile's HEALTHCHECK. It uses its own probes, which you set in the Deployment. The ideas are the same, and so is the rule from the Kubernetes health checks lesson: a check that decides whether to restart something should only check the app itself, never its database. That's why /healthz doesn't touch Redis.

Scanning for known problems

Even a small image contains other people's software, and new security flaws are found in popular software every week. Image scanners compare everything in your image against public lists of known flaws, and tell you which ones apply. Two common free ones are Trivy and Docker Scout, which is built into Docker Desktop. They're worth running before you ship, and many teams run one automatically on every build. A smaller image doesn't just download faster: it gives a scanner fewer things to find.

In my real setup

CivicGrid's API Dockerfile does everything in this lesson. Its builder stage installs a compiler and the development files for PostgreSQL, because one of its database libraries may need them to build. The final stage installs only the one small library the API needs to talk to the database at run time, and a comment in the file says it plainly: the compilers and build caches stay behind in stage 1 and never ship to production. It creates a user with ID 1000 and switches to it before the app starts, and it has a HEALTHCHECK.

Two honest notes. First, in production it runs on Kubernetes, so that HEALTHCHECK is ignored there; the probes in the Deployment do that job. Second, its health check calls /health, which checks the database, the exact mistake the Kubernetes health checks lesson warns about, which I found while writing that lesson. And I don't scan my images automatically yet. Adding Trivy to my build pipeline is on my list, not done.

Kubernetes adds two more layers on top. My Deployment refuses to start the API as root at all, even if the image were changed by mistake. And the API's container runs with a read-only filesystem, so it can't write anywhere except a small scratch folder at /tmp. Even if someone got in, they couldn't change the app's files.

Break it on purpose

Bake a secret into an image. Make a throwaway copy of your Dockerfile that copies the whole folder, the shortcut many tutorials use:

BASH
sed 's/^COPY --chown=app:app app.py ./COPY --chown=app:app . ./' Dockerfile > Dockerfile.lazy
mv .dockerignore dockerignore.off
docker build -f Dockerfile.lazy -t hello-app:lazy .
docker run --rm hello-app:lazy cat settings.env

(-f tells docker build to use a different Dockerfile.) It prints GREETING=Hola: your settings file is inside the image. Imagine that was a database password, and the image was pushed to a public registry. Anyone who pulls it can read it, and deleting the file later doesn't help, because it's still in an earlier layer. Now put the .dockerignore back, and build again:

BASH
mv dockerignore.off .dockerignore
docker build -f Dockerfile.lazy -t hello-app:lazy .
docker run --rm hello-app:lazy cat settings.env

No such file or directory. The .dockerignore kept it out of the build entirely. Copy only the files you mean to, and keep a .dockerignore, so one mistake isn't enough to leak a secret.

Try to write as the normal user:

BASH
docker run --rm hello-app:3.0 sh -c 'echo test > /app/new-file.txt'

Permission denied. The app user owns app.py, but not the /app folder itself, which WORKDIR created as root. That's a feature: the app can't change its own folder. If your app genuinely needs to write files, give it a specific folder for that (or a volume, Lesson 5), rather than going back to root.

Clean up

BASH
docker rm -f hello
docker rmi hello-app:full hello-app:lazy
rm Dockerfile.lazy

Keep hello-app:3.0 and your folder: Lesson 8 takes this image to Kubernetes.

Check yourself

  1. Why does a smaller image tend to be safer, not just faster?

  2. Your folder has a file with real passwords, and your Dockerfile has COPY . . What keeps the file out of the image?

  3. What does a multi-stage build ship?

  4. docker run --rm myimage whoami prints root. Why is that worth fixing?

  5. Why does the app's HEALTHCHECK call /healthz instead of the home page, which uses Redis?

  6. You run your image on Kubernetes. What happens to its Dockerfile HEALTHCHECK?

Key terms

  • Base image: the image in your FROM line, which your image builds on.
  • slim / alpine: smaller variants of official images. slim is the same Linux with fewer tools; alpine is a different, tiny Linux.
  • .dockerignore: a file listing what Docker leaves out of the build.
  • Multi-stage build: a Dockerfile with several FROM stages, where only the last one ships.
  • Root: the all-powerful admin user. Containers run as root unless you say otherwise.
  • USER: the Dockerfile instruction that switches to a normal user.
  • HEALTHCHECK: tells Docker how to check whether a container's app is healthy.
  • Image scanner: a tool, like Trivy or Docker Scout, that finds known security flaws in an image.

Go deeper