--- title: "How to Write a Dockerfile with Multi-Stage Builds" description: "Write a production-ready Dockerfile for a Python web app: layer caching, .dockerignore, a non-root user, health checks and a multi-stage build, then test it." category: "Docker Kubernetes" language: en source: https://cubepath.com/docs/docker-kubernetes/creating-dockerfiles-complete-guide updated: 2026-09-25 --- # How to Write a Dockerfile with Multi-Stage Builds A Dockerfile is a text file with the instructions Docker follows to build an image: which base image to start from, which files to copy, which commands to run and how to start the application. In this tutorial you will write a Dockerfile for a small Python web application, starting with a simple version and improving it step by step with layer caching, a `.dockerignore` file, a non-root user, a health check and a multi-stage build. The same principles apply to any language. The examples use Ubuntu 24.04 as the host. ## Prerequisites To follow this guide you need: - A server or workstation with Docker Engine and the Buildx plugin installed, for example a CubePath VPS running Ubuntu 24.04. - A user who can run `docker` without `sudo` (a member of the `docker` group). - Basic familiarity with `docker run`, `docker ps` and `docker logs`. ## Step 1 - Creating the sample application Create a project directory: ```bash mkdir -p ~/flask-demo && cd ~/flask-demo ``` Create the application file: ```bash nano app.py ``` ```python from flask import Flask, jsonify app = Flask(__name__) @app.get("/") def index(): return jsonify(message="Hello from Docker") @app.get("/health") def health(): return jsonify(status="ok") ``` Create the list of dependencies. Pinning exact versions makes every build produce the same image: ```bash nano requirements.txt ``` ```text flask==3.1.2 gunicorn==23.0.0 ``` The app will be served by Gunicorn, a production WSGI server, rather than Flask's development server. ## Step 2 - Writing a first Dockerfile Create a file named `Dockerfile` (no extension) in the project directory: ```bash nano Dockerfile ``` ```dockerfile FROM python:3.12-slim WORKDIR /app COPY . . RUN pip install --no-cache-dir -r requirements.txt EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] ``` Each instruction does one thing: - `FROM` sets the base image. `python:3.12-slim` is Debian with Python and little else, much smaller than the full `python:3.12` image. - `WORKDIR` sets the working directory for the following instructions and creates it if needed. - `COPY . .` copies the build context (the project directory) into `/app`. - `RUN` executes a command at build time and saves the result as a new layer. - `EXPOSE` documents the port the application listens on. It does not publish anything; you still need `-p` at run time. - `CMD` is the default command when a container starts. The JSON (exec) form runs Gunicorn directly as PID 1, so it receives stop signals correctly. Build the image and tag it: ```bash docker build -t flask-demo:1.0 . ``` ```output [+] Building 14.2s (9/9) FINISHED => [internal] load build definition from Dockerfile => [1/4] FROM docker.io/library/python:3.12-slim => [2/4] WORKDIR /app => [3/4] COPY . . => [4/4] RUN pip install --no-cache-dir -r requirements.txt => exporting to image => => naming to docker.io/library/flask-demo:1.0 ``` Run it and test it: ```bash docker run -d --name flask-demo -p 8000:8000 flask-demo:1.0 curl http://localhost:8000/ ``` ```output {"message":"Hello from Docker"} ``` It works, but this Dockerfile has three problems: any code change reinstalls all dependencies, it copies everything in the directory into the image, and the application runs as root. The next steps fix them. Remove the container before continuing: ```bash docker rm -f flask-demo ``` ## Step 3 - Ordering instructions for layer caching Docker caches each layer and reuses it as long as the instruction and the files it depends on have not changed. Once a layer changes, every layer after it is rebuilt. In the first Dockerfile, `COPY . .` comes before `pip install`, so editing `app.py` invalidates the dependency layer. Copy `requirements.txt` on its own first, install dependencies, and only then copy the rest of the code. Open the Dockerfile: ```bash nano Dockerfile ``` ```dockerfile FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"] ``` Build it, change a line in `app.py` (for example the message text), and build again: ```bash docker build -t flask-demo:1.1 . ``` ```output => CACHED [2/5] WORKDIR /app => CACHED [3/5] COPY requirements.txt . => CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt => [5/5] COPY . . ``` The dependency layer is `CACHED`, so the rebuild takes about a second instead of reinstalling packages. Apply the same rule in any language: copy the dependency manifest (`package.json` and its lock file, `go.mod` and `go.sum`, `pom.xml`), install, then copy the source. ## Step 4 - Excluding files with .dockerignore `COPY . .` copies the whole build context, including `.git`, local virtual environments, caches and possibly secrets such as `.env`. A `.dockerignore` file in the project root excludes them from the context, which makes builds faster and images cleaner: ```bash nano .dockerignore ``` ```text .git .gitignore .venv __pycache__ *.pyc .env Dockerfile .dockerignore ``` The syntax is the same as `.gitignore`. Excluding `Dockerfile` itself also means editing it does not invalidate the `COPY . .` layer. ## Step 5 - Running as a non-root user By default processes in a container run as root. If an attacker exploits the application, they are root inside the container, which makes escaping to the host easier. Create an unprivileged user and switch to it with `USER`. Also set two Python variables that are useful in containers: no `.pyc` files written at run time, and unbuffered output so logs appear immediately in `docker logs`: ```bash nano Dockerfile ``` ```dockerfile FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd --system --uid 10001 --no-create-home appuser USER appuser EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] ``` The files copied into `/app` stay owned by root and are only readable by `appuser`, which is what you want: the application cannot modify its own code. If the app needs to write somewhere, create that directory and `chown` it to `appuser` before the `USER` line. Build and verify which user the process runs as: ```bash docker build -t flask-demo:1.2 . docker run -d --name flask-demo -p 8000:8000 flask-demo:1.2 docker exec flask-demo id ``` ```output uid=10001(appuser) gid=999(appuser) groups=999(appuser) ``` Because port 8000 is above 1024, a non-root user can bind to it. Remove the container: ```bash docker rm -f flask-demo ``` ## Step 6 - Adding a health check A running process is not necessarily a working application. `HEALTHCHECK` tells Docker how to test the app, and `docker ps` then shows `healthy` or `unhealthy`. The slim image has no `curl`, so use Python itself to request the `/health` endpoint. Add this before the `CMD` line: ```dockerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] ``` If the request fails or times out, Python exits with a non-zero code and the check counts as failed. After three consecutive failures the container is marked `unhealthy`. Rebuild and run: ```bash docker build -t flask-demo:1.3 . docker run -d --name flask-demo -p 8000:8000 flask-demo:1.3 ``` After about 30 seconds, check the status: ```bash docker ps --format 'table {{.Names}}\t{{.Status}}' ``` ```output NAMES STATUS flask-demo Up 35 seconds (healthy) ``` Docker Compose can use this status to start dependent services only when the app is healthy. Remove the container: ```bash docker rm -f flask-demo ``` ## Step 7 - Using a multi-stage build Some dependencies need a compiler or headers to install, and those tools have no place in the final image. A multi-stage build uses one stage to build and a second, clean stage that copies only the result. For Python, install the dependencies into a virtual environment in a builder stage and copy the whole environment into the final stage. Replace the Dockerfile with the final version: ```bash nano Dockerfile ``` ```dockerfile # syntax=docker/dockerfile:1 ARG PYTHON_VERSION=3.12 FROM python:${PYTHON_VERSION}-slim AS builder 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:${PYTHON_VERSION}-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="/opt/venv/bin:$PATH" COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY . . RUN useradd --system --uid 10001 --no-create-home appuser USER appuser EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] ``` A few details worth noting: - `AS builder` names the first stage, and `COPY --from=builder` copies files out of it. Only the last stage ends up in the image you tag. - Both stages use the same base image, so the Python interpreter the virtual environment points to exists at the same path in the final stage. - `ARG PYTHON_VERSION` before the first `FROM` makes the Python version a build argument. You can build against another version without editing the file: `docker build --build-arg PYTHON_VERSION=3.13 -t flask-demo:py313 .` - If a package needed system build tools, you would install them (for example `apt-get install -y build-essential`) only in the builder stage. Build the final image and run it: ```bash docker build -t flask-demo:2.0 . docker run -d --name flask-demo -p 8000:8000 --restart unless-stopped flask-demo:2.0 curl http://localhost:8000/health ``` ```output {"status":"ok"} ``` Compare image sizes and look at the layers of the final image: ```bash docker images flask-demo docker history flask-demo:2.0 ``` For this small app the difference is modest because the dependencies are pure Python, but for applications with compiled dependencies, or for compiled languages such as Go where the final stage only needs the binary, a multi-stage build often cuts the image size by several hundred megabytes. ## Step 8 - Understanding CMD and ENTRYPOINT Both instructions define what runs when the container starts, and they are easy to confuse: | | `CMD` | `ENTRYPOINT` | |---|---|---| | Purpose | Default command or default arguments | The fixed executable | | Overridden by | Any arguments after the image name in `docker run` | Only `docker run --entrypoint` | | Typical use | Applications like this one | Images that wrap a single tool | With only `CMD`, you can replace the command at run time, which is handy for debugging: ```bash docker run --rm -it flask-demo:2.0 python -c "import flask; print(flask.__version__)" ``` ```output 3.1.2 ``` When you combine both, `ENTRYPOINT` is the executable and `CMD` supplies default arguments that the user can override: ```dockerfile ENTRYPOINT ["gunicorn", "app:app"] CMD ["--bind", "0.0.0.0:8000", "--workers", "2"] ``` In an image built that way, `docker run image_name --workers 4 --bind 0.0.0.0:8000` replaces only the arguments passed to Gunicorn. Always use the JSON form for both. The shell form (`CMD gunicorn app:app`) wraps the process in `/bin/sh -c`, so the shell becomes PID 1 and `docker stop` signals do not reach your application, which then gets killed after the 10-second timeout. ## Step 9 - Keeping secrets out of the image Anything written with `ENV`, `ARG` or `COPY` stays in the image and can be read by anyone who pulls it, even if a later instruction deletes the file. Pass runtime secrets as environment variables when you start the container, never in the Dockerfile: ```bash docker run -d --name flask-demo -e API_KEY=your_api_key flask-demo:2.0 ``` If the build itself needs a secret, for example a token for a private package index, use a BuildKit secret mount. The secret is available only while that `RUN` instruction executes and is not stored in any layer: ```dockerfile RUN --mount=type=secret,id=pip_token \ PIP_INDEX_URL="https://token:$(cat /run/secrets/pip_token)@pypi.example.com/simple" \ pip install --no-cache-dir -r requirements.txt ``` ```bash docker build --secret id=pip_token,src=$HOME/.pip_token -t flask-demo:2.0 . ``` ## Step 10 - Scanning the image for vulnerabilities Base images and dependencies receive security fixes regularly. Scan your image with Trivy, which you can run as a container without installing anything: ```bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image flask-demo:2.0 ``` The report lists known CVEs by severity for both the Debian packages in the base image and the Python packages. Most fixes come from rebuilding with a fresh base image, so rebuild regularly and pull the newest base when you do: ```bash docker build --pull -t flask-demo:2.1 . ``` ## Troubleshooting **`failed to solve: ... requirements.txt: not found`**: the file is not in the build context. Check that you run `docker build` from the project directory (the final `.` is the context) and that `.dockerignore` does not exclude it. **A `RUN` step fails and the error scrolls by too fast**: rebuild with plain output and without cache to see every line: `docker build --progress=plain --no-cache -t flask-demo:debug .` **`PermissionError` when the app writes a file**: the application runs as `appuser` and `/app` is owned by root. Create a dedicated writable directory in the Dockerfile and `chown` it to `appuser`, or write to a mounted volume. **The container is `unhealthy` but the app works from outside**: the health check runs inside the container, so it must use the port the app listens on inside (8000), not the published host port. Check the probe output with `docker inspect -f '{{json .State.Health}}' flask-demo`. **Changes to the code do not appear in the container**: you are running an old image. Rebuild, then remove and recreate the container; `docker restart` keeps using the image the container was created from. ## Conclusion You have written a Dockerfile that installs dependencies in a cached layer, keeps unwanted files out with `.dockerignore`, runs as a non-root user, reports its health, and uses a multi-stage build to keep build tools out of the final image. The same structure works for Node.js, Go, Java or any other stack: copy the manifest, install dependencies, copy the code, drop privileges and define a clear start command. As next steps, you can: - Describe the application and its database in a `compose.yaml` file and run them with `docker compose up -d`. - Push the image to a private registry with `docker tag` and `docker push` so your servers can pull it. - Build images automatically on every commit in your CI pipeline and scan them before deployment.