Skip to content

Your First Dockerfile: From Source Code to Running Container

Write a Dockerfile for a small Python web app line by line, build and run it, use the layer cache, and fix the errors every beginner hits, from ports to platforms.

●By 10 min read

A Dockerfile is a recipe: start from a base image, add your dependencies and code, and say what command runs the app. docker build follows the recipe to produce an image, and docker run starts a container from it. The same image then runs the same way on your laptop, a CI runner, and a production server.

This tutorial takes a small Python web service from a directory of source files to a running container, explaining each instruction as it goes. Once it works, our Docker for Linux admins guide covers networking, volumes and production hardening in depth.

Before You Start: The App and Docker

Install Docker Engine first. On Debian/Ubuntu, the docker.io package from the distro works, or use the docker-ce packages from Docker's own repository for a newer release. On RHEL/Fedora, the default container tool is Podman, and every docker command in this guide works as podman with the same Dockerfile.

$ docker version --format '{{.Server.Version}}'

27.3.1

# Let your user talk to the daemon without sudo (log out and back in afterwards).

# Membership in the docker group is equivalent to root on this host.

$ sudo usermod -aG docker "$USER"

The app is a minimal Flask service in a directory called hello-api. Before writing a Dockerfile, answer four questions about any app: which runtime it needs, how dependencies are installed, which command starts it, and which port it listens on.

hello-api/

β”œβ”€β”€ app.py

β”œβ”€β”€ requirements.txt

└── Dockerfile

# app.py

import socket

from flask import Flask

app = Flask(__name__)

@app.get("/")

def index():

return {"message": "hello", "host": socket.gethostname()}

# requirements.txt

flask==3.0.3

gunicorn==23.0.0

The Dockerfile, Line by Line

# 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"]

InstructionWhat it does here
FROM python:3.12-slimStarts from the official Python image on a slim Debian base. Pin a version tag rather than latest so rebuilds are predictable
WORKDIR /appCreates /app and makes it the current directory for every later instruction and for the running container
COPY requirements.txt .Copies only the dependency list into the image first, so the next step can be cached
RUN pip install ...Runs at build time and saves the result as a layer. --no-cache-dir keeps pip's download cache out of the image
COPY . .Copies the rest of the source from the build context into /app
EXPOSE 8000Documents the port the app listens on. It does not publish anything; docker run -p does that
CMD [...]The default command when a container starts. Exec form (a JSON array) runs gunicorn directly as PID 1

RUN vs CMD β€” RUN executes while the image is being built, and its result is baked in. CMD is stored as metadata and executed only when a container starts. Installing packages in CMD is a classic first mistake.

Bind to 0.0.0.0 β€” inside a container, 127.0.0.1 is the container's own loopback. An app listening there cannot be reached through a published port, even though it looks fine from docker exec.

Prefer exec form β€” with CMD gunicorn ... (shell form), Docker runs /bin/sh -c, and the shell, not your app, can end up receiving the SIGTERM from docker stop. The app is then killed after the 10-second timeout instead of shutting down cleanly.

Build the Image

The build context is the directory you pass to docker build, here .. Everything in it is sent to the builder, so add a .dockerignore before the first build to keep secrets, VCS history and local junk out of the image:

# .dockerignore

.git

__pycache__/

*.pyc

.venv/

.env

Dockerfile

.dockerignore

$ docker build -t hello-api:0.1 .

[+] Building 17.9s (10/10) FINISHED docker:default

=> [internal] load build definition from Dockerfile 0.0s

=> [internal] load metadata for docker.io/library/python:3.12-slim 1.2s

=> [internal] load .dockerignore 0.0s

=> [1/5] FROM docker.io/library/python:3.12-slim@sha256:2b0079146a7... 6.1s

=> [internal] load build context 0.0s

=> [2/5] WORKDIR /app 0.2s

=> [3/5] COPY requirements.txt . 0.0s

=> [4/5] RUN pip install --no-cache-dir -r requirements.txt 9.7s

=> [5/5] COPY . . 0.0s

=> exporting to image 0.5s

$ docker image ls hello-api

REPOSITORY TAG IMAGE ID CREATED SIZE

hello-api 0.1 5f1c3e9a7b2d 12 seconds ago 146MB

Tag every build β€” -t name:tag gives the image a readable name. Without it you get only an image ID, and without a tag you get latest, which makes it unclear which version is running.

Never COPY secrets β€” an .env file or SSH key copied into a layer stays in the image history even if a later step deletes it. Pass secrets at run time with environment variables or a mounted file, or at build time with RUN --mount=type=secret.

sponsored

Run It and Look Inside

# -d: background, --name: a handle for later commands, -p host:container

$ docker run -d --name hello -p 8080:8000 hello-api:0.1

3b7e1f0c9a24d6e8...

$ docker ps

CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES

3b7e1f0c9a24 hello-api:0.1 "gunicorn --bind 0.0.…" Up 4 seconds 0.0.0.0:8080->8000/tcp, [::]:8080->8000/tcp hello

$ curl -s localhost:8080

{"host":"3b7e1f0c9a24","message":"hello"}

$ docker logs hello

[2026-10-02 09:30:11 +0000] [1] [INFO] Starting gunicorn 23.0.0

[2026-10-02 09:30:11 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)

[2026-10-02 09:30:11 +0000] [1] [INFO] Using worker: sync

[2026-10-02 09:30:11 +0000] [7] [INFO] Booting worker with pid: 7

# Run a command inside the container (docker exec -it hello sh for a shell)

$ docker exec hello ls /app

app.py requirements.txt

# Stop and remove it

$ docker stop hello && docker rm hello

The hostname is the container ID β€” the JSON response shows the short container ID because Docker sets it as the hostname. Run two containers on different host ports and each answers with its own ID.

PID 1 is gunicorn β€” the logs show the master process as PID 1, because exec-form CMD started it directly. That is what lets docker stop deliver SIGTERM to the app.

Publishing binds all interfaces β€” -p 8080:8000 listens on 0.0.0.0, and Docker's iptables rules can bypass a host firewall like ufw. Use -p 127.0.0.1:8080:8000 for services that should only be reachable locally.

Why Instruction Order Matters: The Layer Cache

Each instruction produces a layer, and Docker reuses a layer when the instruction and its inputs are unchanged. Once one step changes, every step after it is rebuilt. Edit only app.py and rebuild:

$ docker build -t hello-api:0.2 .

[+] Building 0.9s (10/10) FINISHED docker:default

# ...[internal] load steps trimmed

=> [1/5] FROM docker.io/library/python:3.12-slim@sha256:2b0079146a7... 0.0s

=> CACHED [2/5] WORKDIR /app 0.0s

=> CACHED [3/5] COPY requirements.txt . 0.0s

=> CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt 0.0s

=> [5/5] COPY . . 0.0s

=> exporting to image 0.1s

The pip install, the slow step, came from cache because requirements.txt did not change. Had the Dockerfile started with COPY . ., every one-line code edit would reinstall every dependency. The rule: copy what changes rarely first, and what changes often last.

Make it a little more production-ready β€” add ENV PYTHONUNBUFFERED=1 so logs appear immediately in docker logs, and run as an unprivileged user with RUN useradd --create-home app followed by USER app before CMD.

Grow into multi-stage builds β€” for compiled languages, build in one stage and copy only the binary into a small final image. The Dockerfile generator produces multi-stage templates for Go, Node, Python and others.

Bypass the cache when you must β€” docker build --no-cache rebuilds every step, and --pull fetches a newer base image. Use both on a schedule so security fixes in the base image reach your builds.

First-Dockerfile Errors and Their Fixes

Error or symptomCauseFix
"/requirements.txt": not foundThe file is outside the build context or listed in .dockerignoreRun docker build from the project directory; check .dockerignore
port is already allocatedAnother container or process holds the host portdocker ps; sudo ss -tlnp | grep 8080
curl: (56) Recv failure: Connection reset by peerThe app inside listens on 127.0.0.1Bind to 0.0.0.0 in the app or CMD
exec format errorImage built for arm64 (Apple Silicon) running on amd64, or the reversedocker build --platform linux/amd64
Container exits right after startThe main process crashed or finisheddocker logs hello; docker ps -a
permission denied while trying to connect to the Docker daemon socketYour user is not in the docker group yetUse sudo, or add the group and log in again

When a container misbehaves, docker inspect hello shows the full configuration, including the exact command, environment and port mappings, and the Linux commands reference covers the ss and ps checks for what is using a port on the host.

Frequently Asked Questions

What is the difference between a Dockerfile, an image and a container?

A Dockerfile is a text recipe. docker build runs it and produces an image, a read-only stack of layers with your app and its dependencies. docker run starts a container, which is a running process with its own filesystem view created from that image. One image can run as many containers.

What is the difference between RUN, CMD and ENTRYPOINT?

RUN executes a command at build time and saves the result in a layer. CMD sets the default command when a container starts and is replaced by any command you pass to docker run. ENTRYPOINT sets a fixed executable, and CMD then supplies its default arguments. For a first app, CMD in exec form is enough.

Does EXPOSE publish a port?

No. EXPOSE only records which port the app listens on, as documentation and for tools that read image metadata. To reach the app from the host, publish the port with docker run -p host_port:container_port, for example -p 8080:8000.

Why is my Docker build reinstalling dependencies every time?

An earlier instruction changes on every build, usually COPY . . placed before the dependency install. Copy only the dependency manifest, such as requirements.txt or package.json, run the install, and copy the rest of the source afterwards so code edits do not invalidate the install layer.

Can I use the same Dockerfile with Podman?

Yes. Podman reads the same Dockerfile format, and podman build, podman run and podman ps accept the same common flags. Podman runs rootless by default, so publishing a host port below 1024 needs extra configuration, and images are stored per user rather than in a system-wide daemon.

sponsored

Related Tools

See also