From "what is a container?" to writing lean, secure, production-grade images and multi-service stacks. Everything runs locally. Work top to bottom, or jump to a part.
The journey:
| You need | Why |
|---|---|
| Docker Engine or Docker Desktop installed | Everything here runs containers locally |
| A terminal | Every step is CLI |
| ~5GB free disk | Images, layers, and build cache add up |
No cloud account, registry login, or paid service is required. Confirm your install works before you start:
docker version # client + server both reported
docker run --rm hello-world # prints a success message, then exits
Docker packages an application and everything it needs (runtime, libraries, files) into a portable image, and runs it as an isolated container — a set of processes on the host kernel, fenced off by Linux namespaces (what it can see) and cgroups (what it can use).
Three words you must not confuse:
| Term | What it is |
|---|---|
| Image | A read-only, layered template (a class) |
| Container | A running (or stopped) instance of an image (an object) |
| Registry | A store of images (Docker Hub, GHCR, ECR) |
The value: the image runs the same on your laptop, CI, and production — "works on my machine" becomes "works everywhere," because the machine ships inside the image.
docker run hello-world # pulls + runs a tiny test image
docker run -d -p 8080:80 --name web nginx # run nginx detached, map host:container port
docker ps # running containers
docker logs web # its stdout/stderr
docker exec -it web sh # get a shell inside it
docker stop web && docker rm web # stop + remove
Key flags to internalize: -d (detached/background), -p host:container (publish a port), --name, -e KEY=val (env var), -it (interactive shell), --rm (auto-delete on exit).
An image is a stack of read-only layers; each Dockerfile instruction adds one. When you run a container, Docker adds a thin writable layer on top. Layers are cached and shared between images, which is why the order of instructions matters enormously for build speed.
docker images # list local images
docker pull python:3.12-slim
docker history python:3.12-slim # see the layers
The cache rule: Docker reuses a cached layer until an instruction (or a file it copies) changes; after that, every following layer rebuilds. So put the things that change least (dependency installs) before the things that change most (your source code).
A Dockerfile is the recipe for an image. Here's a well-ordered one for a Python app:
FROM python:3.12-slim # small, official base
WORKDIR /app
# Copy ONLY the dependency manifest first, so `pip install` is cached
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Now copy source (changes often — kept last so it doesn't bust the deps cache)
COPY . .
EXPOSE 8000
# Prefer exec form (JSON array) so signals reach your process for clean shutdown
CMD ["python", "app.py"]
Build and run:
docker build -t myapp:1.0 .
docker run --rm -p 8000:8000 myapp:1.0
Essential instructions: FROM (base), WORKDIR, COPY/ADD (prefer COPY), RUN (build-time commands), ENV, EXPOSE (documentation), ENTRYPOINT vs CMD (ENTRYPOINT = the fixed command, CMD = default args). Add a .dockerignore (like .gitignore) so junk and secrets never enter the build context.
Containers are ephemeral — delete one and its writable layer is gone. To persist or share data:
# Named volume (Docker-managed, best for databases)
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16
# Bind mount (host path -> container, great for live-editing code in dev)
docker run --rm -p 8000:8000 -v "$(pwd):/app" myapp:1.0
docker volume ls / docker volume rm manage them.
Containers on the same user-defined network reach each other by name via Docker's built-in DNS.
docker network create appnet
docker run -d --name db --network appnet postgres:16
docker run -d --name api --network appnet -e DB_HOST=db myapp:1.0
# 'api' can now connect to 'db:5432' — the name resolves automatically
-p 8080:80 publishes a container port to the host (north-south).bridge network does not provide name resolution — always create your own network (or use Compose, which does it for you).Real apps are several containers. Compose declares them in one file and wires networking/volumes automatically.
# compose.yaml
services:
api:
build: .
ports: ["8000:8000"]
environment:
DB_HOST: db
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
volumes:
pgdata:
docker compose up -d # build + start everything, one network, name-based DNS
docker compose logs -f api
docker compose down # stop + remove (add -v to also drop volumes)
depends_on + healthcheck ensures the API waits until the DB is actually ready, not just started.
Small images build faster, pull faster, and have a smaller attack surface. The trick is a multi-stage build: compile in a fat builder stage, then copy just the artifact into a tiny runtime stage.
# ---- build stage ----
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# ---- runtime stage (tiny) ----
FROM gcr.io/distroless/static
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The final image contains only the binary — no compiler, no shell, often under 20MB. For interpreted languages, use -slim or -alpine bases and copy only what you need. Check size with docker images and inspect layers with tools like dive.
Default containers run as root — a real risk. Harden them:
# Create and use a non-root user
RUN useradd -u 10001 -m appuser
USER 10001
Checklist:
USER), and drop capabilities at runtime (--cap-drop ALL).python:3.12.4-slim, or a digest) — never rely on latest.-e, secrets, env files), and use .dockerignore so .env/keys never enter the build.docker scout cves, Trivy, Grype) in CI.--read-only + tmpfs for writable paths).docker tag myapp:1.0 ghcr.io/you/myapp:1.0
docker login ghcr.io
docker push ghcr.io/you/myapp:1.0
Tagging discipline: tag with an immutable version or the git SHA (myapp:sha-ab12cd) for deploys, and move a :latest or :stable pointer separately. Deploying :latest is a classic source of "which version is actually running?" incidents. In CI, build with BuildKit (cache mounts, parallel stages), scan, then push on green.
| Symptom | Command / fix |
|---|---|
| Container exits immediately | docker logs <c>; check the CMD/ENTRYPOINT and exit code |
| "port is already allocated" | Another process holds the host port — change -p or free it |
| Build is slow every time | Reorder Dockerfile so deps install before COPY . .; add .dockerignore |
| Can't reach another container | Put both on a user-defined network; connect by name, not localhost |
| Image huge | Multi-stage build, slim base, fewer RUN layers, clean caches in the same layer |
| Disk full | docker system df then docker system prune (careful with -a/--volumes) |
| Inspect anything | docker inspect <c>, docker stats, docker exec -it <c> sh |
COPY . . before installing dependencies. Every source edit then busts the dependency cache and reinstalls everything. Copy the manifest and install first.:latest to production. You lose the answer to "what's actually running?" Tag with a version or the git SHA and move :latest separately..dockerignore.USER and drop capabilities.docker rm. Use a named volume for anything stateful.bridge network for multi-container apps. It has no name-based DNS — create a user-defined network (or use Compose).RUN apt-get update in a separate layer from install. A cached update layer serves stale package indexes. Chain them in one RUN.Work these hands-on — they cement the whole loop.
-slim base, multi-stage, --no-cache-dir, a .dockerignore.) Compare with docker images.requirements.txt and rebuild — what changed? Explain why from the cache rule.compose.yaml with an app container talking to Postgres by name, with a healthcheck gate. Bring it up, docker compose logs -f, then down -v.docker rm the container, start a fresh one on the same volume — verify the table survived.Self-check (answer without scrolling up):
ENTRYPOINT and CMD?You now have the full loop: write a Dockerfile → build a lean image → run and network containers → compose a stack → harden → ship. That's zero to hero.
Learned the concepts? Test yourself with Docker interview questions.
Go to the question bank →