Docker cheatsheet
Learn Docker step by step, from your first container to small, safe production images. Each section has three parts:
- In short β the idea in one sentence.
- Example β commands and files with a comment saying what each does.
- Try it β a tiny exercise to check you understood.
Want the longer story behind a topic? The complete guide walks through it in more depth.
π Full guide: Docker β BeginnerInstall Docker Desktop (or Colima / Podman on a Mac or Linux), then create the practice app β most examples build and run it. Go through Part 1 in order; move to Part 2 once you can build and run an image, and Part 3 when you're preparing images for production.
Contentsβ
Part 1 β Beginner: What Docker is Β· Running containers Β· Images Β· Writing a Dockerfile Β· Building & publishing ports Β· Looking inside Β· Configuration Β· Cleaning up
Part 2 β Core: Layers & caching Β· .dockerignore Β· Volumes & bind mounts Β· Networks Β· Docker Compose Β· ENTRYPOINT vs CMD Β· Tags & registries Β· Common mistakes
Part 3 β Advanced: Multi-stage builds Β· Smaller, safer images Β· Health checks & restarts Β· Resource limits Β· Graceful shutdown Β· Faster & multi-platform builds Β· Debugging playbook Β· Words you'll meet
Practice appβ
In short: a tiny web app with no dependencies, so every build is quick.
Put these two files in an empty folder called hello-docker.
# app.py β answers every request with a greeting
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = f"Hello from {os.environ.get('APP_NAME', 'docker')}!\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
print("listening on port 8000", flush=True)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
# Dockerfile β the recipe for the image
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]
Part 1 β Beginnerβ
1. What Docker isβ
In short: Docker packages an app with everything it needs β runtime, libraries, settings β into an image, and runs it as an isolated container that behaves the same on every machine.
| Word | Meaning |
|---|---|
| Image | A read-only package: files plus instructions for starting the app. Like a class. |
| Container | A running (or stopped) instance of an image. Like an object. |
| Dockerfile | The recipe used to build an image |
| Registry | A place that stores images β Docker Hub, GitHub Container Registry, AWS ECR |
| Tag | A version label on an image: python:3.12-slim |
Container vs virtual machine (VM): a VM runs a whole separate operating system; a container shares the host's operating-system kernel and only isolates the app's processes and files. That's why containers start in about a second and use far less memory.
docker version # client and server (engine) versions β both must answer
docker info # how many containers and images you have, and more
Try it: run docker version. If the "Server" part errors, Docker's engine
isn't running yet β start Docker Desktop (or colima start).
2. Running containersβ
In short: docker run downloads the image if needed, creates a container,
and starts it.
docker run hello-world # prints a welcome message, then exits
docker run --rm -it python:3.12-slim python # -it: interactive terminal; --rm: delete when it exits
docker run -d --name web nginx # -d: run in the background ("detached")
docker ps # running containers
docker ps -a # all containers, including stopped ones
docker logs web # what the container printed
docker stop web # ask it to stop (SIGTERM, then SIGKILL after 10 s)
docker start web # start the same container again
docker rm -f web # stop and delete it
A container stops when its main process exits. That's why hello-world stops
straight away, while nginx keeps running until you stop it.
Try it: run docker run --rm -it alpine sh, create a file, exit, and run
the same command again. Is the file still there? (It isn't β each run makes a
new container.)
3. Imagesβ
In short: images are downloaded ("pulled") from a registry and identified
by name:tag.
docker pull python:3.12-slim # download an image
docker images # list local images and their sizes
docker image inspect python:3.12-slim --format '{{.Architecture}}' # e.g. arm64
docker history python:3.12-slim # the layers the image is made of
docker rmi python:3.12-slim # delete a local image
| Image name | Means |
|---|---|
nginx | docker.io/library/nginx:latest β Docker Hub, official image, tag latest |
python:3.12-slim | Python 3.12 on a trimmed-down Debian |
ghcr.io/org/app:1.4.2 | Version 1.4.2 of org/app on GitHub's registry |
latest is just a default tag name, not "the newest version". Always pin a
specific tag in real projects.
Try it: pull python:3.12 and python:3.12-slim, then compare their sizes
with docker images python.
4. Writing a Dockerfileβ
In short: a Dockerfile lists the steps to build your image, one instruction per line, top to bottom.
FROM python:3.12-slim # start from an existing image (the "base image")
WORKDIR /app # cd into /app (created if missing) for the next steps
COPY app.py . # copy a file from your folder into the image
RUN python -m compileall . # run a command at BUILD time
ENV APP_NAME=docker # set an environment variable
EXPOSE 8000 # document the port the app listens on
CMD ["python", "app.py"] # the command to run when a container STARTS
| Instruction | Runs | Use for |
|---|---|---|
RUN | Once, while building | Installing packages, compiling |
CMD | Every time a container starts | Starting your app |
COPY | While building | Your code and config files |
EXPOSE doesn't open anything by itself β it's documentation. You still
publish the port with -p when running.
Try it: add RUN echo "built at $(date)" > /build-info.txt to the
practice Dockerfile, and read the file inside a running container.
5. Building & publishing portsβ
In short: docker build turns the Dockerfile into an image; -p connects
a port on your computer to a port in the container.
cd hello-docker
docker build -t hello:1.0 . # -t: name:tag Β· "." = this folder is the build context
docker run -d --name hello -p 8080:8000 hello:1.0 # your port 8080 β container port 8000
curl localhost:8080 # Hello from docker!
docker rm -f hello
-p HOST:CONTAINER β the left number is on your computer, the right one is
inside the container. -p 127.0.0.1:8080:8000 makes it reachable only from
your own machine.
The build context (the .) is the folder sent to Docker for the build;
COPY can only copy files from inside it.
Try it: run two containers of the same image on ports 8081 and 8082.
6. Looking insideβ
In short: open a shell inside a running container, follow its logs, and read its settings.
docker run -d --name hello -p 8080:8000 hello:1.0
docker exec -it hello sh # a shell inside the running container (type exit to leave)
docker exec hello ls /app # run one command inside it
docker logs -f hello # follow the logs live (Ctrl+C to stop following)
docker inspect hello --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' # e.g. 172.17.0.2
docker stats --no-stream # CPU and memory use of each container
docker cp hello:/app/app.py ./copy.py # copy a file out of a container
docker top hello # processes running inside it
exec works only on a running container. If it keeps crashing, read
docker logs first, or start it with a shell instead:
docker run --rm -it hello:1.0 sh.
Try it: slim images don't include ps. Find out which program is
process 1 with docker exec hello cat /proc/1/cmdline or docker top hello.
7. Configurationβ
In short: pass settings into a container with environment variables, so one image works in every environment.
docker run --rm -p 8080:8000 -e APP_NAME=staging hello:1.0 & # -e sets one variable
sleep 1 && curl localhost:8080 # Hello from staging!
cat > app.env <<'EOF'
APP_NAME=from-file
LOG_LEVEL=debug
EOF
docker run --rm --env-file app.env hello:1.0 env | grep APP_NAME # APP_NAME=from-file
Never bake passwords or tokens into an image β anyone who can pull the image
can read them (docker history shows ENV values). Pass them at run time, or
use your platform's secret store.
Try it: change the practice app to read a GREETING variable with a
default, rebuild, and run it with two different greetings.
8. Cleaning upβ
In short: stopped containers, old images, and build cache pile up β check and prune them regularly.
docker system df # how much space images, containers, volumes, and cache use
docker container prune # delete all stopped containers
docker image prune # delete "dangling" images (untagged leftovers)
docker image prune -a # delete every image not used by a container
docker builder prune # delete the build cache
docker system prune # stopped containers + unused networks + dangling images + cache
prune commands ask for confirmation; add -f to skip the question in
scripts. Volumes are never deleted unless you add --volumes β your data is
safe by default.
Try it: run docker system df, prune, and run it again. How much space
did you get back?
Part 2 β Coreβ
9. Layers & cachingβ
In short: each RUN, COPY, and ADD makes a layer. Docker reuses
unchanged layers from its cache, but once one step changes, every step after
it runs again β so put things that change least at the top.
# Slow: any code change reinstalls every dependency
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
# Fast: dependencies are cached until requirements.txt itself changes
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
The same idea applies to every language: copy package.json and run
npm ci before copying the source; copy pom.xml and download dependencies
before copying src/.
Try it: build the fast version twice, changing only app.py in between.
The second build should say CACHED on the install step.
10. .dockerignoreβ
In short: a .dockerignore file keeps files out of the build context β
faster builds, smaller images, and no secrets copied in by accident.
# .dockerignore
.git
.env
*.log
__pycache__/
node_modules/
tests/
Dockerfile
.dockerignore
Without it, COPY . . copies your .git history, local .env secrets, and
node_modules built for your laptop instead of the container's system.
Try it: create a large dummy file (mkfile 100m big.bin on macOS,
fallocate -l 100M big.bin on Linux), build and watch the context size, then
ignore it and build again.
11. Volumes & bind mountsβ
In short: a container's own files disappear when it's deleted. Keep data in a volume (managed by Docker) or a bind mount (a folder from your computer).
docker volume create pgdata
docker run -d --name db -e POSTGRES_PASSWORD=pw \
-v pgdata:/var/lib/postgresql/data postgres:17 # named volume: data survives the container
docker run --rm -it -v "$PWD":/app -w /app python:3.12-slim python app.py
# bind mount: the container sees your live source folder β edits apply immediately
docker volume ls
docker volume inspect pgdata # where Docker keeps it
docker rm -f db && docker volume rm pgdata # the data is gone only now
| Kind | Written as | Use for |
|---|---|---|
| Named volume | -v pgdata:/path | Databases and anything that must survive |
| Bind mount | -v "$PWD":/path | Live-editing code during development |
| tmpfs | --tmpfs /tmp | Scratch data kept in memory only |
Add :ro (-v "$PWD":/app:ro) to make a mount read-only.
Try it: start PostgreSQL with a named volume, create a table, delete the container, start a new one with the same volume, and check the table is still there.
12. Networksβ
In short: containers on the same user-defined network can reach each other by name; Docker runs a small DNS (Domain Name System) service that turns names into container addresses.
docker network create shop-net
docker run -d --name db --network shop-net -e POSTGRES_PASSWORD=pw postgres:17
sleep 5 # give PostgreSQL a moment to start
docker run --rm --network shop-net postgres:17 pg_isready -h db # db:5432 - accepting connections
docker network ls
docker network inspect shop-net # which containers are attached
- Inside a network, containers use the container port (
db:5432) β no-pneeded.-pis only for reaching a container from your computer. - Containers on the default network can't find each other by name β always create your own network (Compose does this for you).
- From inside a container,
localhostis the container itself, not your computer. Usehost.docker.internalto reach your computer (Docker Desktop).
Try it: run the practice app on shop-net and curl it by name from
another container: docker run --rm --network shop-net curlimages/curl hello:8000.
13. Docker Composeβ
In short: Compose describes several containers β with their ports, volumes, networks, and settings β in one file, and starts them all with one command.
# compose.yaml
services:
web:
build: . # build from the Dockerfile in this folder
ports:
- "8080:8000"
environment:
APP_NAME: compose
DATABASE_URL: postgresql://postgres:pw@db:5432/shop # "db" is the service name
depends_on:
db:
condition: service_healthy # wait until the database is ready, not just started
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: pw
POSTGRES_DB: shop
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
volumes:
pgdata:
docker compose up -d --build # build if needed, start everything in the background
docker compose ps # status of each service
docker compose logs -f web # follow one service's logs
docker compose exec db psql -U postgres shop # a shell into a service
docker compose down # stop and remove containers and the network
docker compose down -v # β¦and the volumes (deletes the data!)
Compose creates a network for the project, so services reach each other by service name. It's for development and testing; production usually uses Kubernetes or a cloud container service.
Try it: add a third service, adminer (a web database browser, port
8080 inside), on host port 8081, and log into the database through it.
14. ENTRYPOINT vs CMDβ
In short: ENTRYPOINT is the program that always runs; CMD gives its
default arguments, which docker run can replace.
FROM python:3.12-slim
ENTRYPOINT ["python", "-m", "http.server"] # always runs this
CMD ["8000"] # default argument
docker build -t fileserver .
docker run --rm -p 8000:8000 fileserver # python -m http.server 8000
docker run --rm -p 9000:9000 fileserver 9000 # python -m http.server 9000 β CMD replaced
docker run --rm -it --entrypoint sh fileserver # replace the ENTRYPOINT to debug
Always use the JSON array form (["python", "app.py"]). The plain-text
form (CMD python app.py) runs your app inside /bin/sh -c, which stops it
receiving the stop signal β see Graceful shutdown.
Try it: build the image above and serve on port 7000 without editing the Dockerfile.
15. Tags & registriesβ
In short: to share an image, tag it with the registry's address and push it; others pull it by that name.
docker login ghcr.io # asks for a username and token
docker tag hello:1.0 ghcr.io/ada/hello:1.0 # add a second name to the same image
docker tag hello:1.0 ghcr.io/ada/hello:latest
docker push ghcr.io/ada/hello:1.0
docker push ghcr.io/ada/hello:latest
DIGEST=$(docker image inspect ghcr.io/ada/hello:1.0 \
--format '{{range .RepoDigests}}{{println .}}{{end}}' | grep '^ghcr.io/') # known after a push
echo "$DIGEST" # ghcr.io/ada/hello@sha256:β¦
docker pull "$DIGEST" # pull that exact, unchangeable version
A tag can be moved to a different image later; a digest (sha256:β¦)
names exactly one image forever. Deploy by version tag (1.4.2) or digest β
never latest, or you can't tell what's running or roll back reliably.
Try it: run a local registry (docker run -d -p 5000:5000 registry:2),
then tag, push, delete, and pull localhost:5000/hello:1.0.
16. Common mistakesβ
In short: the classic Docker traps, and the fix for each.
| Mistake | Fix |
|---|---|
| App works locally but not in the container: "connection refused" | The app must listen on 0.0.0.0, not 127.0.0.1, inside the container |
| Changed the code, container still runs the old version | Rebuild the image (docker build, or docker compose up --build) |
| Every build reinstalls all dependencies | Copy the dependency file and install before copying the source (layers) |
| Data lost when the container was deleted | Put it in a named volume |
Container can't reach localhost:5432 | Use the service/container name (db:5432), or host.docker.internal for your computer |
docker stop takes 10 seconds | The app ignores SIGTERM β see Graceful shutdown |
| Huge image | Slim base image, .dockerignore, multi-stage build |
| Secret in the image | Pass it at run time; if it was pushed, rotate it β image layers keep it |
| "no space left on device" | docker system df, then prune |
Try it: change the practice app to listen on 127.0.0.1, rebuild, and see
the first mistake for yourself.
Part 3 β Advancedβ
17. Multi-stage buildsβ
In short: build in one stage with all the tools, then copy only the result into a small final stage. Compilers and build caches never reach production.
# Stage 1: build a Java app with Maven
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -q dependency:go-offline # cached until pom.xml changes
COPY src ./src
RUN mvn -q package -DskipTests
# Stage 2: only a Java runtime and the JAR
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
USER 1000
CMD ["java", "-jar", "app.jar"]
The build stage holds Maven, a full JDK (Java Development Kit), and every
downloaded library; the final image holds only a Java runtime and one JAR β
usually hundreds of megabytes smaller. Check with docker images.
docker build --target build . stops after a named stage β useful for running
tests in CI inside the build image.
Try it: write a two-stage Dockerfile for a Go program ending in
FROM scratch (an empty image), and compare its size with the build stage.
18. Smaller, safer imagesβ
In short: every file in an image is something to download, patch, and
possibly exploit. Ship as little as possible, and don't run as root.
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home --uid 1000 appuser # a normal user
COPY --chown=appuser:appuser app.py .
USER appuser # everything after this runs as appuser
CMD ["python", "app.py"]
| Do | Why |
|---|---|
Use -slim, -alpine, or distroless base images | Fewer packages = smaller, fewer vulnerabilities |
Pin versions (python:3.12.7-slim, or by digest) | Builds are repeatable |
Run as a non-root USER | A break-in gets fewer powers |
Scan images (docker scout cves, Trivy, Grype) | Finds known vulnerabilities in packages |
Clean up in the same RUN (apt-get install β¦ && rm -rf /var/lib/apt/lists/*) | A later RUN rm doesn't shrink earlier layers |
Use --read-only and --cap-drop ALL at run time where the app allows | Less the container can change |
Distroless images contain your app's runtime and nothing else β no shell, no package manager.
Try it: run docker run --rm hello:1.0 whoami before and after adding the
USER line.
19. Health checks & restartsβ
In short: a health check lets Docker test whether the app really works, not just whether the process is alive; a restart policy brings a crashed container back.
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
HEALTHCHECK --interval=10s --timeout=2s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000')" || exit 1
CMD ["python", "app.py"]
docker run -d --name hello --restart unless-stopped -p 8080:8000 hello:1.0
docker ps # STATUS shows (healthy), (unhealthy), or (health: starting)
docker inspect hello --format '{{.State.Health.Status}}'
--restart | Restarts the container⦠|
|---|---|
no (default) | Never |
on-failure[:5] | When it exits with an error (at most 5 times) |
unless-stopped | Always, unless you stopped it yourself |
always | Always, even after you stop it once Docker restarts |
Slim images don't include curl, which is why the check above uses Python.
20. Resource limitsβ
In short: without limits, one container can use all the host's memory or CPU. Set limits, and know what happens when they're hit.
docker run -d --name hello --memory 256m --cpus 0.5 hello:1.0 # at most 256 MB and half a CPU
docker stats --no-stream hello # MEM USAGE / LIMIT shows 256MiB
docker inspect hello --format '{{.State.OOMKilled}} {{.State.ExitCode}}' # true 137 after an out-of-memory kill
- Memory: going over the limit gets the process killed by the kernel's OOM killer (Out Of Memory). The container exits with code 137.
- CPU: going over the limit doesn't kill anything β the container is slowed down ("throttled").
- Runtimes need to be told about the limit: Java 10+ reads it automatically;
Node.js may need
--max-old-space-size.
Try it: run python -c "x = bytearray(512 * 1024 * 1024)" in a container
with --memory 256m, then check the exit code with docker ps -a.
21. Graceful shutdownβ
In short: docker stop sends SIGTERM ("please stop") and waits 10
seconds before SIGKILL ("stop now"). Your app should catch SIGTERM, finish
current work, and exit.
# app.py β handle SIGTERM so "docker stop" is instant and clean
import signal
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
def stop(signum, frame):
print("SIGTERM received, shutting down", flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, stop)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
Two things make a container ignore SIGTERM:
- Your app is PID 1 (process id 1). Linux doesn't apply default signal
actions to PID 1, so an app without a handler never stops on SIGTERM. Add a
handler (above), or run with
docker run --init, which adds a tiny init process in front of your app. - Shell form
CMD python app.pymakes/bin/shPID 1, and the shell doesn't pass the signal on. Use the JSON form.
Try it: time docker stop on the practice app with and without the
handler (time docker stop hello).
22. Faster & multi-platform buildsβ
In short: BuildKit (Docker's modern builder) can keep a download cache between builds and build images for several CPU types at once.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt # pip's cache survives between builds
COPY . .
CMD ["python", "app.py"]
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/ada/hello:1.0 --push . # one tag, two CPU types (Intel and ARM)
docker build --secret id=npmrc,src=$HOME/.npmrc . # pass a secret to one RUN step without storing it
Macs with Apple chips and many cloud servers use ARM processors
(arm64); most others use Intel/AMD (amd64). An image built for one
can't run natively on the other, so publish both.
23. Debugging playbookβ
In short: work from the outside in β status, logs, settings, then inside the container.
| Symptom | First commands |
|---|---|
| Container exits immediately | docker ps -a (exit code) Β· docker logs <name> Β· run it with -it --entrypoint sh |
| Exit code 137 | Killed β out of memory (OOMKilled) or by docker stop |
| Exit code 1 / 2 | The app's own error β read the logs |
| Can't reach the app | docker ps (is the port published?) Β· is the app listening on 0.0.0.0? Β· docker exec <name> wget -qO- localhost:8000 |
| Containers can't talk to each other | docker network inspect <net> β same network? using the service name and container port? |
| Build fails | docker build --progress=plain --no-cache . to see every step's full output |
| No shell in the image (distroless) | docker debug <name> (Docker Desktop), or attach a debug container sharing its process namespace: docker run -it --pid container:<name> --network container:<name> busybox |
24. Words you'll meetβ
In short: the jargon, in one line each.
| Word | Meaning |
|---|---|
| Docker Engine / daemon | The background service that builds and runs containers; the docker command talks to it |
| OCI | Open Container Initiative β the standard image format, so images work with Docker, Podman, and Kubernetes |
| Base image | The image in your FROM line |
| Layer | One saved change to the image's files, shared between images that use it |
| Build context | The folder sent to the builder; COPY can only read from it |
| Digest | The sha256:β¦ fingerprint that names one exact image |
| Namespace / cgroup | Linux features that isolate what a container can see / limit what it can use |
| Dangling image | An image layer set with no tag, left over from rebuilding |
| Sidecar | A helper container running next to the main one (a log shipper, a proxy) |
| Podman | A Docker-compatible tool that runs containers without a background daemon |
| Container orchestrator | A system that runs containers across many machines β Kubernetes, ECS, Nomad |
For layering internals, networking under the hood, and interview questions, see the complete guide.