Docker Basics #3: Images and Containers — build, run, ps, logs, exec

8 min read

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:

Container lifecycle #

Before listing commands, a quick mental picture of the states a container moves through:

Container lifecycle
   ┌──────────┐
   │  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:

Basic build
docker build -t hello-docker .

The flags you reach for most:

FlagMeaning
-t name:tagName and tag the image. Without :tag, defaults to latest. Repeat for multiple tags.
-f Dockerfile.devUse a non-default Dockerfile name
--no-cacheSkip the layer cache and rebuild from scratch
--pullAlways pull the base image (avoid stale cache)
--build-arg KEY=valuePass a value into a Dockerfile ARG
--target stageIn a multi-stage build, build only one stage
--platform linux/amd64Build for a specific architecture
--progress=plainFull log instead of BuildKit’s one-line summary

Tagging the same build with two names is common:

Two tags at once
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 #

Image list
docker images
# REPOSITORY   TAG       IMAGE ID       CREATED        SIZE
# hello-docker latest    a1b2c3d4e5f6   2 minutes ago  148MB
# python       3.14-slim 9a8b7c6d5e4f   3 days ago     147MB

Useful variants:

Handy forms
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 layer

docker 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:

Common shape
docker run -d -p 8000:8000 --name myapp -e DEBUG=1 --rm hello-docker
FlagMeaning
-d (--detach)Run in the background. Prints the container ID and returns.
-p HOST:CONTAINERPort mapping. -p 8080:8000 → host 8080 → container 8000
-P (uppercase)Map all EXPOSEd ports to random host ports
--name NContainer name. Without it, Docker picks a random one like bold_curie.
-e KEY=valSet an env var. Repeatable.
--env-file .envRead env vars from a .env file
--rmAuto-remove on exit. Good for dev / one-shot runs.
-itInteractive + TTY. For dropping into a shell.
-v src:dstVolume / bind mount (#4)
--network NPick a network (#4)
--restart unless-stoppedRestart automatically if the container dies
-w /pathOverride the working directory
-u 1000:1000Override UID/GID

Flags come before the image name. Anything after the image name is a command to run inside the container (optional).

Flags → image → command (optional)
docker run [flags] <image> [command]
One-shot command in Ubuntu
docker run --rm ubuntu:24.04 echo hello
# hello

Don’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, you rm explicitly)

docker ps — running containers #

What's up right now
docker ps
# CONTAINER ID   IMAGE          STATUS         PORTS                    NAMES
# a1b2c3d4...    hello-docker   Up 3 minutes   0.0.0.0:8000->8000/tcp   myapp
All containers (including stopped)
docker ps -a

Useful variants:

Handy forms
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 format

docker logs — stdout/stderr #

For a container started with -d:

View logs
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 timestamps

Docker 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.

Drop into a shell
docker exec -it myapp bash
# or sh (for bases like alpine without bash)
docker exec -it myapp sh

Once inside, you can read files, hit a DB client, or ps to inspect processes. The first step of debugging.

One-shot command
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. exit ends the container.
  • docker exec -it myapp bash — runs bash inside the already-running myapp. exit leaves myapp running.

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:

Stop
docker stop myapp
# Send SIGTERM → wait 10s → SIGKILL if not done
docker stop -t 30 myapp   # extend the wait to 30 seconds

stop 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 CMD in 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.

Restart / bring an exited container back
docker start myapp     # exited → running again
docker restart myapp   # stop → start
docker kill myapp      # SIGKILL immediately (no graceful)

docker rm / docker rmi — cleanup #

Remove containers
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)
Remove images
docker rmi hello-docker
docker rmi $(docker images -q --filter dangling=true)  # untagged ones

The <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:

Light cleanup
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.

Current disk usage
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.

Everything about a container
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:

From start to cleanup
# 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 myapp

It 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 / images for state; docker logs / exec to see inside.
  • docker stop is graceful (SIGTERM); docker kill is immediate (SIGKILL).
  • docker system prune sweeps in bulk; docker inspect --format is 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.

X