This guide is for learners who want to understand Docker by doing, not by memorizing commands. You will start with a single container, then build your own image, then move into multi-service Docker Compose labs. Each lab explains not only what to type, but why the step matters.
By the end of the guide, you should be able to:
- Explain the difference between an image, a container, a volume, a network, and a Compose project.
- Build a custom image from a
Dockerfile. - Run containers with port mappings and environment variables.
- Use
docker composeto manage a local development stack. - Connect multiple services together with Compose networking.
- Persist state with named volumes.
- Read logs, inspect services, and clean up correctly.
- Understand a more production-like Compose setup with healthchecks, env files, and a reverse proxy.
- Work through the labs in order. Each lab builds on the previous one.
- The lab paths in this guide are relative to the directory that contains this file:
outputs/. - Type commands yourself instead of copy-pasting everything at once.
- After each lab, pause and explain the result in your own words.
- Break things on purpose. Change a port, remove a volume, or stop a dependency and see what happens.
- Docker Desktop, or Docker Engine plus the Compose plugin.
- A terminal.
- Basic shell navigation such as
cdandls. - Free local ports:
5000,5001,8080, and8081.
Verify your setup:
docker --version
docker compose version
docker run --rm hello-worldWhat this proves:
docker --versionconfirms the Docker CLI is installed.docker compose versionconfirms the modern Compose subcommand is available.docker run --rm hello-worldconfirms Docker can pull an image and start a container.
Before the labs, keep these five ideas straight:
An image is a packaged filesystem plus metadata and a startup command. Think of it as a blueprint or template.
A container is a running instance of an image. If an image is a class, a container is an object. Containers are disposable by design.
A volume stores data outside the container lifecycle. Delete and recreate the container, and the volume can still keep the data.
Containers can talk to each other over a Docker-managed network. Compose creates a project network automatically, so services can usually reach each other by service name.
Compose is a way to declare a multi-container application in YAML.
Instead of remembering long docker run commands, you describe services once and run them together.
Path: no starter files required.
Run a web server container, inspect it, enter it, and remove it cleanly.
docker run -d --name web1 -p 8081:80 nginx:alpineExplanation:
docker runcreates and starts a container.-druns it in detached mode.--name web1gives it a human-friendly name.-p 8081:80maps your machine's port8081to the container's port80.nginx:alpineis the image name and tag.
Open http://localhost:8081.
What happened:
- Docker pulled the image if it was not already local.
- A container started from that image.
- Nginx listened on port
80inside the container. - Docker published that port to
8081on your machine.
docker psExplanation:
- This shows active containers only.
- Notice the container name, image, status, and published ports.
docker logs web1Explanation:
- Logs are often the fastest way to see whether an app started correctly.
- For long-running apps, this is usually your first debugging command.
docker exec -it web1 shInside the container, try:
ls
ps
cat /etc/os-release
exitExplanation:
docker execruns a command inside an already-running container.-itgives you an interactive terminal session.- This helps you inspect files, environment variables, and running processes.
docker stop web1
docker rm web1Explanation:
stopsends a signal to end the main process.rmremoves the stopped container.- Removing a container does not remove the image.
- Images and containers are different things.
- Port mapping is explicit.
- Container logs and
execare core debugging tools. - Containers are disposable.
Path: labs/lab-02-build-image
Build a small Flask web app into a custom image and run it.
app.pyrequirements.txtDockerfile.dockerignore
cd labs/lab-02-build-imageExplanation:
- Docker builds from a context directory.
- Everything in the current build context can be sent to the Docker daemon unless excluded by
.dockerignore.
docker build -t compose-hands-flask:v1 .Explanation:
-tadds a name and tag to the built image..means the current directory is the build context.- Docker executes the
Dockerfilestep by step and creates image layers.
docker run --rm -p 5000:5000 -e MESSAGE="Built from my Dockerfile" compose-hands-flask:v1Open http://localhost:5000.
Explanation:
--rmremoves the container automatically when it exits.-e MESSAGE=...injects an environment variable at runtime.- The image is now your own artifact, not just a public image you pulled.
docker image ls compose-hands-flask
docker psExplanation:
docker image lsshows your built image.docker psconfirms the container is running from that image.
Edit app.py, change the default message, then rebuild:
docker build -t compose-hands-flask:v2 .Explanation:
- Image builds are immutable snapshots.
- Changing source code does nothing to a running container until you rebuild or remount the code.
The .dockerignore file keeps junk out of the build context.
That makes builds faster, smaller, and less error-prone.
Path: labs/lab-03-compose-dev
Replace a long docker run command with a Compose file and use a bind mount for fast iteration.
app.pyrequirements.txtDockerfilecompose.yaml.dockerignore
cd ../lab-03-compose-devIf you are not currently in lab-02-build-image, use the full path:
cd labs/lab-03-compose-devdocker compose up --buildExplanation:
docker compose upcreates the project network, builds images if needed, and starts services.--buildforces Compose to rebuild before starting.- In this lab there is only one service, but the Compose workflow is the same for larger stacks.
Open http://localhost:5000.
In another terminal:
docker compose ps
docker compose logs -f webExplanation:
psshows the services in the current project.logs -ftails logs for one service.- Compose scopes resources by project, which makes multi-container work easier to manage.
Change the message in app.py or in compose.yaml, then refresh the browser.
Explanation:
- This lab uses a bind mount, so the container sees your local files directly.
- That is good for development because you can iterate quickly.
- It is not how you should ship production images.
docker compose downExplanation:
downremoves containers and the project network.- It does not remove named volumes unless you add
-v.
- Declarative configuration.
- Easier repeatability.
- Cleaner service lifecycle management.
- A stepping stone to multi-service applications.
Path: labs/lab-04-compose-multi-service
Run a Flask app and Redis together, connected by Compose networking, with a persistent Redis volume.
app.pyrequirements.txtDockerfilecompose.yaml.dockerignore
cd ../lab-04-compose-multi-serviceOr:
cd labs/lab-04-compose-multi-servicedocker compose up --buildOpen http://localhost:5001.
Refresh the page several times.
What you should see:
- A counter increasing on each request.
- The app reporting that it is talking to Redis.
Explanation:
- The
webservice connects toredisby service name, not by IP address. - Compose creates a default network and registers service names on it.
- This is one of the biggest practical benefits of Compose.
In another terminal:
docker compose exec redis redis-cli get hitsExplanation:
docker compose execis the Compose-aware version ofdocker exec.- You are reading application state directly from the dependency container.
Stop the stack:
docker compose downStart it again:
docker compose upRefresh the page.
Explanation:
- The hit counter should continue because Redis data is stored in a named volume.
- Containers are transient, but the volume survives.
Now reset everything completely:
docker compose down -vExplanation:
-vremoves named volumes created by the project.- This is the difference between deleting containers and deleting state.
docker compose configExplanation:
- This shows the resolved configuration after interpolation and defaults.
- It is useful when debugging larger Compose files.
- Service discovery by service name.
- Multi-service orchestration.
- Persistent state with named volumes.
- The importance of cleanup choices.
Path: labs/lab-05-compose-productionish
Run a small stack with an app, Redis, and Nginx, using healthchecks, an env file, restart policies, and a debug profile.
app.pyrequirements.txtDockerfilecompose.yaml.env.env.examplenginx/default.conf.dockerignore
cd ../lab-05-compose-productionishOr:
cd labs/lab-05-compose-productionishThis lab includes a ready-to-run .env plus a matching .env.example template.
If you want to reset the lab back to the default values, run:
cp .env.example .envExplanation:
env_fileis a clean way to supply runtime configuration.- It keeps environment-specific values out of the main Compose file.
- The example file is version-safe to commit, while the real
.envis usually local.
docker compose up --build -dOpen http://localhost:8080.
Explanation:
proxypublishes port8080.- Nginx forwards requests to the internal
webservice. webdepends on a healthy Redis instance.
docker compose ps
docker compose logs -f proxy web redisExplanation:
psshould show the state of each service.- Healthchecks make startup order and readiness more explicit.
- Logs across multiple services help you trace request flow.
Start the debug service:
docker compose --profile debug up -d inspectorRun a network check from inside the Compose network:
docker compose exec inspector wget -qO- http://web:5000/healthExplanation:
- Profiles let you keep optional services out of the default startup path.
- This is useful for temporary tooling such as shells, seeders, or troubleshooting helpers.
docker compose downIf you want to remove persistent Redis data too:
docker compose down -venv_filefor configuration.- Healthchecks for dependency readiness.
- A reverse proxy service.
- Restart policies.
- An optional profile.
- A non-root application container.
Symptom:
Bind for 0.0.0.0:5000 failed
Fix:
- Stop the process already using that port, or change the left side of the port mapping.
Example:
ports:
- "5050:5000"Symptom:
- The container starts and stops right away.
Fix:
- Read the logs.
- Confirm the startup command is correct.
- Check environment variables and file paths.
Useful commands:
docker logs <container-name>
docker compose logs <service-name>Symptom:
- You edit files locally but the app output does not change.
Fix:
- For image-based labs, rebuild the image.
- For bind-mounted dev labs, confirm the volume is configured correctly.
Symptom:
- You thought you deleted everything, but old data still exists.
Fix:
- Remember that
docker compose downkeeps named volumes. - Use
docker compose down -vwhen you want a full reset.
Symptom:
- App container starts, but cannot connect to Redis or another dependency yet.
Fix:
- Add a healthcheck to the dependency.
- Use
depends_onwithcondition: service_healthywhen your Compose implementation supports it.
- Prefer
docker composeover olddocker-compose. - Keep Dockerfiles small and deterministic.
- Use
.dockerignore. - Separate development-only behavior from production images.
- Tag images meaningfully.
- Use named volumes for real state.
- Use
docker compose configwhen YAML behavior is confusing. - Clean up old containers and images regularly.
After finishing the labs, try these:
- Add PostgreSQL as another service and connect the Flask app to it.
- Add an
.envvariable that changes the Flask app behavior by environment. - Add a second app replica and put Nginx in front of it.
- Split the Compose file into a base file plus an override for development.
- Add a CI job that builds the Docker image automatically.
docker run -d --name demo -p 8080:80 nginx:alpine
docker ps
docker logs demo
docker exec -it demo sh
docker stop demo
docker rm demo
docker build -t myapp:v1 .
docker run --rm -p 5000:5000 myapp:v1
docker compose up --build
docker compose up -d
docker compose ps
docker compose logs -f
docker compose exec web sh
docker compose config
docker compose down
docker compose down -vIf you can explain why each command exists and when to use it, you have a solid beginner-to-intermediate Docker foundation.