Docker Basics #3: Images and Containers — build, run, ps, logs, exec
In #2 Writing your first Dockerfile, we baked an image and ran it once. This post covers the CLI commands you actually use day to day. 90% of routine work comes down to these commands.
This post in the Docker Basics series:
- #1 What is a container
- #2 Writing your first Dockerfile
- #3 Images and containers — build, run, ps, logs, exec ← this post
- #4 Volumes and networks
- #5 Registries — Docker Hub, GHCR, push/pull
- #6
.dockerignoreand the build context
Container lifecycle #
Before listing commands, a quick mental picture of the states a container moves through:
┌──────────┐
│ image │
└────┬─────┘
│ docker run
▼
┌──────────┐ docker stop ┌──────────┐
│ running │ ─────────────▶ │ exited │
│ │ ◀───────────── │ │
└────┬─────┘ docker start └────┬─────┘
│ │
│ docker pause │ docker rm
▼ ▼
┌──────────┐ ┌──────────┐
│ paused │ │ (gone) │
└──────────┘ └──────────┘run creates a container from an image; while the main process is alive it’s running. When that process exits — or stop sends a signal — it goes to exited. rm finally removes it. Keep these five states and arrows in mind and the commands fall into place.
docker build — bake an image
#
The simplest form, same as in #2:
docker build -t hello-docker .The flags you reach for most:
| Flag | Meaning |
|---|---|
-t name:tag | Name and tag the image. Without :tag, defaults to latest. Repeat for multiple tags. |
-f Dockerfile.dev | Use a non-default Dockerfile name |
--no-cache | Skip the layer cache and rebuild from scratch |
--pull | Always pull the base image (avoid stale cache) |
--build-arg KEY=value | Pass a value into a Dockerfile ARG |
--target stage | In a multi-stage build, build only one stage |
--platform linux/amd64 | Build for a specific architecture |
--progress=plain | Full log instead of BuildKit’s one-line summary |
Tagging the same build with two names is common:
docker build -t myapp:1.2.0 -t myapp:latest .Pin a release version and latest together. When pushing, push both.
docker images — view cached images
#
docker images
# REPOSITORY TAG IMAGE ID CREATED SIZE
# hello-docker latest a1b2c3d4e5f6 2 minutes ago 148MB
# python 3.14-slim 9a8b7c6d5e4f 3 days ago 147MBUseful variants:
docker images -q # IDs only (for scripts)
docker images --filter dangling=true # untagged (<none>) intermediate images
docker image inspect myapp # full metadata as JSON
docker history myapp # which command made each layerdocker history is interesting the first time you see it — it shows the layers that built the image and their sizes. A go-to when an image has gotten too big and you need to find why.
docker run — the flags you’ll use
#
docker run has a lot of options, but you only see a handful day to day:
docker run -d -p 8000:8000 --name myapp -e DEBUG=1 --rm hello-docker| Flag | Meaning |
|---|---|
-d (--detach) | Run in the background. Prints the container ID and returns. |
-p HOST:CONTAINER | Port mapping. -p 8080:8000 → host 8080 → container 8000 |
-P (uppercase) | Map all EXPOSEd ports to random host ports |
--name N | Container name. Without it, Docker picks a random one like bold_curie. |
-e KEY=val | Set an env var. Repeatable. |
--env-file .env | Read env vars from a .env file |
--rm | Auto-remove on exit. Good for dev / one-shot runs. |
-it | Interactive + TTY. For dropping into a shell. |
-v src:dst | Volume / bind mount (#4) |
--network N | Pick a network (#4) |
--restart unless-stopped | Restart automatically if the container dies |
-w /path | Override the working directory |
-u 1000:1000 | Override UID/GID |
Flags come before the image name. Anything after the image name is a command to run inside the container (optional).
docker run [flags] <image> [command]docker run --rm ubuntu:24.04 echo hello
# helloDon’t combine -d and --rm
#
A common point of confusion. -d --rm runs detached and deletes the container immediately on exit, leaving you no time to read logs. Pick one:
- Dev / experimentation:
--rm(foreground, cleans up on exit) - Operations / demo:
-d(detached, yourmexplicitly)
docker ps — running containers
#
docker ps
# CONTAINER ID IMAGE STATUS PORTS NAMES
# a1b2c3d4... hello-docker Up 3 minutes 0.0.0.0:8000->8000/tcp myappdocker ps -aUseful variants:
docker ps -q # IDs only
docker ps --filter status=exited # only stopped
docker ps --filter ancestor=hello-docker # only ones from a given image
docker ps --format '{{.Names}}\t{{.Status}}' # custom formatdocker logs — stdout/stderr
#
For a container started with -d:
docker logs myapp # everything so far
docker logs -f myapp # follow — like tail -f
docker logs --tail 100 myapp # last 100 lines
docker logs --since 10m myapp # last 10 minutes
docker logs --timestamps myapp # with timestampsDocker captures the container’s stdout/stderr into a host log file. So if your app writes only to stdout, docker logs sees everything. That’s why the recommended pattern is to write logs to stdout, not to a file inside the container.
docker exec — into a running container
#
run makes a new container; exec runs a command inside one that’s already running.
docker exec -it myapp bash
# or sh (for bases like alpine without bash)
docker exec -it myapp shOnce inside, you can read files, hit a DB client, or ps to inspect processes. The first step of debugging.
docker exec myapp ls /app
docker exec myapp env | grep DB_Without -it, just running a single command is also common.
run vs exec once more
#
They look similar at first. The big difference:
docker run -it ubuntu bash— creates a new container and runs bash in it.exitends the container.docker exec -it myapp bash— runs bash inside the already-runningmyapp.exitleavesmyapprunning.
In operations it’s almost always exec. Anything you do inside a run-spawned Ubuntu disappears with the container, so it’s only useful for “play with the environment briefly.”
docker stop / start / restart
#
Driving the lifecycle directly:
docker stop myapp
# Send SIGTERM → wait 10s → SIGKILL if not done
docker stop -t 30 myapp # extend the wait to 30 secondsstop is graceful by default. Docker sends SIGTERM and gives the app time to clean up (default 10s). If it doesn’t exit in that window, SIGKILL drops.
The reason #2 insisted on
CMDin exec form lives here. With shell form, SIGTERM stops at the shell and never reaches the app, so it always dies via SIGKILL. DB connections aren’t closed cleanly; in-flight work gets cut.
docker start myapp # exited → running again
docker restart myapp # stop → start
docker kill myapp # SIGKILL immediately (no graceful)docker rm / docker rmi — cleanup
#
docker rm myapp # remove a stopped container
docker rm -f myapp # force remove even if running (kill + rm)
docker rm $(docker ps -aq) # remove all containers (dangerous)docker rmi hello-docker
docker rmi $(docker images -q --filter dangling=true) # untagged onesThe <none>:<none> “dangling” images stack up over repeated builds. Clean them out occasionally.
docker system prune — sweep at once
#
Instead of typing several cleanup commands, sweep unused stuff in one go:
docker system prune
# Stopped containers + dangling images + unused networks
docker system prune -a
# Above + images not used by any container
docker system prune -a --volumes
# Above + unused volumes (data may be lost — careful)When a CI box or dev machine starts running out of disk, this is the first command to reach for. --volumes, though, can wipe DB data — always look twice.
docker system df
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 18 4 3.2GB 2.1GB (65%)
# Containers 6 1 12MB 5MB (41%)
# Local Volumes 8 3 420MB 280MB (66%)docker inspect — dig metadata
#
A command you reach for occasionally when chasing a problem. It dumps JSON for any container / image / network / volume.
docker inspect myapp
docker inspect --format '{{.State.Status}}' myapp
# running
docker inspect --format '{{.NetworkSettings.IPAddress}}' myapp
# 172.17.0.2--format is Go template syntax — extremely useful when you want a single value on one line.
One full cycle — together #
End-to-end flow with the commands from this post:
# 1. build the image
docker build -t myapp .
# 2. run in the background
docker run -d --name myapp -p 8000:8000 -e DEBUG=1 myapp
# 3. check status
docker ps
docker logs -f myapp
# 4. step inside to inspect
docker exec -it myapp sh
# 5. graceful stop
docker stop myapp
# 6. clean up
docker rm myapp
docker rmi myappIt looks like a lot at first but quickly becomes muscle memory — and the same pattern shows up in CI scripts, Makefiles, and shell aliases.
Wrap-up #
The picture from this post:
- Container lifecycle: image → running → exited → (gone). Commands sit on those arrows.
docker build— flags you’ll actually use:-t,-f,--no-cache,--build-arg,--target,--platform.docker run— daily flags:-d,-p,--name,-e,--rm,-it.docker ps/imagesfor state;docker logs/execto see inside.docker stopis graceful (SIGTERM);docker killis immediate (SIGKILL).docker system prunesweeps in bulk;docker inspect --formatis your scalpel.
In the next post (#4 Volumes and networks) we tackle two things: how to keep data alive when a container dies (volumes), and how containers talk to each other and to the host (networks). The first step into operations.