SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

What is a Dockerfile, and how do you write one?

A Dockerfile is the recipe Docker builds an image from. Write one for a Python web app with cached layers, a non-root user, a healthcheck and two stages.

What is a Dockerfile?

A Dockerfile is a text file of instructions that Docker reads to build an image. Docker's own reference calls it "a text document that contains all the commands a user could call on the command line to assemble an image." You write the steps once. docker build then turns them into an image you can run on any machine with Docker.

Think of it as a recipe that builds the image one step at a time. Docker's cache documentation says it directly: "Each instruction in this Dockerfile translates to a layer in your final image." Docker saves each layer. On the next build it reuses that layer if nothing the layer depends on has changed. Most of the rules below exist because of that one fact.

This guide writes a real Dockerfile for a small Python web app, one instruction at a time. Then it turns the result into a two-stage build and plugs it into Docker Compose. You need Docker installed. If your server does not have it yet, start with installing Docker on a VPS and come back.

The example app

The app is a tiny API built with FastAPI and served by Uvicorn. Uvicorn is an ASGI (asynchronous server gateway interface) server for Python. Create a project folder with this layout:

hello-api/
  Dockerfile
  .dockerignore
  requirements.txt
  app/
    __init__.py
    main.py

app/__init__.py is an empty file. It makes app a Python package, so Uvicorn can load app.main. Put this in app/main.py:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
    return {"message": "hello from a container"}


@app.get("/health")
def health():
    return {"status": "ok"}

And this in requirements.txt:

fastapi
uvicorn

In a real project, pin exact versions in requirements.txt (for example with pip freeze). An unpinned line installs whatever is newest on the day you build. Two builds made a month apart can then contain different code.

A first Dockerfile, line by line

Here is a complete single-stage Dockerfile. Each part is explained below it.

# syntax=docker/dockerfile:1
FROM python:3.13-slim

WORKDIR /usr/local/app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

RUN useradd app
USER app

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

The syntax line

# syntax=docker/dockerfile:1 is a parser directive. It must be the first line. Docker's reference says most users will want this value, "which causes BuildKit to pull the latest stable version of the Dockerfile syntax before the build." So the build fetches a frontend image before it starts. BuildKit is the build engine. Docker's documentation says it is "the default builder for Docker Desktop and Docker Engine users." The exception is Windows containers, which use the legacy builder.

FROM with a pinned tag

FROM picks the base image that every later step builds on. Always give it a tag. If you write FROM python with no tag and no digest, the reference says "the builder assumes a latest tag by default."

latest is a poor choice because tags are mutable. Docker's best-practices page puts it plainly: "a publisher can update a tag to point to a new image." With latest, a rebuild next month can jump to a new major Python version. Your app then breaks for a reason that is not in your code.

python:3.13-slim is a middle path. It stays on Python 3.13, and the publisher updates it with patch releases. Docker describes the same pattern with Alpine: "3.21 resolves to the latest patch version for 3.21." If you need the exact same bytes on every build, pin a digest (FROM python:3.13-slim@sha256:...). That has a real cost. The best-practices page warns that with a digest "you're opting out of automated security fixes," and it suggests GitHub Dependabot to keep pinned bases current.

WORKDIR

WORKDIR /usr/local/app sets the directory for the instructions that follow, and for the running container. Use an absolute path. Docker's guidance says "you should always use absolute paths for your WORKDIR." If you never set it, the working directory is /, and your files end up mixed in with the system's files.

COPY, and why not ADD

COPY puts files from your project into the image. ADD can do the same, and Docker's best-practices page calls the two "functionally similar." The difference is that ADD is "best for when you need to download a remote artifact as part of your build." It can also check a checksum for remote files.

For local files, use COPY. It does one thing, so a reader of your Dockerfile knows exactly what that line does. Keep ADD for the case it was built for: fetching a file from a URL at build time.

The order of COPY and RUN

Look at the order again. requirements.txt is copied and installed before the app code is copied. That order is the most useful habit in this whole file.

The rule comes from Docker's cache documentation: "If a layer changes, all other layers that come after it are also affected." You change app/main.py many times a day. You change requirements.txt rarely. The dependency install sits above the code copy, so a code change only rebuilds the COPY app ./app layer and the steps after it. The slow pip install layer comes straight from the cache.

Swap the order to COPY . . followed by RUN pip install, and every one-line code edit changes the copy layer. Pip then downloads and installs every package again. Docker's own get-started example uses the same manifest-first order. The --no-cache-dir flag tells pip not to keep its download cache inside the image, where it would only add size.

A non-root USER

By default, the process in a container runs as root. Docker's advice: "If a service can run without privileges, use USER to change to a non-root user." This web app needs no privileges. RUN useradd app creates a user, and USER app switches to it. From that line on, later RUN steps and the container's CMD all run as app.

One detail catches people out. Files added with COPY belong to root unless you say otherwise. The reference states: "Without this flag, files are created with UID and GID of 0." Here the app only reads its code, so that is fine. If your process must write into a copied folder, copy it with COPY --chown=app:app data ./data. Without --chown, the write fails with a permission error. When you later mount host folders into containers, ownership becomes a host question too, which the PUID and PGID variables many images use exist to solve.

EXPOSE is documentation

EXPOSE 8080 does not open anything. The reference is direct: "The EXPOSE instruction doesn't actually publish the port." It records which port the app listens on, for people and for tools.

Publishing happens when you run the container, with -p (--publish). -P publishes every exposed port to a random host port. So EXPOSE matters for -P, and as a note to the next reader. It never makes the app reachable by itself.

HEALTHCHECK

HEALTHCHECK tells Docker how to test whether the app inside the container actually works. That is a different question from whether the process is alive. Docker runs the command inside the container. Exit code 0 means healthy, and 1 means unhealthy. Code 2 is reserved, so do not use it.

The defaults are an interval of 30 seconds, a timeout of 30 seconds, and 3 retries. The example keeps the interval and the retries but cuts the timeout to 3 seconds, because a health endpoint that needs 30 seconds to answer is already broken. The status starts as starting. It becomes healthy when a check passes, and unhealthy after repeated failures.

Docker's reference example uses curl. Do not assume curl exists in your base image. This check uses Python's own urllib instead, because the image must contain Python to run the app at all. urlopen raises an error on a refused connection or an HTTP error status. The uncaught error makes Python exit with code 1, so Docker counts the check as failed. You can only have one healthcheck: "If you list more than one then only the last HEALTHCHECK will take effect."

CMD, and where ENTRYPOINT fits

CMD is the command the container runs when it starts. A Dockerfile needs at least one of CMD or ENTRYPOINT. Write it in exec form, the JSON array, which the reference calls "the preferred form." The shell form wraps your command in /bin/sh -c, and the reference notes that this "does not pass signals" to your process. Docker's build checks recommend JSON arguments for CMD and ENTRYPOINT for the same reason.

The --host 0.0.0.0 part matters. Inside a container, an app that listens only on 127.0.0.1 cannot be reached from outside the container, even with -p. Binding to 0.0.0.0 makes it listen on the container's network interface.

Arguments you pass to docker run after the image name replace CMD. ENTRYPOINT is for images meant to work like a single program. How the two combine, and how Compose overrides each one, is covered in command vs entrypoint in Docker Compose, so this guide does not repeat it.

The .dockerignore file

When you run docker build ., the dot is the build context: the folder Docker sends to the builder. Without a filter, that includes your .git folder and any secrets lying in the project. Docker looks for "a file named .dockerignore in the root directory of the context," with one pattern per line. Matching files are removed from the context before it is sent. The build gets faster, and nothing private can be copied in by accident.

Create .dockerignore next to the Dockerfile:

.git
.env
.venv
__pycache__
*.pyc

The .env line is the important one. Without it, a COPY . . in any future version of this Dockerfile would bake your secrets into an image layer. Anyone who pulls the image could then read them. Pass secrets at run time instead, as shown in keeping secrets out of images with env files.

Build and run the image

From the project folder, build the image. Give it a name and a version tag with -t:

docker build -t hello-api:1.0 .

The build should finish without errors. Run docker build again without changing anything. Every step should now come from the cache, so it finishes much faster. Then edit app/main.py and build once more. Only the steps from COPY app ./app onwards should run again. If pip install runs every time, a file copied above it changes on every build.

Start a container from the image:

docker run -d --name hello-api -p 127.0.0.1:8080:8080 hello-api:1.0
curl http://127.0.0.1:8080/

-d runs it in the background and prints the container ID. The curl call should return {"message":"hello from a container"}. If it returns Connection refused, wait a few seconds for Uvicorn to start, then read docker logs hello-api.

The -p value binds the port to 127.0.0.1 on the host only. That is deliberate. A plain -p 8080:8080 listens on every interface of your server, and your firewall may not stop it, because Docker-published ports bypass ufw. Put a reverse proxy in front if the app must be public.

Check the health status once the first interval has passed:

docker inspect --format '{{.State.Health.Status}}' hello-api

It should print starting at first and healthy after the first passing check. unhealthy means the check command keeps failing. docker inspect hello-api shows the full health log, with the output of each check.

A two-stage build that ships only the runtime

The single-stage image works. But anything you install to build your app stays in the final image, including the compilers that some Python packages need. A multi-stage build fixes that. Docker's docs explain the mechanism: "Each FROM instruction can use a different base, and each of them begins a new stage of the build." You name a stage with AS, and COPY --from=<stage> copies files out of it. The last stage becomes your image.

Here is the same app in two stages. The first stage installs the dependencies into a virtual environment. The second stage copies only that environment and the app code.

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build

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:3.13-slim AS runtime

ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /usr/local/app

COPY --from=build /opt/venv /opt/venv
COPY app ./app

RUN useradd app
USER app

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Both stages use the same base image on purpose. A virtual environment points back to the Python interpreter that created it. With the same base, that interpreter sits at the same path in both stages, so the copied environment works. Mix two different bases and the copied /opt/venv can fail to start.

The build stage is where heavy tools belong. If a package in requirements.txt needs a C compiler, install it with apt-get in the build stage only. The final image never contains it. You can also build one stage on its own, which helps when you debug it:

docker build --target build -t hello-api:build .

The --target flag sets the stage the build stops at.

Keeping the image fresh

A built image is frozen. Security fixes in the base image do not reach you until you rebuild. Docker's best-practices page says to "rebuild your images regularly with updated dependencies," and it gives two flags for this. --pull "forces Docker to check for and download a newer version of the base image." --no-cache rebuilds every layer from scratch:

docker build --pull --no-cache -t hello-api:1.1 .

Each rebuild leaves old images and build cache on disk. On a small VPS that adds up over weeks, so learn how to clean up Docker images and build cache before the disk fills. If you would rather use a daemonless engine, Podman vs Docker on a VPS explains where the two differ.

How the Dockerfile plugs into Compose build

On a server you rarely type long docker run lines by hand. Docker Compose reads a compose.yaml file instead. Its build: key "tells Compose how to (re)build an application from source." The short form names a folder, and that folder "must contain a Dockerfile":

services:
  api:
    build: .
    ports:
      - "127.0.0.1:8080:8080"

The long form adds options. dockerfile points at a file with a different name. target picks a stage of a multi-stage build. args sets ARG values. With the two-stage Dockerfile above:

services:
  api:
    build:
      context: .
      target: runtime
    ports:
      - "127.0.0.1:8080:8080"

Build and start it:

docker compose build
docker compose up -d

Be careful when a service has both build: and image:. With no pull_policy set, Compose "attempts to pull the image first" and builds from source only if it cannot find the image. That can surprise you if the name matches an image in a registry. The trade-offs are covered in Compose build vs image.

When an upstream image beats writing your own

You do not need a Dockerfile for every container. If you want to run a database or a finished app that its own project already packages, use the published image. Docker's guidance is: "Whenever possible, use current official images as the basis for your images." Docker Official Images are a curated set on Docker Hub, and Docker says they "are regularly updated." A project's own image gets its fixes from the people who maintain the software.

Write your own Dockerfile when the code is yours, as with the API in this guide. Also write one when you need a change the upstream image cannot make through environment variables or mounted config, such as an extra system package. In that case, start FROM the upstream image and add only your change. Your Dockerfile stays a few lines long, and the upstream project still maintains the base.

FAQ

What is the difference between a Dockerfile and a Docker image?

A Dockerfile is a text file of build instructions. An image is what docker build produces from it: a stack of layers, one per instruction, that Docker can start as a container. You commit the Dockerfile to git. You push the image to a registry or keep it on the server.

Why does my Docker build reinstall every dependency after a small code change?

A step above your dependency install copies files that change on every edit, usually COPY . .. When a layer changes, Docker rebuilds every layer after it, so the install runs again. Copy the dependency file first (requirements.txt for Python, package.json for Node), run the install, and copy the rest of the code after that.

Does EXPOSE in a Dockerfile open a port on my server?

No. EXPOSE only records which port the app listens on. The port is published when you run the container with -p, for example -p 127.0.0.1:8080:8080, or with -P, which maps every exposed port to a random host port.

Should I use COPY or ADD in a Dockerfile?

Use COPY for files from your project. Docker describes the two as functionally similar, and COPY does only one job, so the line is easy to read. ADD is meant for downloading a remote file during the build, and it can verify that file's checksum.

Why should a Dockerfile avoid the latest tag?

FROM python with no tag means latest, and a tag can move to a new image at any time. A rebuild can then pull a new major version and break your app for a reason outside your code. Pin a version tag such as python:3.13-slim, or a digest if you need identical builds. Then rebuild with --pull regularly to pick up security fixes.

Was this article useful?