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:

mkdir -p ~/flask-demo && cd ~/flask-demo

Create the application file:

nano app.py
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:

nano requirements.txt
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:

nano 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:

docker build -t flask-demo:1.0 .
[+] 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:

docker run -d --name flask-demo -p 8000:8000 flask-demo:1.0
curl http://localhost:8000/
{"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:

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:

nano 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:

docker build -t flask-demo:1.1 .
 => 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:

nano .dockerignore
.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:

nano 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:

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
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:

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:

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:

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:

docker ps --format 'table {{.Names}}\t{{.Status}}'
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:

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:

nano 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:

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
{"status":"ok"}

Compare image sizes and look at the layers of the final image:

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:

CMDENTRYPOINT
PurposeDefault command or default argumentsThe fixed executable
Overridden byAny arguments after the image name in docker runOnly docker run --entrypoint
Typical useApplications like this oneImages that wrap a single tool

With only CMD, you can replace the command at run time, which is handy for debugging:

docker run --rm -it flask-demo:2.0 python -c "import flask; print(flask.__version__)"
3.1.2

When you combine both, ENTRYPOINT is the executable and CMD supplies default arguments that the user can override:

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:

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:

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
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:

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:

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.