Docker 04 — Running & Operating Containers
You have an image. Now you have to run it, watch it, climb inside it when it misbehaves, and shut it down cleanly — without googling each command mid-incident. This is the module that turns “I built an image” into “I operate containers,” which is the difference between someone who followed a tutorial and someone you can hand a misbehaving service to at 11pm.
The Goal
By the end of this module you can:
- Explain what the
dockerCLI actually talks to — and why nothing runs without the daemon - Run
splitease-apidetached with a published port and an injected env var, from memory - Read a running container’s logs live and exec a shell inside it to debug
- Walk any container through its full lifecycle — image to created to running to stopped to removed
- Debug the two traps that get every beginner: the wrong-way-round port and the container that exits the instant you start it
- Keep a container up across crashes and reboots with a restart policy
The Lesson
The CLI is not Docker — the daemon is
When you type docker run, the docker command does almost nothing itself. It is a thin client. It packages your request and sends it over a socket to a long-running background process called the daemon (dockerd). The daemon is the thing that actually pulls images, creates containers, runs processes, and streams logs back. The CLI just asks; the daemon does.
flowchart LR CLI["docker CLI"] -->|"REST over socket"| Daemon["dockerd daemon"] Daemon --> C1["container splitease-api"] Daemon --> C2["container postgres"] Daemon --> Store["local image store"] Store --> Daemon
Three things follow from this picture, and each one saves you confusion later:
- The daemon must be running. On Windows that means Docker Desktop is started. If the whale icon is off, every command dies with
error during connect ... the system cannot find the file specifiedorCannot connect to the Docker daemon. The fix is never the command — it is “start Docker Desktop.” - The image store is local. When you
docker run nginx, the daemon first checks its own local store. Only if the image is missing does it reach out to a registry. That is why the second run is instant. - The CLI is stateless. It holds nothing. Every
docker ps,docker logs,docker stopis a fresh question to the daemon, which is the single source of truth about what is running. This is why you can close your terminal and a detached container keeps running — the daemon owns it, not your shell.
In a real job this is the mental model behind half of all “Docker is broken” tickets: nine times out of ten the daemon is down or pointed at the wrong host, and the command was fine all along.
docker run = create + start
The one command you will type most is docker run. It does two things in sequence: it creates a container from an image (a fresh writable layer on top of the read-only image), then starts its main process. People think of it as one verb; it is two, and knowing that makes the lifecycle below click.
Here is the shape of a real run, with the flags you will actually use every day:
docker run -d \
--name splitease \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=dev \
--restart unless-stopped \
splitease-api:latest
Read it flag by flag — these six cover ~90% of what you will ever type:
| Flag | Means | Why you reach for it |
|---|---|---|
-d | Detached. Run in the background, print the container ID, give your prompt back. | The default for any server. Without it your terminal is glued to the process. |
-p 8080:8080 | Publish a port: host:container. | The only way traffic from your machine reaches the app inside. Direction matters — see below. |
-e KEY=value | Set an environment variable inside the container. | Inject config without rebuilding the image — DB URL, profile, secrets at runtime. |
--name splitease | Give the container a human name. | So you type docker logs splitease, not docker logs a3f9c1. |
--rm | Auto-remove the container when it stops. | For throwaway runs (a quick test, a one-off script) so they do not pile up. |
-it | Interactive + TTY. Keep stdin open and attach a terminal. | For anything you want to type into — a shell, a REPL. The opposite of -d. |
-d and -it are opposites in intent: detached for “run it and leave it,” interactive for “I want to talk to it.” You rarely use both.
Trap #1 — the port direction.
-p 8080:8080readshost:container. Left of the colon is your machine; right is inside the container. Ifsplitease-apilistens on 8080 inside but you want to hit it on 9090 from your laptop, that is-p 9090:8080. Get them backwards (-p 8080:9090) andcurl localhost:9090works whilelocalhost:8080connects to nothing — and the container looks perfectly healthy, which is what makes this one so maddening. Say it out loud every time: host first, container second.
Seeing what is running: ps, logs, inspect
Once it is running detached, you have lost the live console — on purpose. Here is how you get the view back.
docker ps # containers running right now
docker ps -a # ALL containers, including stopped and exited ones
docker ps shows you the living. The moment a container stops it vanishes from ps — which is exactly when beginners panic and think it was deleted. It was not. docker ps -a shows it sitting there in Exited state, and the STATUS column (Exited (1) 4 seconds ago) is your first clue about why it died.
docker logs splitease # everything the app printed to stdout/stderr
docker logs -f splitease # follow: stream new lines live, like tail -f
docker logs is your primary debugging window. Whatever the app writes to standard out and standard error, the daemon captured it — even for a container that already crashed. The -f (follow) flag tails it live so you can watch a request flow through in real time.
docker inspect splitease # full JSON: IP, mounts, env, ports, restart policy, exit code
docker inspect dumps everything the daemon knows about the container as JSON — its internal IP, mounted volumes, the exact env it was started with, its restart policy, and its exit code. You will not read all of it; you will Ctrl-F for the one field you need ("ExitCode", "IPAddress"). Treat it as the reference manual, not bedtime reading.
Getting inside: docker exec
This is the single most useful operational skill in the whole module. Your container is running but something is wrong — the app cannot reach the database, a config file looks empty, an env var seems unset. You do not rebuild and pray. You walk inside the running container and look:
docker exec -it splitease sh
exec runs a new command inside an already-running container. With -it and sh (or bash if the image has it), that command is an interactive shell — and now you are standing inside the container’s filesystem, as if you SSH’d into a tiny machine:
# now inside the container:
env | grep SPRING # are my env vars actually set?
cat /app/application.yml # is the config what I think it is?
wget -qO- localhost:8080/actuator/health # does the app answer from inside?
exit # leave the container shell (it keeps running)
The distinction that matters: docker run starts a container from an image; docker exec runs a command in a container that is already up. Exec touches nothing about the image — it is purely a debugging window into the live thing. In a real incident, exec -it ... sh is the move that turns “it’s broken and I have no idea why” into an actual answer in two minutes.
The lifecycle: create, start, stop, remove
Every container walks the same path, and docker run just compresses the first two steps. Step through it below and watch what happens to the writable layer — the scratch space stacked on top of the read-only image where all runtime changes live.
The states, and the commands that move between them:
| State | What it means | How you got here |
|---|---|---|
| image | A read-only template. Not a container yet. | docker build or docker pull |
| created | Container exists, writable layer allocated, process not started. | docker create (or the create half of run) |
| running | Main process is alive and executing. | docker start (or the start half of run) |
| stopped | Process ended; writable layer still on disk. | docker stop, or the process exiting |
| removed | Container and its writable layer gone forever. | docker rm |
docker stop splitease # graceful: sends SIGTERM, then SIGKILL after ~10s
docker start splitease # bring a stopped container back — same writable layer, same data
docker rm splitease # delete it permanently (must be stopped first)
docker rm -f splitease # force: stop AND remove in one shot
The load-bearing point — and the visualizer drives it home: stop keeps your writable layer, rm destroys it. Stop a container, start it again, and anything it wrote to its own filesystem is still there. But rm deletes that writable layer for good. So if your app wrote data inside the container (an uploaded file, a SQLite DB, Postgres data in the default location), docker rm vaporizes it. That is the entire reason volumes exist — storage that lives outside the container’s writable layer and survives rm. You will wire those up in data-and-networking; for now just hold the danger in your head: rm is not undo, it is delete.
Trap #2 — “my container exited immediately.” You run it,
docker psshows nothing,docker ps -ashowsExited (0)a second later. The container did not crash — a container lives exactly as long as its main process. If that process is a script that finishes, or a shell with nothing to do, or a command that printed and returned, the container’s whole reason to exist is over and it stops. A container is not a little always-on VM; it is a wrapper around one foreground process. To stay up it needs a process that keeps running in the foreground — a web server,java -jar app.jar, a daemon that blocks.splitease-apistays up because the JVM runs forever waiting for requests. A container runningshwith no-itexits at once becauseshreads end-of-input and quits. Check the exit code indocker ps -aordocker inspect:Exited (0)means “the process finished normally — there was just nothing to keep it alive.”
Keeping it up: restart policies
By default a stopped container stays stopped, and a crashed one stays dead. For anything you actually depend on, tell the daemon to bring it back:
docker run -d --restart unless-stopped --name splitease -p 8080:8080 splitease-api:latest
| Policy | Behaviour |
|---|---|
no (default) | Never auto-restart. |
on-failure | Restart only if it exits non-zero (a crash), up to a retry limit. |
unless-stopped | Always restart it — across crashes and host reboots — unless you explicitly stopped it. |
always | Like unless-stopped, but restarts even ones you manually stopped, after a daemon restart. |
unless-stopped is the sane default for a service on your own box: it survives crashes and reboots, but respects a deliberate docker stop so you can take it down on purpose without it springing back. This is the daemon-as-supervisor feature — the same job systemd or a process manager does, built right in. Real orchestrators (Kubernetes, in the Cloud track) take this idea much further, but the local instinct starts here.
Build This
Operate splitease-api end to end — run, observe, climb inside, tear down. You need the image from dockerfile-deep-dive; if you do not have it yet, build it first with docker build -t splitease-api:latest . from the repo root. Run everything below from any terminal with Docker Desktop running.
# 1. Run it detached, published on 8080, with a profile env var, kept up across crashes
docker run -d \
--name splitease \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=dev \
--restart unless-stopped \
splitease-api:latest
# 2. Confirm it is alive and note the STATUS column
docker ps
# 3. Hit it from your host — proves the published port works
curl http://localhost:8080/actuator/health
# expected: {"status":"UP"}
# 4. Tail the logs live (Ctrl-C to stop following — the container keeps running)
docker logs -f splitease
# 5. Climb inside the RUNNING container and look around
docker exec -it splitease sh
# now inside:
env | grep SPRING # SPRING_PROFILES_ACTIVE=dev should be there
wget -qO- localhost:8080/actuator/health # the app answering from inside itself
exit
# 6. Tear it down cleanly
docker stop splitease # graceful shutdown
docker ps -a # see it now in Exited state, not gone
docker rm splitease # delete it for good
docker ps -a # gone
Definition of done: You ran a server detached, reached it over a published port from your host with curl, watched its logs stream live, opened a shell inside the running container and confirmed your env var landed, then stopped and removed it — and you can explain, without looking anything up, why -p 8080:8080 is host:container, why the container stayed up (long-running JVM process) while a bare sh would have exited, and what docker rm destroyed that docker stop left alone.
Where to Practice
| What | Where | Time |
|---|---|---|
| Run/ps/logs/exec/stop on a throwaway image, no install | labs.play-with-docker.com | 30 min |
docker run reference — every flag, in order | docs.docker.com (CLI reference → docker run) | 20 min |
| Container lifecycle + restart policies guide | docs.docker.com (Get Started → run containers) | 20 min |
Operate your own splitease-api exactly as in Build This | your SplitEase repo | 30 min |
How to Practice
Run the Build This loop three times across three days from memory, deleting the container at the end each time so you start clean. Day one, peek at this page; day three, type every command cold. The retention drill that matters most: deliberately break it — run with -p 9090:8080 and curl the wrong port, or run an image whose process exits, then use docker ps -a and docker logs to diagnose why before you read the answer. Operating containers is muscle memory; you only build it by doing the moves, not reading them.
Check Yourself
1. What does the docker CLI actually talk to, and what happens if it is not running?
Answer
The CLI talks to the daemon (dockerd) over a socket — the daemon does all the real work (pulling, creating, running, logging). If the daemon is not running (Docker Desktop not started), every command fails with Cannot connect to the Docker daemon and the fix is to start Docker, not change the command.
2. In -p 8080:9090, which number is your machine and which is inside the container?
Answer
host:container. So 8080 is your machine, 9090 is inside the container. You would reach the app at localhost:8080, and it must be listening on 9090 inside for this to work.
3. docker run is secretly two operations. Which two?
Answer
Create (make a container from the image, allocate its writable layer) then start (run its main process). docker create + docker start separately do the same thing.
4. Your container exits the instant you start it, docker ps -a shows Exited (0). What happened?
Answer
Its main process finished. A container lives exactly as long as its foreground process — if that process is a script that returns, or a shell with no input, there is nothing to keep the container alive. Exit code 0 means it ended normally, not a crash. It needs a long-running foreground process (a web server, java -jar) to stay up.
5. What is the difference between docker run and docker exec?
Answer
docker run starts a new container from an image. docker exec runs a command inside a container that is already running — your main debugging window. exec -it <c> sh gives you an interactive shell inside the live container.
6. You docker stop a container, then docker start it again. Is the data it wrote still there? What about after docker rm?
Answer
After stop + start: yes, the writable layer survives, so the data is still there. After docker rm: no — rm destroys the writable layer permanently. Data you need to keep belongs in a volume (see data-and-networking).
7. Why does docker ps show nothing while docker ps -a shows your container?
Answer
docker ps lists only running containers. The moment one stops it disappears from ps but still exists on disk in Exited state — docker ps -a shows all containers including stopped ones, with the STATUS column telling you when and how it exited.
8. Which restart policy keeps a service up across crashes and reboots but still lets you take it down on purpose?
Answer
--restart unless-stopped. It restarts the container after crashes and host reboots, but respects a deliberate docker stop (it will not spring back until you start it again).
Still Unclear?
Paste any of these to Claude to go deeper:
- “Walk me through what actually happens inside the Docker daemon, step by step, when I run
docker run -d -p 8080:8080 splitease-api. Where does the port mapping get set up, and what process owns it?” - “Give me five different reasons a container might exit immediately with
docker ps -ashowingExited (1), the exact log line or symptom for each, and how to confirm which one I’m hitting.” - “Explain the difference between
docker stop,docker kill, anddocker rm -fin terms of signals (SIGTERM vs SIGKILL) and what each does to my app’s in-flight requests and writable layer.”
Why AI Can’t Do This For You
AI can hand you the docker run line, but it cannot stand at your keyboard at 11pm when the container is up, the logs look fine, and curl localhost:8080 still hangs. Diagnosing that — is it the port direction, a process that exited, a daemon pointed at the wrong host, an env var that never landed — is a sequence of live observations (ps -a, logs -f, exec -it sh, inspect) that only you can run against your actual running system, reading each result to decide the next move. The commands are learnable in an afternoon; the operating instinct of which to reach for, in what order, under pressure, is the skill that makes you the person who gets handed the broken service.
Module done? Add it to today’s tracker