Nothing matches that. Try a shorter word.
Start here
Three sections. The second one — images and layers — is the model that makes every later confusion evaporate, so don't skip it.
01 What Docker actually does
It packages an application with everything it needs to run, then starts it as an ordinary process that thinks it has the machine to itself.
"It works on my machine" is a dependency problem: your laptop has Node 20, Python 3.11 and a particular OpenSSL, and the server doesn't. Docker's answer is to ship the dependencies with the app as a single artefact — an image — and then run it in isolation so it can't collide with anything else on the host.
An image is a frozen, read-only snapshot of a filesystem plus a note saying which command to run. It's a class.
A container is that image, running. It's an instance. You can start twenty containers from one image and they don't interfere with each other.
A registry (Docker Hub, GHCR, ECR) is where images are stored and shared. It's npm for filesystems.
A container is not a virtual machine
This is the misconception worth killing on day one. A VM boots a whole second operating system on emulated hardware. A container is just a process on your existing kernel, wrapped in three Linux features that make it feel alone:
| Feature | What it hides | What you notice |
|---|---|---|
| namespaces | Gives the process its own view of process IDs, network interfaces, mounts, hostname and users. | Your app is PID 1 inside. ps in the container shows only its own processes. It has its own IP. |
| cgroups | Caps how much CPU, memory and I/O the process may use. | --memory=512m works. Exceeding it gets the container killed, not the host. |
| union filesystem | Stacks read-only image layers with one thin writable layer on top. | Twenty containers from one image cost one copy of the image on disk. See section 02. |
Because containers share the host kernel, a Linux container needs a Linux kernel. On macOS and Windows, Docker Desktop quietly runs a small Linux VM and your containers live inside that. This explains three things beginners find baffish: why bind-mounted file I/O is slower on a Mac, why localhost means something different in each place, and why a container built on an Apple Silicon Mac can fail on an x86 server with exec format error.
Your first two minutes
$ docker run hello-world # pulls an image, runs it, prints, exits $ docker run -it --rm alpine sh # a whole Linux userland, in about a second / # cat /etc/os-release NAME="Alpine Linux" # ...even if your host is Ubuntu or macOS / # ps PID USER COMMAND 1 root sh # your shell is PID 1. it sees nothing else. / # exit # --rm means the container is deleted on exit $ docker run -d -p 8080:80 --name web nginx $ curl localhost:8080 # a web server, no install, no config
docker run did four things in order: looked for the image locally, pulled it from Docker Hub because it wasn't there, created a container from it, and started it. The -d detached it to the background; -p 8080:80 forwarded your laptop's port 8080 to port 80 inside. Nothing was installed on your machine — docker rm -f web leaves no trace.
02 Images & layers
The model that explains build speed, image size, and why the order of your Dockerfile matters enormously.
An image isn't one blob. Each instruction in a Dockerfile creates a layer — a diff of the filesystem — and the image is those layers stacked. Layers are read-only, content-addressed, and shared: if ten images start FROM node:20-alpine, that base is stored once.
Three containers, one image. Disk cost is 332 MB plus three thin writable layers — not 996 MB.
The build cache, and the one rule that follows from it
When you rebuild, Docker walks your Dockerfile top to bottom and reuses a cached layer as long as the instruction and its inputs are unchanged. The moment one layer misses, every layer after it is rebuilt. That single fact dictates how you order a Dockerfile.
# SLOW — re-installs every dependency on any source change FROM node:20-alpine WORKDIR /app COPY . . # ← changes whenever ANY file changes... RUN npm ci # ← ...so this 90-second step never hits cache CMD ["node", "server.js"] # FAST — dependencies only reinstall when the lockfile changes FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ # ← rarely changes RUN npm ci # ← cached until the lockfile does COPY . . # ← cheap layer, changes constantly CMD ["node", "server.js"]
Order instructions from least-likely-to-change to most-likely-to-change. Base image, then system packages, then dependency manifests, then dependency install, then your source code last. A 90-second rebuild becomes 3 seconds, and it costs nothing but line order.
Deleting a file in a later layer doesn't shrink the image; it just marks it hidden. The bytes are still in the earlier layer, and anyone with the image can extract them. This is why the following is a real, common security bug:
RUN curl -H "Authorization: $TOKEN" ... && rm /root/.netrc # token is STILL in the layer
Cleanup only helps within a single RUN: RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* works because the install and the delete happen in one layer. For real secrets, use build secrets (section 14).
$ docker history myapp:1.0 IMAGE CREATED CREATED BY SIZE a1b2c3d4 2 minutes ago CMD ["node" "server.js"] 0B <missing> 2 minutes ago COPY . . # buildkit 12MB <missing> 5 minutes ago RUN npm ci # buildkit 180MB ← the big one <missing> 5 minutes ago COPY package*.json ./ 4.1kB <missing> 3 weeks ago /bin/sh -c #(nop) CMD ["node"] 0B $ docker image inspect myapp:1.0 --format '{{.RootFS.Layers}}' # the content hashes
A tag is a movable pointer, like a git branch. node:20 means something different this month than last. The immutable identifier is the digest: node:20@sha256:abc.... Use tags while developing; pin digests when a build must be reproducible. And latest is not special — it's just the tag applied when you don't name one.
03 The container lifecycle
Where a container goes when it stops — and why a stopped container is not a deleted one.
Beginners routinely think docker stop removes things. It doesn't. A stopped container still exists, still holds its writable layer, and still occupies its name — which is why docker run --name web a second time fails with "name is already in use" even though nothing is running.
The green path is what makes stopped containers useful — you can restart one and its files are still there. It's also what silently fills your disk.
$ docker run -d --name web nginx # create + start $ docker ps # RUNNING containers only $ docker stop web # SIGTERM, then SIGKILL after 10s $ docker ps # empty — but the container still exists $ docker ps -a # -a = ALL, including exited. here it is. CONTAINER ID IMAGE STATUS NAMES 9f2c8ad1c41d nginx Exited (0) 12 seconds ago web $ docker start web # same container, same files, back up $ docker rm -f web # -f stops it first. NOW it's gone. $ docker container prune # delete every stopped container $ docker system df # what Docker is using your disk for
| Exit code | Meaning |
|---|---|
| 0 | Clean exit. For a server, this usually means the process finished when you expected it to keep running. |
| 1 / 2 | The application itself errored. Read docker logs. |
| 125 | The docker run command itself was wrong — a bad flag. |
| 126 | The command was found but isn't executable. Missing chmod +x. |
| 127 | Command not found inside the container. Usually a typo, or it isn't in that base image. |
| 137 | SIGKILL — almost always out of memory. Raise --memory or fix the leak. |
| 143 | SIGTERM. A normal docker stop. |
There is no "container process" separate from your app. When the command in CMD exits, the container exits — which is why docker run ubuntu stops instantly (bash with no terminal has nothing to do) and why you cannot "start a container and then run things in it" as a two-step. Give it a long-running process, or use -it to hand it a terminal.
The eight things you'll do every day
Running, inspecting, building, storing, connecting. Every section has a copyable example that works as written.
04 docker run
One command with fifty flags. These twelve cover almost everything you'll ever type.
docker run [FLAGS] IMAGE [COMMAND] [ARGS...]
└─ for docker └─ what to run instead of the image's CMD
$ docker run -d --name api -p 3000:3000 -e NODE_ENV=production myapp:1.0
$ docker run --rm -it node:20-alpine node --version # override the CMD
| Flag | What it does | Notes |
|---|---|---|
| -d | Detach — run in the background and print the container ID. | Without it your terminal is attached to the container's output. |
| -it | Interactive + TTY. Gives you a usable shell. | Two flags: -i keeps stdin open, -t allocates a terminal. Always used together. |
| --rm | Delete the container when it exits. | Use it for every throwaway command. Prevents the slow accumulation of dead containers. |
| --name X | A stable name instead of boring_einstein. | Also the DNS hostname other containers use on a user-defined network. |
| -p 8080:80 | Publish: host port 8080 → container port 80. | host:container, in that order. -p 80 picks a random host port. -p 127.0.0.1:8080:80 binds to localhost only — do this for databases. |
| -v name:/path | Mount a named volume for data that must survive. | See section 09. -v ./src:/app/src instead bind-mounts a host directory. |
| -e KEY=value | Set an environment variable. | --env-file .env for many. Never pass real secrets this way in production — they show in docker inspect. |
| --network X | Attach to a specific network. | Required for container-to-container DNS. See section 10. |
| --restart unless-stopped | Restart automatically after a crash or a reboot. | Options: no (default), on-failure[:n], always, unless-stopped. The last is usually what you want. |
| --memory 512m --cpus 1.5 | Hard resource caps. | Exceeding memory gets you exit code 137. See section 13. |
| -u 1000:1000 | Run as a specific uid:gid instead of root. | Fixes most bind-mount permission problems on Linux. |
| -w /app | Working directory inside the container. | Overrides the image's WORKDIR. |
$ docker run -d \
--name api \ # addressable as "api" by other containers
--network appnet \ # a user-defined network, so DNS works
-p 127.0.0.1:3000:3000 \ # only reachable from this machine
-e NODE_ENV=production \
--env-file .env \ # the rest of the config
-v applogs:/var/log/app \ # named volume — survives rm
--restart unless-stopped \ # comes back after a reboot
--memory 512m --cpus 1 \ # can't take the host down
myapp:1.0
- Port order. -p 3000:8080 means "host 3000 → container 8080". Reversing it produces a port that answers nothing. Host first, always.
- Your app must bind 0.0.0.0, not 127.0.0.1. A server listening on localhost inside the container is listening on the container's own loopback, and -p can never reach it. This is the single most common "the port is published but I get connection refused".
- -e after the image name is not a flag. Everything after the image is the container's command. docker run myapp -e FOO=1 passes -e FOO=1 to your app.
05 Inspecting & debugging
Six commands answer nearly every "why isn't this working".
| Command | Answers |
|---|---|
| docker ps -a | What exists and what state it's in. -a is the important part — without it you can't see the container that just died. |
| docker logs -f --tail 100 X | What the app printed. This is stdout/stderr of PID 1 only — an app logging to a file inside the container shows nothing here. |
| docker exec -it X sh | A shell inside a running container. Use bash if the image has it, sh on Alpine. |
| docker inspect X | Everything: mounts, networks, env, the resolved command, exit code, health. Verbose — filter it. |
| docker stats | Live CPU, memory and network per container. Memory climbing toward the limit explains an incoming exit 137. |
| docker events | A live feed of every create/start/die/kill. Run it in a second terminal while reproducing a problem. |
$ docker inspect api --format '{{.State.Status}} {{.State.ExitCode}}'
exited 137 # OOM killed
$ docker inspect api --format '{{json .Mounts}}' | jq # where is data going?
$ docker inspect api --format '{{.NetworkSettings.IPAddress}}'
$ docker inspect api --format '{{range .Config.Env}}{{println .}}{{end}}'
$ docker inspect api --format '{{.State.Health.Status}}' # if a HEALTHCHECK exists
# which host port did Docker pick?
$ docker port api
3000/tcp -> 127.0.0.1:3000
# 1. It exited. What did it say on the way out? $ docker logs api # 2. Nothing useful? Start it with a shell INSTEAD of its normal command. $ docker run --rm -it --entrypoint sh myapp:1.0 /app # ls -la # are the files where you think? /app # cat package.json /app # node server.js # run it by hand and watch it fail properly # 3. Image has no shell (distroless/scratch)? Attach one. $ docker run --rm -it --pid=container:api --network=container:api \ nicolaka/netshoot # 4. Copy a file out of a dead container to look at it $ docker cp api:/app/config.json ./config.json # 5. What changed in the container's filesystem since it started? $ docker diff api C /app A /app/tmp/upload-9f2c.tmp
docker run creates a new container from an image. docker exec runs a command in an existing, running one. If a container has exited, exec cannot help you — that's when you reach for --entrypoint sh on a fresh container, as above.
06 Images & registries
Building, naming, sharing — and reclaiming the 40 GB Docker quietly took.
ghcr.io/acme/myapp:1.4.2 └──┬──┘ └─┬─┘ └─┬─┘ └─┬─┘ │ │ │ └── tag — a movable pointer, NOT a version guarantee │ │ └──────── image name │ └────────────── namespace / user / org └───────────────────── registry. omitted = docker.io (Docker Hub) nginx → docker.io/library/nginx:latest acme/myapp:1.0 → docker.io/acme/myapp:1.0 ghcr.io/acme/myapp:1.0 → GitHub Container Registry myapp@sha256:9f2c8a... → a digest. immutable. the only truly reproducible form.
$ docker build -t myapp:1.0 . # -t tags it; "." is the BUILD CONTEXT $ docker build -t myapp:1.0 -f docker/Dockerfile.prod . $ docker images # what's local $ docker pull postgres:16-alpine # fetch without running # tag = add another name to the SAME image. it doesn't copy anything. $ docker tag myapp:1.0 ghcr.io/acme/myapp:1.0 $ docker login ghcr.io $ docker push ghcr.io/acme/myapp:1.0 $ docker rmi myapp:1.0 # remove one image $ docker save myapp:1.0 | gzip > myapp.tgz # move an image without a registry $ gunzip -c myapp.tgz | docker load
$ docker system df # what is actually using space TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 47 4 23.8GB 19.2GB (80%) Containers 31 2 1.1GB 1.09GB (99%) Local Volumes 12 3 8.4GB 6.2GB (73%) Build Cache 284 0 11.3GB 11.3GB $ docker system prune # stopped containers, unused networks, dangling images $ docker system prune -a # ALSO every image not used by a container $ docker system prune -a --volumes # ...and volumes. THIS DELETES DATA. $ docker builder prune # just the build cache — usually the biggest win
The . at the end of docker build -t myapp . is not decoration. It's the directory Docker uploads to the daemon before building. Run it in a folder containing node_modules, .git and a few build artefacts and you'll wait thirty seconds for "sending build context to Docker daemon 1.4GB" before a single instruction executes.
The fix is a .dockerignore file, which works exactly like .gitignore and is the cheapest build speedup available:
node_modules .git dist *.log .env **/__pycache__ Dockerfile .dockerignore
It also protects you: without it, COPY . . happily bakes your .env and .git history into the image.
The <none>:<none> entries in docker images are old layers that lost their tag when you rebuilt with the same name. They're harmless but accumulate fast: docker image prune clears them.
07 The Dockerfile
A recipe read top to bottom. Every line is a step, and most of them become a layer.
# syntax=docker/dockerfile:1 # opt into the latest BuildKit features FROM node:20-alpine # the base. every Dockerfile starts here. WORKDIR /app # cd, and mkdir -p if it doesn't exist. # use this, never "RUN cd /app" — cd doesn't persist RUN apk add --no-cache tini # system packages. one RUN = one layer. COPY package.json package-lock.json ./ # dependency manifests FIRST — see section 02 RUN npm ci --omit=dev COPY . . # source last: the layer that always changes ENV NODE_ENV=production \ # available at build AND run time PORT=3000 EXPOSE 3000 # DOCUMENTATION ONLY. does not publish a port. USER node # drop root. everything after runs as `node`. HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/health || exit 1 ENTRYPOINT ["/sbin/tini", "--", "node"] # the executable CMD ["server.js"] # its default argument, overridable
RUN vs CMD vs ENTRYPOINT
These three get confused more than anything else in Docker, and they do completely unrelated jobs.
| When it runs | How many | Purpose | |
|---|---|---|---|
| RUN | Build time. Its result is baked into a layer. | As many as you like | Install packages, compile, create directories. |
| CMD | Run time. The default command, replaced by anything you type after the image name. | Only the last one counts | The default thing this image does. |
| ENTRYPOINT | Run time. The fixed executable. Arguments get appended to it. | Only the last one counts | Make the image behave like a single binary. |
ENTRYPOINT ["node"] CMD ["server.js"] $ docker run myapp → node server.js $ docker run myapp --inspect → node --inspect # CMD replaced $ docker run --entrypoint sh myapp → sh # ENTRYPOINT overridden # CMD alone — simplest, fully overridable. fine for most apps. CMD ["node", "server.js"] $ docker run myapp bash → bash # ENTRYPOINT alone — the image IS the tool. ENTRYPOINT ["curl"] $ docker run mycurl https://example.com → curl https://example.com
Both instructions accept two forms, and they are not equivalent:
CMD node server.js # SHELL form → runs /bin/sh -c "node server.js" CMD ["node", "server.js"] # EXEC form → runs node directly
In shell form, PID 1 is sh, not your app. docker stop sends SIGTERM to PID 1, sh doesn't forward it, your app never hears it, and ten seconds later everything is SIGKILLed — dropping in-flight requests on every single deploy. Always use the exec form (JSON array, double quotes).
Exec form has one catch: no shell means no variable expansion or pipes. CMD ["echo", "$HOME"] prints a literal $HOME. If you genuinely need a shell, be explicit: CMD ["sh", "-c", "exec node server.js"] — the exec makes node replace the shell as PID 1.
ARG vs ENV
ARG NODE_VERSION=20 # BUILD time only. gone at runtime. FROM node:${NODE_VERSION}-alpine ARG BUILD_ID # must be re-declared after FROM to be usable ENV APP_VERSION=${BUILD_ID} # promote it into the image if the app needs it ENV LOG_LEVEL=info # BUILD and RUN time. overridable with -e. # $ docker build --build-arg BUILD_ID=abc123 -t myapp . # $ docker run -e LOG_LEVEL=debug myapp ← ENV can be overridden, ARG cannot
Both are stored in the image metadata in plaintext. docker history shows every ARG value, and docker inspect shows every ENV — to anyone who can pull the image. For build-time credentials use BuildKit secrets (section 14); for runtime credentials inject them at docker run time from a secret manager.
COPY vs ADD
Use COPY. It copies files, and that's all it does — which is what you want and what a reviewer can verify at a glance.
ADD also fetches URLs and silently auto-extracts local tar archives, which surprises people and makes builds harder to reason about. Its one legitimate use is unpacking a tarball you already have. To fetch a remote file, use RUN curl so you can verify a checksum in the same layer.
COPY src/ /app/src/ # trailing slash = directory COPY --chown=node:node . /app # set ownership as you copy — saves a RUN chown layer COPY --from=builder /app/dist ./dist # from another BUILD STAGE — see section 08 COPY --from=nginx:alpine /etc/nginx/nginx.conf ./ # or straight out of another image
| Base | Size | Use when |
|---|---|---|
| node:20 | ~1.1 GB | You need a full Debian toolchain. Fine for a build stage, wasteful for the final image. |
| node:20-slim | ~200 MB | Sensible default. Debian, glibc, most things work. |
| node:20-alpine | ~140 MB | Small and popular — but it uses musl libc, not glibc. Native modules may need rebuilding and some prebuilt binaries simply won't run. |
| gcr.io/distroless/nodejs20 | ~110 MB | No shell, no package manager, minimal attack surface. Excellent for production, painful to debug. |
| scratch | 0 B | Completely empty. Only for a static binary — Go, Rust — with no libc at all. |
Start with -slim. Move to alpine or distroless when image size actually becomes a problem you can measure.
08 Multi-stage builds
Build with a full toolchain, ship only the result. Usually a 10× size reduction for one extra line.
Compilers, dev dependencies, test fixtures and build caches are needed to produce your application and are dead weight — and extra attack surface — in the thing you deploy. A multi-stage build uses several FROM statements: earlier stages do the work, and the final stage copies out only the artefacts.
No compiler in the shipped image means nothing to compile an exploit with, and 900 MB less to pull on every deploy.
# syntax=docker/dockerfile:1 # ---------- stage 1: install + build ---------- FROM node:20 AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci # ALL deps, including dev — we need them to build COPY . . RUN npm run build # produces /app/dist RUN npm prune --omit=dev # strip devDependencies from node_modules # ---------- stage 2: the image that ships ---------- FROM node:20-alpine AS final WORKDIR /app ENV NODE_ENV=production COPY --from=builder --chown=node:node /app/node_modules ./node_modules COPY --from=builder --chown=node:node /app/dist ./dist COPY --from=builder --chown=node:node /app/package.json ./ USER node EXPOSE 3000 CMD ["node", "dist/server.js"]
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/app ./cmd/server
# ↑ static binary, no libc needed at all
FROM scratch # literally empty
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/app /app
USER 65534:65534 # nobody
ENTRYPOINT ["/app"]
# final image: ~8 MB, containing one binary and a CA bundle. Nothing to exploit.
$ docker build --target builder -t myapp:dev . # stop at a stage — great for CI test steps # stages can be a test gate: if this fails, the build fails FROM builder AS test RUN npm run lint && npm test # stages build in parallel when they don't depend on each other (BuildKit)
Only the last stage becomes the image unless you pass --target. And COPY --from copies files, not environment: ENV, WORKDIR and USER from the builder do not carry over — re-declare them in the final stage. Alpine and Debian also differ in libc, so a binary compiled in a node:20 builder can fail in an alpine final stage with "not found" even though the file is clearly there. Keep both stages on the same distro family, or build statically.
09 Volumes & data
A container's filesystem dies with it. Anything you want to keep has to live outside.
Everything a container writes goes into its thin writable layer, and docker rm deletes that layer. Run Postgres without a volume and every row vanishes the moment you recreate the container — which you do on every image update. There are three ways to keep data, and picking the right one is mostly about who owns the files.
Same syntax, three very different lifetimes. Named volumes for data you'd cry about losing; bind mounts for code you're editing.
# named volume — Docker manages the location. the default choice for data. $ docker volume create pgdata $ docker run -d --name db -v pgdata:/var/lib/postgresql/data \ -e POSTGRES_PASSWORD=secret postgres:16-alpine $ docker rm -f db # container gone $ docker run -d --name db -v pgdata:/var/lib/postgresql/data ... postgres:16-alpine # ...and every row is still there # bind mount — live editing. no rebuild between saves. $ docker run -d --name web \ -v "$(pwd)/src:/app/src" \ # absolute path required -v "$(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro" \ # :ro = read-only -p 3000:3000 myapp:dev # tmpfs — in RAM, never written to disk $ docker run --tmpfs /tmp:rw,size=64m myapp # managing volumes $ docker volume ls $ docker volume inspect pgdata # where is it really? $ docker volume rm pgdata # refuses if a container uses it $ docker volume prune # delete every unused volume. DESTROYS DATA.
# there is no `docker volume backup`. you mount it into a throwaway container. $ docker run --rm -v pgdata:/data -v "$(pwd)":/backup alpine \ tar czf /backup/pgdata-$(date +%F).tgz -C /data . # restore $ docker run --rm -v pgdata:/data -v "$(pwd)":/backup alpine \ sh -c "rm -rf /data/* && tar xzf /backup/pgdata-2026-08-31.tgz -C /data" # for a database, prefer its own tool — it knows about consistency: $ docker exec db pg_dump -U postgres app > dump.sql
- A mount hides what was underneath. Bind-mounting ./src onto /app/src replaces the image's copy entirely. Mount over /app and you hide node_modules too — which is why the classic dev setup adds an anonymous volume to shield it: -v $(pwd):/app -v /app/node_modules.
- Permission denied on Linux. The container's uid must be able to write the host directory. Either chown the host folder to that uid, or run with -u $(id -u):$(id -g). On macOS and Windows, Docker Desktop translates ownership for you, which is why this bug only appears once you deploy.
- Relative paths don't work with -v. -v ./src:/app historically created a named volume called ./src rather than mounting. Use $(pwd)/src, or the clearer --mount type=bind,source=...,target=....
- Named volumes are seeded once. On first use, an empty named volume is populated from the image's contents at that path. After that it's never refreshed — so a new image with updated files won't appear until you delete the volume.
10 Networking
Why containers can't find each other, why localhost lies, and the one command that fixes both.
Each container gets its own network namespace: its own interfaces, its own IP, its own localhost. That last point is the source of most confusion — localhost inside a container means that container, not your laptop and not the container next to it.
Only api is published. db is reachable by name from inside the network and from nowhere else — which is exactly what you want for a database.
| Network | What it does | Container-to-container DNS |
|---|---|---|
| bridge (default) | The network containers join when you don't say otherwise. | No. Containers can only reach each other by IP. |
| user-defined bridge | One you create with docker network create. | Yes — by container name. This is why you should always create one. |
| host | No isolation; the container uses the host's network stack directly. -p is ignored. | n/a — Linux only, and it gives up isolation. |
| none | No networking at all. | n/a — for batch jobs that must not phone home. |
$ docker network create appnet
$ docker run -d --name db --network appnet \
-e POSTGRES_PASSWORD=secret postgres:16-alpine
# NOTE: no -p. nothing outside the network can reach it.
$ docker run -d --name api --network appnet -p 8080:3000 \
-e DATABASE_URL=postgres://postgres:secret@db:5432/app \
myapp:1.0 # ↑ the container NAME
$ docker exec api ping -c1 db # resolves. this is the payoff.
# inspect and manage
$ docker network ls
$ docker network inspect appnet # which containers, which IPs
$ docker network connect appnet other # attach a running container to a second network
| Question | Answer |
|---|---|
| "Connection refused, but the port is published" | Your app is bound to 127.0.0.1 inside the container. Bind 0.0.0.0 instead. Check with docker exec X netstat -tlnp. |
| "api can't find db" | They're on the default bridge, which has no DNS. Create a network and put both on it. |
| "How do I reach the host from inside?" | host.docker.internal on Docker Desktop. On Linux add --add-host=host.docker.internal:host-gateway. |
| "Port is already allocated" | Something else holds that host port — often an old container. docker ps -a, then lsof -i :8080. Change the host side: -p 8081:3000. |
EXPOSE 3000 in a Dockerfile is metadata — a note to a human reading docker inspect. It publishes nothing. Only -p at run time (or ports: in Compose) makes a port reachable from the host. Its one functional use is docker run -P, which publishes every EXPOSEd port on random host ports.
11 Docker Compose
Those five docker run commands, written down once and run with two words.
Compose takes the flags from section 04 and puts them in a YAML file. It creates a network for you automatically, so DNS between services works with no setup, and it starts and stops the whole stack as a unit. For local development it's the only sane way to work.
# No "version:" key — it's obsolete in Compose v2 and prints a warning. name: myapp services: api: build: context: . dockerfile: Dockerfile target: final # which multi-stage target image: myapp:1.0 # name the built image ports: - "8080:3000" # quote it — 8080:3000 can parse as a sexagesimal number environment: DATABASE_URL: postgres://postgres:secret@db:5432/app NODE_ENV: production env_file: [.env] # everything else depends_on: db: condition: service_healthy # wait for READY, not just started restart: unless-stopped healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] interval: 30s timeout: 3s retries: 3 start_period: 10s db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret POSTGRES_DB: app volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 10 # no ports: — reachable only from inside the stack cache: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] volumes: - redisdata:/data volumes: # named volumes must be declared here pgdata: redisdata:
$ docker compose up -d # build if needed, create network, start everything $ docker compose up --build # force a rebuild $ docker compose ps # status of the stack $ docker compose logs -f api # one service; omit the name for all $ docker compose exec api sh # shell into a running service $ docker compose run --rm api npm test # one-off task in a NEW container $ docker compose restart api $ docker compose down # stop + remove containers and the network $ docker compose down -v # ...and the volumes. DELETES DATA. $ docker compose config # the fully resolved file — great for debugging # note: `docker compose` (v2 plugin, current) not `docker-compose` (v1, python, EOL)
# compose.yaml is loaded, then compose.override.yaml automatically on top. # compose.override.yaml — dev only, git-ignored or committed as the dev default services: api: build: target: builder # dev stage, with dev dependencies volumes: - ./src:/app/src # live reload environment: NODE_ENV: development command: ["npm", "run", "dev"] # explicit composition for prod: $ docker compose -f compose.yaml -f compose.prod.yaml up -d
- depends_on alone does not wait for readiness. By default it only orders startup — your API still connects to a Postgres that hasn't finished initialising and crashes. You need condition: service_healthy plus a real healthcheck on the dependency, as above. Even then, make your app retry its database connection; that's the durable fix.
- Compose prefixes everything with the project name — the directory name unless you set name:. Two projects in folders both called app will collide over containers and volumes.
- docker compose down -v deletes your database. It's one character away from the harmless version.
- Environment precedence runs: shell > environment: > env_file > Dockerfile ENV. A stale variable exported in your shell silently wins over the file, which is a genuinely maddening hour to debug. docker compose config shows the truth.
Before it goes anywhere real
Everything above gets an app running. This part is what separates a demo from something you'd put on the internet.
12 Security & hardening
Six changes, none of them hard, that remove most of the risk from a container image.
Unless you say otherwise, PID 1 inside your container is root — and it's the same root as the host's, just namespaced. Combine that with a bind-mounted host directory or a container-escape bug and root inside becomes root outside. Adding one USER line removes an entire class of problem.
FROM node:20-alpine # many official images already ship a non-root user. node's is `node`. # if yours doesn't, make one: RUN addgroup -S app && adduser -S -G app app WORKDIR /app COPY --chown=app:app . . RUN npm ci --omit=dev USER app # everything after this — including CMD — runs as app CMD ["node", "server.js"] # verify it took: # $ docker run --rm myapp id # uid=101(app) gid=102(app)
$ docker run -d \
--user 1000:1000 \ # override the image's user
--read-only \ # filesystem is immutable...
--tmpfs /tmp:rw,size=64m \ # ...except where the app genuinely writes
--cap-drop ALL \ # drop every Linux capability
--cap-add NET_BIND_SERVICE \ # add back only what's needed (port < 1024)
--security-opt no-new-privileges \ # block setuid escalation
--memory 512m --pids-limit 200 \ # blast radius
myapp:1.0
# NEVER do these unless you know exactly why:
# --privileged disables essentially all isolation
# -v /var/run/docker.sock:/var/... container can control the daemon = root on host
# -v /:/host the entire host filesystem
| Do this | Because |
|---|---|
| Pin base images by digest | FROM node:20-alpine@sha256:abc... — a tag can be re-pointed at any time, including at something malicious. Digests can't. |
| Use the smallest base you can debug | Fewer packages means fewer CVEs. -slim over full, distroless over alpine if you can live without a shell. |
| Scan images in CI | docker scout cves myapp:1.0, or trivy image myapp:1.0. Fail the build on HIGH and CRITICAL. |
| Never bake secrets | ARG, ENV and deleted files all persist in layers. Use BuildKit secrets at build time and injected env at run time. |
| Add a .dockerignore | Stops COPY . . baking in .env, .git and SSH keys. This leaks credentials constantly in the real world. |
| Rebuild regularly | An image built six months ago has six months of unpatched base-image CVEs. Rebuild on a schedule, not only on code change. |
# syntax=docker/dockerfile:1 FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ # the secret is mounted for THIS COMMAND ONLY and never enters a layer RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ci --omit=dev # $ docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp . # prove it: `docker history myapp` and `docker run --rm myapp cat /root/.npmrc` # both come up empty.
13 Limits, health & logs
Keeping one container from taking down the machine, and knowing when it's sick.
By default a container can use all the host's CPU and memory. One leaking process is enough to take down everything else on the box, including the Docker daemon. Limits are cheap insurance.
| Flag | Effect when exceeded |
|---|---|
| --memory 512m | Hard cap. The kernel OOM-kills the process: exit code 137, no warning, no log from your app. Always set this. |
| --memory-reservation 256m | Soft target — reclaimed under host pressure but not enforced. Pairs with the hard cap. |
| --cpus 1.5 | Throttled, not killed. The app just gets slower — silently, which makes it hard to spot. |
| --pids-limit 200 | Caps process count. Stops a fork bomb, accidental or otherwise. |
| --restart unless-stopped | Restarts on crash and after a host reboot, but respects a manual docker stop. |
HEALTHCHECK --interval=30s \ # how often --timeout=3s \ # fail if the check itself hangs --start-period=20s \ # grace while booting; failures here don't count --retries=3 \ # consecutive failures before "unhealthy" CMD wget -qO- http://localhost:3000/health || exit 1 # exit 0 = healthy, exit 1 = unhealthy. that's the whole contract.
$ docker ps
CONTAINER ID IMAGE STATUS PORTS
9f2c8ad1c41d myapp:1.0 Up 2 minutes (healthy) 0.0.0.0:8080->3000/tcp
$ docker inspect api --format '{{json .State.Health}}' | jq
$ docker stats --no-stream
NAME CPU % MEM USAGE / LIMIT MEM % NET I/O
api 0.51% 142MiB / 512MiB 27.7% 1.2MB / 840kB
A plain Docker container marked unhealthy just sits there being unhealthy. --restart reacts to the process exiting, not to a failing health check. Orchestrators (Compose depends_on, Swarm, Kubernetes) are what actually respond to health. Locally, the value of a HEALTHCHECK is visibility and dependency ordering.
The default json-file driver has no rotation. A chatty container will happily write 40 GB to /var/lib/docker/containers/ and take the host down. Fix it globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Then systemctl restart docker. Existing containers keep their old setting until recreated.
Write logs to stdout and stderr, never to a file inside the container. docker logs, and every log shipper ever built, read exactly those two streams. A log file inside a container is invisible and disappears with it — official images symlink their log paths to /dev/stdout for precisely this reason.
14 Build performance
Getting a two-minute build down to five seconds.
| Technique | Typical saving |
|---|---|
| Order layers least→most volatile | Enormous. The single highest-value change — see section 02. |
| A real .dockerignore | Seconds to minutes of context upload, plus avoids spurious cache misses. |
| Cache mounts for package managers | Keeps the npm/pip/apt cache between builds without putting it in a layer. |
| Multi-stage + --target | Parallel stages, and CI can stop early at a test stage. |
| Registry cache in CI | Turns a cold CI runner into a warm one. |
# syntax=docker/dockerfile:1 FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ # the npm cache persists across builds but is NOT part of the image RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev # same idea elsewhere: # pip → --mount=type=cache,target=/root/.cache/pip # go → --mount=type=cache,target=/root/.cache/go-build # apt → --mount=type=cache,target=/var/cache/apt
# push the build cache to the registry so the next CI run starts warm $ docker buildx build \ --cache-from type=registry,ref=ghcr.io/acme/myapp:buildcache \ --cache-to type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max \ -t ghcr.io/acme/myapp:1.0 --push . # one image that runs on both Apple Silicon and x86 servers $ docker buildx create --use $ docker buildx build --platform linux/amd64,linux/arm64 \ -t ghcr.io/acme/myapp:1.0 --push . # what did the cache actually do? $ docker build --progress=plain . # "CACHED" on each reused step $ docker build --no-cache . # prove a cold build still works
Building on an M-series Mac produces a linux/arm64 image by default. Deploy it to a normal x86 server and every container dies instantly with exec format error. Either build multi-arch as above, or force the target: docker build --platform linux/amd64 .... Emulated cross-builds are correct but slow — often 5–10× — so build natively in CI where you can.
Look things up
The part you come back to. Commands, instructions, error messages, vocabulary.
Command reference
Everything you'll type, grouped by what you're trying to do.
Containers
| Command | Does |
|---|---|
| docker run IMAGE | Create and start a new container. |
| docker ps / ps -a | Running containers / all containers including exited. |
| docker start|stop|restart X | Control an existing container. stop = SIGTERM then SIGKILL after 10s. |
| docker kill X | Immediate SIGKILL. No graceful shutdown. |
| docker rm X / rm -f X | Delete a stopped container / force-delete a running one. |
| docker exec -it X sh | Run a command in a running container. |
| docker logs -f --tail 100 X | stdout/stderr of PID 1. --since 10m to narrow. |
| docker cp X:/path ./path | Copy files in or out. Works on stopped containers too. |
| docker inspect X | Full JSON state. Use --format to extract one field. |
| docker stats / top X | Live resource usage / processes inside. |
| docker port X | Which host ports map where. |
| docker diff X | Files changed since the container started. |
| docker container prune | Delete every stopped container. |
Images
| Command | Does |
|---|---|
| docker build -t name:tag . | Build from the Dockerfile in the context directory. |
| docker images | Local images. -f dangling=true for untagged leftovers. |
| docker pull / push | Fetch from or send to a registry. |
| docker tag src target | Add another name to the same image. Copies nothing. |
| docker rmi name:tag | Remove an image. |
| docker history name:tag | Layers, sizes and the instruction that made each one. |
| docker save / load | Export to a tarball / import — moving images without a registry. |
| docker image prune -a | Delete every image not used by a container. |
| docker scout cves name:tag | Vulnerability scan. |
Volumes, networks, system
| Command | Does |
|---|---|
| docker volume ls / inspect / rm | Manage named volumes. |
| docker volume prune | Delete unused volumes. Destroys data. |
| docker network create NAME | Create a user-defined bridge — enables DNS by container name. |
| docker network ls / inspect | List networks / see which containers are attached. |
| docker network connect NET X | Attach a running container to another network. |
| docker system df | Disk usage by images, containers, volumes and build cache. |
| docker system prune -a | Reclaim almost everything unused. Add --volumes only if you mean it. |
| docker builder prune | Clear the build cache — usually the largest single win. |
| docker events | Live feed of daemon events. |
| docker info / version | Daemon config and versions. First thing to check when nothing works. |
Compose
| Command | Does |
|---|---|
| docker compose up -d | Start the whole stack in the background. |
| docker compose up --build | Rebuild images first. |
| docker compose down / down -v | Stop and remove / also delete volumes. |
| docker compose ps / logs -f | Status / streaming logs. |
| docker compose exec SVC sh | Shell into a running service. |
| docker compose run --rm SVC CMD | One-off command in a fresh container. |
| docker compose config | The fully merged, resolved file. Debug env problems here. |
| docker compose pull | Update all images without rebuilding. |
Dockerfile instruction reference
Every instruction you're likely to need, and whether it creates a layer.
| Instruction | What it does | Layer |
|---|---|---|
| FROM image AS name | Base image and the start of a stage. Required first (after ARG/comments). | yes |
| RUN cmd | Execute at build time. Combine related commands with && to keep layers few and cleanup effective. | yes |
| COPY src dst | Copy from context or another stage. --from, --chown, --chmod. | yes |
| ADD src dst | Like COPY, plus URL fetch and tar auto-extract. Prefer COPY. | yes |
| WORKDIR /path | Set the working directory, creating it if needed. Use instead of RUN cd. | metadata |
| ENV K=V | Environment variable at build and run time. Visible in docker inspect. | metadata |
| ARG K=default | Build-time only variable. Set with --build-arg. Visible in docker history. | metadata |
| CMD ["exe","arg"] | Default command, overridden by args after the image name. | metadata |
| ENTRYPOINT ["exe"] | Fixed executable; CMD becomes its arguments. | metadata |
| EXPOSE 3000 | Documentation only. Does not publish. | metadata |
| USER name | Run subsequent instructions and the final command as this user. | metadata |
| VOLUME /data | Declares a path as a mount point; an anonymous volume is created if none is supplied. Often better left to run time. | metadata |
| HEALTHCHECK CMD ... | How to test the container is working. | metadata |
| LABEL k=v | Image metadata. Use org.opencontainers.image.* keys for source, version, licence. | metadata |
| STOPSIGNAL SIGTERM | Which signal docker stop sends. | metadata |
| SHELL ["pwsh","-c"] | Change the shell used by shell-form RUN/CMD. | metadata |
| ONBUILD ... | Trigger an instruction in any image built FROM this one. Surprising; avoid. | metadata |
Troubleshooting by error message
Find your error text. Nearly all of these have one specific cause.
1. docker ps -a — what state is it in, and what exit code? 2. docker logs X — what did it say on the way out? 3. docker run --rm -it --entrypoint sh IMAGE — get inside and run it by hand.
| Error / symptom | What's happening | Fix |
|---|---|---|
| Cannot connect to the Docker daemon | The daemon isn't running, or your user isn't in the docker group. | Start Docker Desktop, or sudo systemctl start docker. On Linux: sudo usermod -aG docker $USER, then log out and back in. |
| port is already allocated | Another process — often a container you forgot — holds that host port. | docker ps -a, then lsof -i :8080. Or just publish a different host port: -p 8081:3000. |
| connection refused (but the port is published) | Your app is listening on 127.0.0.1 inside the container, which -p cannot reach. | Bind 0.0.0.0. Verify with docker exec X netstat -tlnp. |
| exec format error | Architecture mismatch — an arm64 image on an amd64 host, or vice versa. | docker build --platform linux/amd64, or build multi-arch with buildx. |
| exec ./entrypoint.sh: no such file or directory (and the file is clearly there) | Two classic causes: Windows CRLF line endings in the script, or a glibc binary in an Alpine (musl) image. | dos2unix entrypoint.sh, or match the builder and final stage to the same distro family. |
| permission denied on a mounted volume | The container's uid can't write the host directory. Linux only — Docker Desktop hides this. | -u $(id -u):$(id -g), or chown the host folder to the container's uid. |
| Container exits immediately, code 0 | PID 1 finished. Nothing is wrong — there was just nothing to keep running. | Give it a long-running process, or -it for an interactive shell. |
| Exit code 137 | SIGKILL — almost always the memory limit. | Raise --memory, or fix the leak. docker stats while it climbs. |
| Exit code 127 | Command not found inside the container. | Typo, or the binary isn't in that base image. Alpine has no bash — use sh. |
| COPY failed: file not found in build context | The path is outside the context directory, or excluded by .dockerignore. | Paths are relative to the context (the .), never to the Dockerfile. You cannot COPY ../x. |
| no space left on device | Images, stopped containers and build cache have filled the disk. | docker system df, then docker system prune -a and docker builder prune. |
| manifest unknown / not found | That tag doesn't exist for your platform, or the image is private. | Check the tag on the registry; docker login. |
| unauthorized: authentication required | Not logged in, or the token lacks push scope. | docker login ghcr.io. For GHCR the token needs write:packages. |
| Code changes don't appear | You rebuilt but ran the old image, or the layer was cached. | docker compose up --build. Confirm with docker images timestamps; --no-cache to be certain. |
| Container can't resolve another container's name | They're on the default bridge, which has no DNS. | docker network create appnet and put both on it. |
| Can't reach the host machine from a container | localhost inside means the container. | Use host.docker.internal; on Linux add --add-host=host.docker.internal:host-gateway. |
| App can't reach the database on startup | depends_on waits for start, not readiness. | Add a healthcheck to the db and condition: service_healthy — and make the app retry. |
| docker stop takes 10 seconds every time | Your app never receives SIGTERM, because shell-form CMD made sh PID 1. | Use exec form: CMD ["node","server.js"]. Handle SIGTERM in the app. |
| Image is enormous (1 GB+) | Build tools and dev dependencies shipped with the app. | Multi-stage build (section 08) plus -slim or alpine. docker history shows the guilty layer. |
Glossary
Words the docs assume you already know.
- Image
- A read-only, layered filesystem snapshot plus metadata saying what to run. A class.
- Container
- A running (or stopped) instance of an image, with its own thin writable layer.
- Layer
- The filesystem diff produced by one Dockerfile instruction. Cached, content-addressed and shared between images.
- Base image
- Whatever you put after FROM.
- Registry
- Where images are stored and shared — Docker Hub, GHCR, ECR.
- Repository
- One image name in a registry, holding many tags.
- Tag
- A movable pointer to an image version, like a git branch. Not a guarantee.
- Digest
- The sha256: content hash. Immutable — the only fully reproducible reference.
- Build context
- The directory sent to the daemon at build time — the . in docker build .. COPY can only see inside it.
- Dockerfile
- The recipe for building an image.
- BuildKit
- The modern build engine: parallel stages, cache mounts, build secrets. Default in current Docker.
- Multi-stage build
- Several FROMs in one Dockerfile; only the last becomes the image.
- Volume
- Storage managed by Docker that outlives the container.
- Bind mount
- A host directory mapped into a container. You own the files.
- Union filesystem
- The mechanism stacking read-only layers under one writable layer, with copy-on-write.
- Namespace
- Linux feature giving a process its own view of PIDs, network, mounts and users. The isolation in "container".
- cgroup
- Linux feature capping CPU, memory and I/O. The limits in "container".
- PID 1
- The first process in the container. When it exits, the container exits. It receives the signals.
- Daemon (dockerd)
- The background service that does the actual work. The docker CLI just talks to it.
- containerd / runc
- The lower-level runtime pieces that actually create containers. Docker sits on top.
- OCI
- Open Container Initiative — the standards for image and runtime formats, which is why images work across tools.
- Distroless
- An image with only your app and its runtime — no shell, no package manager.
- Dangling image
- An untagged leftover shown as <none>. Safe to prune.
- Compose project
- The namespace Compose prefixes to containers, networks and volumes — the folder name by default.
Questions & answers
What people ask next. Try answering each one before opening it.
Concepts ↳ #what▶
- Is a container more or less secure than a VM?Less, structurally. A VM has a hypervisor boundary and its own kernel; containers share the host kernel, so one kernel vulnerability can cross the boundary. In exchange you get much smaller images, faster starts and a far smaller patch surface. For strong multi-tenant isolation people use both — containers inside per-tenant VMs, or a sandboxed runtime like gVisor or Kata.
- Why is my Mac slow at bind-mounted file I/O?Because your containers run inside a Linux VM, and the bind mount crosses the VM boundary through a file-sharing protocol. Thousands of small reads — node_modules, .git — are the worst case. Mitigations: VirtioFS in Docker Desktop settings, :cached/:delegated mount flags, or keeping dependencies in a named volume rather than bind-mounting them.
- Should one container run one process?Almost always yes. Docker's model ties the container's life to PID 1, restart policies act on the container, and logs come from stdout — all of which assume one workload. Multiple processes means writing a supervisor, and losing the signal that any one of them died. Legitimate exceptions exist (a tiny sidecar, an init like tini to reap zombies) but "web server + database in one image" is a mistake you'll pay for at the first restart.
Images & builds ↳ #layers▶
- My image is 1.4 GB. Where do I start?docker history IMAGE and find the biggest layer — it's nearly always a dependency install or a copied build directory. Then, in order of payoff: multi-stage so build tools don't ship, a -slim or alpine base, a real .dockerignore, and cleanup inside the same RUN that created the mess (rm -rf /var/lib/apt/lists/*). Those four usually get 1.4 GB under 200 MB.
- Should I commit the lockfile and pin the base image?Yes to both, for the same reason: reproducibility. npm ci with a committed lockfile gives identical dependencies; pinning FROM node:20.11-alpine (or a digest) means today's build matches last week's. Unpinned, a rebuild months later silently picks up a new base and new transitive deps, and "it worked in CI yesterday" becomes unanswerable.
- Why does my build ignore the cache when nothing changed?Something in the context changed, even if it looks irrelevant — a log file, an editor swap file, a rebuilt dist/ — and COPY . . hashes all of it. Add a strict .dockerignore. Also: ARG values, a changed base image, and --no-cache in your CI script all invalidate. --progress=plain shows exactly which step missed.
Running & operating ↳ #run▶
- Should I run a database in Docker?In development, absolutely — it's the single biggest quality-of-life win Compose offers. In production it's a real decision: you take on backups, upgrades, storage performance and failover yourself, and a mis-set docker compose down -v deletes everything. Most teams use a managed database in production and a containerised one locally, which is a perfectly coherent position.
- How do I get secrets into a container properly?Ranked worst to best: baked into the image (never); ENV in the Dockerfile (never — it's in inspect); -e at run time (acceptable, still visible in inspect and process listings); an --env-file kept out of git (fine for dev); mounted as a file from a secret manager (good); fetched at startup with a short-lived identity — Vault, IRSA, workload identity (best). At build time, always BuildKit --secret.
- Why does my app not shut down gracefully?Three usual reasons. Shell-form CMD, so sh is PID 1 and swallows SIGTERM. Or your app has no SIGTERM handler and relies on SIGKILL. Or shutdown takes longer than the 10-second default — docker stop -t 30. Fix the first with exec form, the second with a handler that stops accepting connections and drains, the third by measuring how long draining actually takes.
- What's the difference between Docker and Kubernetes?Different layers, not competitors. Docker builds images and runs containers on one machine. Kubernetes schedules those same OCI images across many machines and adds restarts, rollouts, service discovery and scaling. You build with Docker and run in Kubernetes. (Kubernetes dropped the Docker runtime in 1.24 — but it still runs images Docker builds, because both follow the OCI standard.)
Where to go next▶
- Practise on something realTake an app you already have, write a Dockerfile, make it multi-stage, add a Compose file with a database, then get the image under 200 MB and running as non-root. That sequence covers everything on this page.
- Tools worth knowingdive to explore image layers interactively, hadolint to lint Dockerfiles, trivy or docker scout for CVEs, lazydocker for a terminal UI, and nicolaka/netshoot for debugging networking.
- The things this page skippedDocker Swarm (largely superseded by Kubernetes), rootless mode, custom network drivers (overlay, macvlan), and registry mirroring. Also alternative runtimes — Podman is a near drop-in with no daemon and rootless by default.
- Then: orchestrationOnce you're comfortable here, the Kubernetes handbook picks up exactly where this leaves off — it assumes you understand images, containers and volumes, and starts from what runs them across a fleet.