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
slimandalpinemean - How a
.dockerignorefile 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.
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:
| Base | What's in it | Use it when |
|---|---|---|
python:3.13 | A full Linux system, with compilers and lots of tools | You need to compile things and don't mind the size |
python:3.13-slim | The same Linux, with only the basics | Almost always: a safe default |
python:3.13-alpine | A different, tiny Linux called Alpine | You 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:
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:
# 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.
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:
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:
@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.)
# ---------- 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 singleCOPY --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:
docker build -t hello-app:3.0 .
docker run --rm hello-app:3.0 whoami
app. Now run it with its health check:
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:
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:
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:
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
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
Why does a smaller image tend to be safer, not just faster?
Your folder has a file with real passwords, and your Dockerfile has COPY . . What keeps the file out of the image?
What does a multi-stage build ship?
docker run --rm myimage whoami prints root. Why is that worth fixing?
Why does the app's HEALTHCHECK call /healthz instead of the home page, which uses Redis?
You run your image on Kubernetes. What happens to its Dockerfile HEALTHCHECK?
Key terms
- Base image: the image in your
FROMline, which your image builds on. - slim / alpine: smaller variants of official images.
slimis the same Linux with fewer tools;alpineis a different, tiny Linux. - .dockerignore: a file listing what Docker leaves out of the build.
- Multi-stage build: a Dockerfile with several
FROMstages, 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
- Multi-stage builds (opens in a new tab) in the official Docker docs.
- Building best practices (opens in a new tab) covers base images,
.dockerignore, users and more. - Docker Scout (opens in a new tab) and Trivy (opens in a new tab) are two free ways to scan your images.