Docker Labs for Beginners: 9 Free Hands-On Exercises in Your Browser
· 10 min read · StackMonsters
Most Docker tutorials stop at docker run hello-world. The questions beginners actually get stuck on come a few minutes later: why the container exited the moment it started, why localhost:8080 refuses the connection, where the error went, and why the container keeps restarting. This tutorial works through those, in the order you'll hit them, with the commands and output for each.
You need Docker Engine on Linux, or Docker Desktop on macOS or Windows. If you'd rather not install anything yet, every section links a free lab that runs a Docker engine in your browser and checks the containers you leave behind.
Images and containers are different things
An image is a read-only template: a filesystem plus a default command. A container is one running (or stopped) instance of an image, with its own writable layer on top. You pull an image once and create as many containers from it as you like.
$ docker run hello-world
Hello from Docker!
...
$ docker run hello-world
Hello from Docker!
...
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world latest 74cc54e27dc4 9 months ago 10.1kB
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3b5e8f1c2d4a hello-world "/hello" 5 seconds ago Exited (0) 4 seconds ago eager_hopper
9a1c7d0e6f2b hello-world "/hello" 12 seconds ago Exited (0) 11 seconds ago quirky_banachOne image, two containers. Each docker run creates a new container, and Docker keeps it after it exits until you remove it. That's why docker ps -a fills up with stopped containers if you never pass --rm or clean up.
Always pin a tag. nginx means nginx:latest, which changes whenever upstream publishes. nginx:1.27 gives you the same major and minor version tomorrow.
A container runs one command, then stops
Anything after the image name replaces the image's default command:
$ docker run alpine:3.20 cat /etc/alpine-release
3.20.3
$ docker run alpine:3.20 uname -r
6.8.0-45-generic
$ uname -r
6.8.0-45-genericThe second and third commands show the main difference from a virtual machine. A container has its own filesystem, process list and network, but it shares the host's kernel. The Alpine container reports the host's kernel release because there is only one kernel. On Docker Desktop, the "host" is a small Linux VM that Desktop runs for you, so you'll see that VM's kernel instead of your Mac's or PC's.
Free lab · Docker Essentials · 6 minCompare the Host and a ContainerRuns in your browser. No install, no account.Why your container exits immediately
This is the most common beginner surprise:
$ docker run -d --name box alpine:3.20
f2a9c0d1e3b4...
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
$ docker ps -a --filter name=box
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f2a9c0d1e3b4 alpine:3.20 "/bin/sh" 6 seconds ago Exited (0) 5 seconds ago boxA container lives exactly as long as its main process. Alpine's default command is /bin/sh. A shell reads commands from standard input; docker run without -i gives it no input, so the shell reads end-of-file and exits with code 0. Nothing went wrong. The container did everything it was asked to.
What keeps a container running is a main process that keeps running: a web server, a database, a worker loop. For a shell you want to come back to, give it an open input and a terminal:
docker run -dit --name box alpine:3.20 # -i keeps stdin open, -t allocates a terminal
docker exec -it box sh # a second shell inside it; exit leaves box runningYou'll see sleep infinity or tail -f /dev/null used as a command to keep a container up. They work, but use them only for throwaway debugging containers. In a real service, a container that is up while the application inside it has died is harder to notice than one that exits.
Run a service in the background
$ docker run -d --name web nginx:1.27
4c1e6d0b8a2f9e3d7c5b1a0f8e6d4c2b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4c1e6d0b8a2f nginx:1.27 "/docker-entrypoint.…" 3 seconds ago Up 2 seconds 80/tcp web-d detaches: Docker prints the full 64-character container ID and gives your prompt back. docker ps shows the first 12 characters, and any unique prefix works wherever Docker expects an ID. Give every long-running container a --name, so you can refer to it as web instead of 4c1e6d0b8a2f.
80/tcp in the PORTS column means the image exposes port 80. It does not mean you can reach it. More on that below.
Read the output: docker logs
Docker captures whatever the main process writes to standard output and standard error:
docker logs web # everything so far
docker logs --tail 20 web # the last 20 lines
docker logs -f web # follow new lines as they arrive; Ctrl+C stops following, not the container
docker logs -t web # prefix each line with the time Docker received itOne thing trips people up when they filter logs. docker logs writes the container's stderr to your terminal's stderr, and a pipe only carries stdout. nginx writes access lines to stdout and errors to stderr, so this misses every error line:
docker logs web | grep healthz # stderr bypasses grep
docker logs web 2>&1 | grep healthz # both streams go through the pipeIf docker logs prints nothing at all, the application is probably writing to a file inside the container instead. That's why official images like nginx link their log files to /dev/stdout and /dev/stderr.
Look inside: docker exec
docker exec runs an extra process inside a running container. It's how you check what the application actually sees:
docker exec web cat /etc/os-release # which distribution the image is built on
docker exec web env # the environment the main process got
docker exec -it web sh # an interactive shell; exit leaves the container runningIt only works on a running container:
$ docker exec old-web ls
Error response from daemon: container 7d2e... is not runningFor a stopped container, read docker logs and docker inspect, or start it again. Changes you make with exec live in that container's writable layer. They survive a restart, and they disappear when the container is removed, which is why fixes belong in the image or in mounted volumes.
Reach a container from your machine: -p
A container's ports are private to Docker's network until you publish them. -p HOST:CONTAINER forwards a port on your machine to a port in the container:
$ docker run -d --name web -p 8080:80 nginx:1.27
$ curl -s localhost:8080 | grep title
<title>Welcome to nginx!</title>
$ docker port web
80/tcp -> 0.0.0.0:8080
80/tcp -> [::]:8080The order matters and is easy to flip: host first, container second. The container side has to be the port the application listens on inside the container (80 for nginx), whatever the host side is. Three failures and what each looks like:
# Nothing published on that host port
$ curl localhost:8081
curl: (7) Failed to connect to localhost port 8081 after 0 ms: Couldn't connect to server
# The host port is already taken by another container or program
$ docker run -d --name web2 -p 8080:80 nginx:1.27
docker: Error response from daemon: driver failed programming external connectivity on endpoint web2 (...): Bind for 0.0.0.0:8080 failed: port is already allocated.
# Published to a container port nothing listens on (nginx listens on 80, not 8080)
$ docker run -d --name api -p 8090:8080 nginx:1.27
$ curl localhost:8090
curl: (56) Recv failure: Connection reset by peerThe third one is the trap. Docker accepts the connection on the host and then finds nothing to forward it to, so you get a reset instead of a refusal. Docker Desktop may report it as Empty reply from server.
Port mappings are fixed when a container is created. To change one, remove the container and run it again with the new -p. Any number of containers can listen on port 80 inside; only the host ports need to differ (-p 8080:80, -p 8081:80). And -p 127.0.0.1:8082:80 publishes on loopback only, which keeps a development database off your network.
Configure it: environment variables
Most images read their configuration from environment variables. Some won't start without them:
$ docker run -d --name db postgres:16
$ docker logs db
Error: Database is uninitialized and superuser password is not specified.
You must specify POSTGRES_PASSWORD to a non-empty value for the
superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".
...
$ docker rm db
$ docker run -d --name db -e POSTGRES_PASSWORD=devpass postgres:16With more than two or three variables, put them in a file, one NAME=value per line, and pass --env-file app.env. Like ports, the environment is set when the container is created. Editing app.env afterwards does nothing to a running container, and neither does docker restart. Recreate it.
Never bake real secrets into an image or commit them in an env file. Anyone who can pull the image or read the repository can read them, and so can anyone who runs docker inspect on the container.
Troubleshoot a container that keeps restarting
Put it together on the failure you'll eventually meet in real life. A container started with --restart unless-stopped crashes, Docker restarts it, it crashes again:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8e3f1a2b4c5d pier/worker:1 "python /app/work.py" 2 minutes ago Restarting (2) 8 seconds ago workerThe status already says two things: it's in a restart loop, and the last exit code was 2. Work through it in this order:
- Read the logs.
docker logs --tail 30 worker. Most crashes explain themselves on the last few lines. - Check the exit code and the command.
docker inspect --format '{{.State.ExitCode}} {{.Path}} {{.Args}}' worker. - Check the configuration it was created with.
docker inspect workershows the environment under.Config.Env, the restart policy under.HostConfig.RestartPolicy, and the published ports. - Try the image by hand.
docker run --rm -it --entrypoint sh pier/worker:1opens a shell in the same image, so you can check whether the file the command names actually exists.
Exit codes narrow it down before you read a single log line:
| Exit code | Usually means |
|---|---|
| 0 | The main process finished successfully. Fine for a job; for a service, it means it didn't stay in the foreground. |
| 1, 2 | The application failed and chose the code: bad config, missing file, uncaught exception. Read the logs. |
| 125 | docker run itself failed, before anything ran in the container. |
| 126 | The command exists but couldn't be executed, often a script without execute permission. |
| 127 | The command wasn't found in the image: a typo, or a binary that isn't installed. |
| 137 | Killed with SIGKILL (128 + 9). docker inspect shows "OOMKilled": true if memory ran out; otherwise something forced it, often docker stop after its 10-second grace period. |
| 143 | Stopped by SIGTERM (128 + 15), the normal result of docker stop for a process that doesn't catch it. |
A 137 after every docker stop usually means the process ignored SIGTERM. A process that runs as PID 1 in a container gets no default signal handling, so a program that doesn't install its own SIGTERM handler keeps running until Docker kills it 10 seconds later. Running with --init, or starting the program directly rather than through a shell wrapper, fixes that.
Once you've found the cause, recreate the container with the same name, image and restart policy, and remove the stopped containers you made while investigating. docker container prune removes every stopped container at once, so check docker ps -a before you use it.
What to learn next
These commands cover running other people's images. The next step is building your own: writing a Dockerfile, understanding the layer cache so rebuilds take seconds, then keeping data in volumes, connecting containers on a user-defined network, and describing the whole stack in a Compose file.
