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
dockerwithoutsudo(a member of thedockergroup). - Basic familiarity with
docker run,docker psanddocker 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:
FROMsets the base image.python:3.12-slimis Debian with Python and little else, much smaller than the fullpython:3.12image.WORKDIRsets the working directory for the following instructions and creates it if needed.COPY . .copies the build context (the project directory) into/app.RUNexecutes a command at build time and saves the result as a new layer.EXPOSEdocuments the port the application listens on. It does not publish anything; you still need-pat run time.CMDis 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 buildernames the first stage, andCOPY --from=buildercopies 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_VERSIONbefore the firstFROMmakes 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:
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:
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.yamlfile and run them withdocker compose up -d. - Push the image to a private registry with
docker taganddocker pushso your servers can pull it. - Build images automatically on every commit in your CI pipeline and scan them before deployment.
