Docker Fundamentals: Run Your First Container
You will install Docker and run, inspect, and persist your first containers. By the end you will understand the difference between an image and a container and keep data alive with volumes.
This lab is the foundation for everything else in the Containers & Kubernetes track. Every later lab, building custom images, wiring containers together with networks, composing multi-service stacks, and deploying to Kubernetes, assumes you are comfortable with the core Docker CLI workflow covered here: run, ps, exec, logs, stop, and rm.
We use the official nginx:stable image because it is small, starts instantly, and serves real HTTP traffic, which gives us something concrete to verify at every step. Everything you learn transfers directly to databases, application servers, and any other image on Docker Hub.
Learning Objectives
- Run and manage containers from public images.
- Persist data with named volumes.
- Inspect container networking and logs.
- Explain the image / container / volume relationship and the container lifecycle.
- Debug common failures using
ps -a,logs, andinspect. - Time: ~2 hours · Difficulty: Beginner · Prereqs: a Linux host with sudo.
Architecture Overview
Three ideas explain almost everything in Docker. A image is the immutable blueprint pulled from a registry such as Docker Hub. A container is a live, isolated process created from that blueprint, you can start many containers from one image. A named volume is storage managed by Docker that outlives any individual container, which is how stateful data survives restarts and rebuilds.
Follow the arrows as you work through the lab: docker run first pulls the image from the registry if it is not cached locally, then creates and starts the container. The -p 8080:80 flag publishes the container's internal port 80 onto the host's port 8080, which is the path your browser (or curl) takes to reach nginx. The volume mounts into the container's filesystem at a path you choose, so writes to that path land on durable storage instead of the container's throwaway layer.
Environment Setup
Prerequisites
- Docker Engine 24+ installed and running (
docker versionshows both client and server). - A terminal; on Linux, your user in the
dockergroup avoids sudo on every command. - Assumed skills: basic shell commands; no prior container experience needed.
You will need: Docker Engine 24.x (install docs).
Before you begin: add your user to the docker group or prefix commands with sudo.
On a fresh Ubuntu or Debian host, the fastest way to a working engine is Docker's convenience script. It detects your distribution, configures the official apt repository, and installs the engine, the CLI, and containerd in one shot. For production servers, follow the manual repository setup in the official docs instead; the script is ideal for a lab VM.
INSTALL DOCKER ENGINE (UBUNTU/DEBIAN)
sudo apt update
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
The Docker daemon runs as root and listens on the Unix socket /var/run/docker.sock. By default only root can talk to it, which is why every tutorial tells you to join the docker group. Membership takes effect only on a fresh login session, so either log out and back in or use newgrp to activate it immediately.
RUN DOCKER WITHOUT SUDO
sudo usermod -aG docker $USER
newgrp docker # or log out and back in
On macOS or Windows? Install Docker Desktop instead, it runs the same Linux engine inside a lightweight VM, so every command in this lab works unchanged from your terminal. Two lab-specific notes: on macOS/Windows, the container IP from step 07 is not reachable from the host (the VM sits in between), so stick to the published port (localhost:8080); and the group/socket setup above is unnecessary because Docker Desktop handles permissions for you.
Now verify the install end to end. docker version confirms both the CLI and the daemon are alive (you should see a Client and a Server section). docker run hello-world proves the daemon can pull an image from Docker Hub and start a container from it.
docker version shows only a Client section and then a connection error, the daemon is not running, jump to the Troubleshooting section below. If hello-world prints its banner, you are ready for the lab.Step-by-Step Execution
An image becomes a container the moment you run it.
The command below does four things at once. -d runs the container detached so nginx keeps serving in the background instead of tying up your terminal. --name web gives the container a human-friendly handle you will use in every later command (otherwise Docker invents a random name like grumpy_beaver). -p 8080:80 publishes the container's internal port 80 to host port 8080, without it, nginx would be reachable only from inside Docker's private network. Finally, nginx:stable is the image reference: the stable tag tracks nginx's stable release line, which moves less often than latest but still floats with each release. For true repeatability you would pin the digest (nginx:stable@sha256:...), an immutable build identifier, as step 08 explains.
docker run -d --name web -p 8080:80 nginx:stableThe first run prints pull progress as each image layer downloads, then a long hex string, the full container ID, once the container is up.
-d leaves nginx running in the foreground and your prompt frozen, press Ctrl+C (which stops the container) or open a second terminal. Verify: curl -I http://localhost:8080 returns 200 OK with Server: nginx.Expected output:
$ docker run -d --name web -p 8080:80 nginx:stable
8f2c1a4b5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3
What this teaches: Running a container is starting an isolated process from an image; flags control its network, storage, and lifetime.
docker ps is your dashboard: it shows every running container with its image, status, published ports, and name. Add -a to also see stopped containers, this is the single most useful flag when debugging, because a container that crashed two seconds after launch is invisible to a plain docker ps.
For the full picture, docker inspect dumps the container's complete configuration as JSON: mounts, environment variables, network settings, restart policy, and more. Piping it through a Go template with --format (or through jq) lets you pull out exactly one field, which is how you will script against Docker later.
PULL ONE FIELD OUT OF INSPECT
docker inspect --format '{{.State.Status}} | {{.Config.Image}} | {{.HostConfig.RestartPolicy.Name}}' web
Expected output:
$ docker ps --format '{{.Names}} {{.Status}}'
web Up 10 seconds
docker ps comes back empty after a reboot or a crash, containers do not restart by default. Verify: the STATUS column says Up; if the container is missing, check docker ps -a for an Exited entry and read its logs (step 04).What this teaches: Listing and inspecting shows the difference between what you asked for and what Docker actually created.
Volumes keep data when a container is removed, the key to stateful workloads.
Anything a container writes to its own filesystem lives in a writable layer that is destroyed with the container. A named volume is the escape hatch: Docker stores it under /var/lib/docker/volumes/ on the host and mounts it into the container at a path you choose. Here we mount a volume called webdata over nginx's document root, /usr/share/nginx/html, so the site's files survive no matter what happens to the container serving them.
docker volume create webdata && docker run -d --name web2 -p 8081:80 -v webdata:/usr/share/nginx/html nginx:stableNow prove persistence. Write a custom page into the volume through web2, confirm it is served, then delete the container entirely and attach the same volume to a brand-new container, web3. If the page is still there, the data lived in the volume, not in the container.
WRITE, DESTROY, REATTACH
docker exec web2 sh -c 'echo "<h1>persisted by webdata</h1>" > /usr/share/nginx/html/index.html'
curl -s http://localhost:8081 # shows our custom page
docker rm -f web2 # container is gone
docker run -d --name web3 -p 8081:80 -v webdata:/usr/share/nginx/html nginx:stable
curl -s http://localhost:8081 # still shows our custom page
Expected output:
$ docker volume create webdata && docker run -d --name web2 -p 8081:80 -v webdata:/usr/share/nginx/html nginx:stable
webdata
9e8d7c6b5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8
docker rm, every docker rm in this lab would have wiped it. Verify: docker volume ls shows webdata, and the custom page survives a full container replacement.What this teaches: Volumes persist data outside the container's writable layer; without one, data dies with the container.
Docker captures whatever a container writes to stdout and stderr and stores it per container, so well-behaved images like nginx never need log files on disk. docker logs is how you read that stream. --tail 10 shows the last ten lines, -f follows new output live (like tail -f), and --timestamps prefixes each line with an RFC 3339 timestamp, which is invaluable when correlating events across containers.
docker logs --tail 10 webHit the server once with curl, then read the log: you should see your own request recorded with your client IP (from Docker's bridge network), the path, and the 200 status. This request-then-log loop is the most basic debugging rhythm in container work.
docker logs on a container whose app writes to a log file instead of stdout and seeing nothing, the fix is at the image level, not the CLI level. Verify: each curl you issue produces exactly one new access-log line.Expected output:
/docker-entrypoint.sh: Configuration complete; ready for start up
What this teaches: Logs are the container's stdout and stderr; reading them is the first debugging step for any misbehaving app.
Sometimes reading logs is not enough and you need to look around inside. docker exec starts an additional process inside an already-running container; combined with -it (interactive TTY) and a shell, it drops you into a live session. This is the container equivalent of SSH, without any SSH server installed.
OPEN A SHELL IN THE CONTAINER
docker exec -it web sh
# inside the container:
ls /usr/share/nginx/html
cat /etc/nginx/conf.d/default.conf
exit
Expected output:
$ docker exec hello hostname
8f2c1a4b5d6e
Use this to check config files, test DNS resolution, or verify which process is PID 1. Remember that any changes you make from inside are ephemeral unless they land on a mounted volume, the whole point of step 03.
bash exists, minimal images (alpine, distroless) ship only sh or no shell at all. Try sh first, and if neither exists the image was never meant to be shelled into. Verify: the prompt changes to # and ls /usr/share/nginx/html lists nginx's default pages.What this teaches: Exec runs a new process inside the container's namespaces; it is for inspection, not for configuration.
A container is a process with an on/off switch, not a machine you reinstall. docker stop sends SIGTERM and waits (ten seconds by default) before SIGKILL; docker start resurrects a stopped container with all its filesystem changes and port mappings intact; docker rm deletes the stopped container and its writable layer for good. Knowing the difference saves you from rebuilding containers you could have simply restarted.
FULL LIFECYCLE IN FOUR COMMANDS
docker stop web # graceful shutdown; curl now fails
docker ps -a # web shows "Exited (0)"
docker start web # same container back up, port 8080 works again
docker rm -f web # force-remove even while running
Expected output:
$ docker stop hello && docker rm hello
hello
hello
Note the exit code: Exited (0) means the process shut down cleanly. A nonzero code like Exited (1) or Exited (137) (SIGKILL, often the OOM killer) is your first clue when a container will not stay up. Finish by force-removing the container so later labs start from a clean slate; -f combines stop and remove in one step.
docker rm on a running container and getting Error response from daemon: cannot remove container ... container is running, either stop it first or use rm -f. Verify: after docker stop, curl localhost:8080 fails with connection refused; after docker start, it returns 200 again.What this teaches: Stop, start, and remove are the lifecycle verbs; removing is what reclaims the writable layer.
Every container gets its own network namespace and an IP address on one of Docker's virtual networks. Out of the box, containers attach to the default bridge network (subnet 172.17.0.0/16) and can reach the internet outbound, but inbound traffic only arrives through ports you explicitly publish with -p. Understanding this split, internal reachability versus published ports, is what makes container networking click.
LOOK UNDER THE HOOD
docker network ls
docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web3
docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
Expected output:
$ docker port hello
80/tcp -> 0.0.0.0:8080
Try it: curl http://172.17.0.2 from the host reaches nginx on the container's internal IP without any published port, because the host sits on the bridge too. Other machines on your LAN cannot do this, from the outside, only published ports exist. That default-deny posture is a genuine security feature of the bridge model.
-p 80:80 and expecting containers to talk to each other through it, containers on a network reach each other directly by name or IP, and publishing is only for host/external access. Verify: docker network inspect bridge lists your container under Containers with a 172.17.0.x address.What this teaches: Container networking defaults to a bridge with NAT; -p is the controlled hole you punch in it.
Containers are disposable; images are what accumulate. Every docker run against a new tag downloads more layers to your disk, so knowing how to list, inspect, and remove images keeps a lab VM healthy. An image reference has three parts: the registry (defaulting to Docker Hub), the repository (library/nginx for official images), and the tag (stable). Tags are mutable labels, latest today may not be latest tomorrow, while the digest (sha256:...) pins an exact, immutable build.
LIST AND INSPECT LOCAL IMAGES
docker images
docker history nginx:stable # one line per layer
docker image inspect nginx:stable --format '{{.RepoTags}} {{.Size}}'
Expected output:
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}'
nginx:1.27 188MB
docker history is worth a look: images are stacks of read-only layers, and layers shared between images (two nginx tags, for example) are stored only once. This is why pulling a second tag of the same image is usually fast, Docker downloads just the layers it does not already have.
REMOVE WHAT YOU NO LONGER NEED
docker rmi hello-world # remove by name:tag
docker image prune # drop dangling (untagged) layers only
docker rmi on an image that still has a container (even a stopped one) and hitting image is being used by stopped container, remove the containers first with docker rm. Verify: docker images no longer lists the image, and docker system df shows reclaimed space.What this teaches: Tags are mutable aliases and layers are the cost; pruning reclaims what you no longer need.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
The four core moves, run, inspect, persist, and read logs, are now done. Steps 05-08 layered on the operational skills (exec, lifecycle, networking, image management) you will use daily.
How It Works
An image is a stack of read-only filesystem layers plus metadata; a container is a running process given its own view of the system through namespaces, with resource limits enforced by cgroups. The layered design means ten containers from one image share the image's layers on disk and only their thin writable top layer differs. Deleting a container discards that top layer, which is why data written inside a container vanishes with it.
Volumes exist to break that rule deliberately. A volume is storage managed by Docker that lives outside the container's writable layer and outside the union filesystem entirely, so it survives container deletion and can be shared between containers. Bind mounts are the sibling concept: a host path mapped into the container, ideal for development but tied to one machine.
Tags are human-readable aliases for image digests, and digests are the content hashes that actually identify an image. latest is just a tag that moves; pinning a digest is the only way to guarantee the same bytes twice. docker history shows how each Dockerfile instruction added a layer, which is the first step toward understanding why an image is the size it is.
Testing & Validation
Do not trust that things work, prove each claim with a command. Run through this checklist before calling the lab complete.
1. The service answers HTTP:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080You should see 200. If so, your container is serving traffic and you have completed the lab.
2. The container is actually running (not just recently started and crashed):
STATUS CHECK
docker ps --filter name=web --format '{{.Names}}: {{.Status}}'
Expect web: Up X minutes. An Up time of a few seconds when you started the container long ago means it is crash-looping, check the logs.
3. The volume survived the container swap from step 03:
PERSISTENCE CHECK
curl -s http://localhost:8081 # expect: <h1>persisted by webdata</h1>
docker volume inspect webdata --format '{{.Mountpoint}}'
4. Clean up when you are done, so the containers and volume do not linger into the next lab:
TEARDOWN
docker rm -f web web3
docker volume rm webdata
docker system prune # optional: reclaim dangling images and stopped containers
docker ps -a lists none of the lab containers and docker volume ls no longer shows webdata. A clean slate is part of the deliverable.Troubleshooting
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
| Cannot connect to the Docker daemon | The engine is not running | Start the Docker service (or Docker Desktop) and retry docker version. |
| Permission denied on every command | Your user is not in the docker group | Add yourself with usermod -aG docker, then log out and back in. |
| Port is already allocated | Another container or host service owns the port | List with docker ps and remap with a different host port. |
| No space left on device | Unused images, containers, and build cache accumulated | Run docker system df to see usage, then docker system prune to reclaim. |
exec fails with container not running | The container exited; exec needs a live process | Start it first, or use docker run for a one-off shell. |
Troubleshooting
- permission denied on docker.sock: add your user to the docker group and re-login. The full error is
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock. Runsudo usermod -aG docker $USER, then log out and back in (ornewgrp docker). Confirm withgroups,dockermust appear. - port already allocated: another service uses 8080; map a different host port. Find the squatter with
sudo ss -lntp | grep 8080, then either stop it or pick a free host port, e.g.-p 8090:80. A stale container from an earlier attempt is the usual culprit, checkdocker ps -afirst. - data lost on remove: mount a named volume rather than writing inside the container layer. If you already lost data, it is unrecoverable, the writable layer was deleted with the container. Treat this as the lesson it is: anything that matters gets
-v name:/pathfrom day one. - Cannot connect to the Docker daemon: the error
Is the docker daemon running?means the service is down, not your CLI. Check withsudo systemctl status docker, start it withsudo systemctl start docker, and enable it at boot withsudo systemctl enable docker. On VMs that were suspended or rebooted, this is the most common failure of all. - container exits immediately after run: a container lives only as long as its PID 1 process. If
docker psis empty butdocker ps -ashowsExited (1) 3 seconds ago, the app crashed on startup, readdocker logs <name>for the actual error.Exited (137)specifically points to the OOM killer; give the VM more memory or set a--memorylimit to see it fail predictably.
Extension Ideas
- Build your own image in Docker Images & Dockerfile.
- Connect multiple containers in Container Networking.
- Orchestrate with Docker Compose.
- Add
--restart unless-stoppedto your run commands and reboot the VM to see containers come back on their own. - Limit resources with
--memory=128m --cpus=0.5and watch the container behave under pressure.
Key Results
- Launched a working web container in under 5 minutes.
- Persisted data across container removal with a named volume.
- Inspected status, ports, and logs of running containers.
- Verified the service with an HTTP 200 response.
Self-Check
Answer these from memory before moving on. If any answer is fuzzy, rerun the matching step, the muscle memory is the point of the lab.
- What is the difference between
docker stopanddocker rm? (Stop halts the process but keeps the container and its filesystem; rm deletes it permanently.) - Your container serves port 80 internally but
curl localhost:80fails. What flag did you forget, and where do the two port numbers go? (-p, as host:container, e.g. -p 8080:80.) - You deleted a container and its data vanished. What should you have mounted, and with which flag? (A named volume, with -v name:/path.)
docker psis empty but the app was running a minute ago. What two commands do you run next? (docker ps -a, then docker logs on the exited container.)- Which is immutable, the tag
nginx:stableor the digestsha256:...? (The digest; tags can be repointed to new builds.)
Key Takeaways
- An image is the read-only blueprint; a container is a running instance of it. One image, many containers.
-ddetaches,--namelabels, and-p host:containerpublishes a port, you will type these three flags more than any others.- Container filesystems are disposable by design. Anything that must survive goes in a named volume (
-v name:/path). - Your debugging trio is
docker ps -a(is it up?),docker logs(what did it say?), anddocker inspect(how is it configured?). - Containers on the default bridge get private
172.17.0.xIPs; only published ports are reachable from outside the host. - Exit codes tell the story:
Exited (0)is a clean stop,137is a kill (often OOM), anything else means read the logs. - Tags are mutable labels; digests are immutable. Pin by digest when a build must be exactly reproducible.
Everything here was a single container on a single host. The natural next step is defining images yourself with a Dockerfile, then wiring several containers together, both are covered in the related labs below.
Command Quick Reference
Everything from this lab in one place, for when you come back in three months:
docker run -d --name X -p 8080:80 image:tag, create and start a detached container with a published port.docker ps/docker ps -a, list running / all containers.docker logs [--tail N] [-f] X, read a container's stdout/stderr.docker exec -it X sh, open a shell inside a running container.docker inspect X, full JSON config; pair with--formatfor one field.docker stop / start / restart / rm -f X, lifecycle control.docker volume create / ls / rmand-v name:/path, persistent storage.docker network ls / inspect, see which network a container lives on.docker images / rmi / system df, image inventory and disk usage.docker system prune, reclaim space from stopped containers and dangling layers.
Practice Challenges
Challenge 1: Feel the ephemerality
Goal: Modify a file inside a container, recreate the container, and watch the change vanish; then repeat with a volume and watch it survive.
Hint: The contrast is the lesson; do both experiments back to back.
Challenge 2: Read the layers
Goal: Run docker history on an image and map each layer to its Dockerfile instruction.
Hint: The biggest layers are usually a package install or a copied artifact; that is where optimization starts.
Challenge 3: Cap the memory
Goal: Run a container limited to 512 MB and observe what happens when it exceeds the limit.
Hint: Use --memory=512m; the OOM killer's exit code 137 is the signature.