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"]
| Instruction | What it does here |
|---|---|
FROM python:3.12-slim | Starts from the official Python image on a slim Debian base. Pin a version tag rather than latest so rebuilds are predictable |
WORKDIR /app | Creates /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 8000 | Documents 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.
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 symptom | Cause | Fix |
|---|---|---|
"/requirements.txt": not found | The file is outside the build context or listed in .dockerignore | Run docker build from the project directory; check .dockerignore |
port is already allocated | Another container or process holds the host port | docker ps; sudo ss -tlnp | grep 8080 |
curl: (56) Recv failure: Connection reset by peer | The app inside listens on 127.0.0.1 | Bind to 0.0.0.0 in the app or CMD |
exec format error | Image built for arm64 (Apple Silicon) running on amd64, or the reverse | docker build --platform linux/amd64 |
| Container exits right after start | The main process crashed or finished | docker logs hello; docker ps -a |
permission denied while trying to connect to the Docker daemon socket | Your user is not in the docker group yet | Use 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.