Docker & Docker Compose: A Beginner’s Tutorial #
This tutorial introduces Docker and Docker Compose from first principles, using a simple example you can run yourself. By the end, you’ll understand containers, images, Dockerfiles, networking between containers, and how Compose ties multiple services together — concepts you’ll recognise from real projects like a web app + database + monitoring stack.
What problem does Docker solve? #
“It works on my machine” is the classic software problem: an app runs fine for you, but breaks for a teammate or on a server because of differences in OS version, installed libraries, or configuration.
Docker packages an application together with everything it needs to run — code, runtime, system tools, libraries — into a single unit called a container. That container runs identically on any machine that has Docker installed, whether it’s your laptop, a teammate’s laptop, or a cloud server.
Containers vs. virtual machines #
A common point of confusion is how containers differ from virtual machines (VMs):
| Virtual Machine | Container | |
|---|---|---|
| Includes a full OS kernel? | Yes | No — shares the host’s kernel |
| Startup time | Minutes | Seconds |
| Size | GBs | MBs–low GBs |
| Isolation | Very strong | Strong, but lighter-weight |
Containers are much lighter because they don’t boot an entire operating system — they share the host machine’s kernel and only isolate the application’s filesystem, processes, and network.
Key concepts #
- Image — A read-only template containing an application and everything it needs (like a class in programming). Images are built once and can be run many times.
- Container — A running instance of an image (like an object instantiated from a class). You can start, stop, and delete containers without affecting the underlying image.
- Dockerfile — A text file with step-by-step instructions for building an image (e.g. “start from Python 3.12, copy my code in, install dependencies”).
- Registry — A place to store and share images, e.g. Docker Hub.
- Volume — A mechanism for persisting data outside a container’s filesystem, so data survives even if the container is deleted.
Installing Docker #
Download Docker Desktop for your OS (macOS, Windows, or Linux) from docker.com. It includes the Docker engine, CLI, and Docker Compose.
Verify the install:
docker --version
docker compose versionYour first container #
Try running a public image with no setup at all:
docker run hello-worldThis pulls the hello-world image from Docker Hub (if you don’t have it
locally), creates a container from it, runs it, and prints a message.
A more practical example — running an Nginx web server:
docker run -d -p 8080:80 nginx-d— run in the background (“detached mode”)-p 8080:80— map port8080on your machine to port80inside the container (where Nginx listens by default)
Visit http://localhost:8080 in your browser — you should see the Nginx
welcome page.
Useful commands to know #
docker ps # list running containers
docker ps -a # list all containers, including stopped ones
docker stop <container_id> # stop a running container
docker rm <container_id> # remove a stopped container
docker images # list downloaded images
docker logs <container_id> # view a container's output/logsWriting your own Dockerfile #
Let’s containerize a tiny Python web app. We’ll need a few files for this: app.py, requirements.txt and Dockerfile.
app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello from inside a container!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)requirements.txt
flaskDockerfile
# Start from an official lightweight Python image
FROM python:3.12-slim
# Set the working directory inside the container
WORKDIR /app
# Copy dependency file first (better layer caching)
COPY requirements.txt .
RUN pip install -r requirements.txt
# Copy the rest of the application code
COPY . .
# Document which port the app listens on
EXPOSE 5000
# Command to run when the container starts
CMD ["python", "app.py"]Build and run it #
docker build -t my-flask-app .
docker run -d -p 5000:5000 my-flask-appdocker build -t my-flask-app .— build an image from the Dockerfile in the current directory (.), tagging itmy-flask-appdocker run -p 5000:5000 my-flask-app— run a container from that image on port 5000
Visit http://localhost:5000 — you should see your Flask app’s response.
Why layer order matters #
Docker builds images in layers, one per instruction, and caches each
layer. If a layer hasn’t changed since the last build, Docker reuses the
cached version instead of rebuilding it. That’s why requirements.txt is
copied and installed before the rest of the code: your dependencies change
far less often than your code does, so this ordering means pip install only
reruns when requirements.txt actually changes — not on every code edit.
Why Docker Compose? #
Real applications are rarely a single container. A typical app might need:
- A web server
- A database
- A cache
- A monitoring dashboard
Running each with individual docker run commands, remembering all the flags,
networking them together manually, and starting them in the right order
quickly becomes unmanageable.
Docker Compose lets you define your entire multi-container application in one YAML file, then bring it all up (or down) with a single command.
Your first docker-compose.yml file
#
Let’s extend the Flask example to include a Redis cache alongside it.
docker-compose.yml
services:
web:
build: . # build from the Dockerfile in this directory
ports:
- "5000:5000"
depends_on:
- redis
redis:
image: redis:alpine # pull a pre-built image, no Dockerfile neededRun it:
docker compose upThis single command builds the web image (if needed), pulls the redis
image, creates a shared network, and starts both containers.
Stop everything:
docker compose downUseful flags:
docker compose up -d # run in the background
docker compose up --build # force a rebuild of images
docker compose down -v # also remove volumes (careful — deletes data!)
docker compose logs <service> # view logs for one service
docker compose ps # list running servicesHow containers talk to each other #
This is the concept that trips up most beginners.
When you run multiple services with Compose, each service name becomes a hostname other services can use to reach it — Compose creates an internal network and registers DNS entries automatically.
So in the example above, code running inside the web container connects to
Redis using the hostname redis, not localhost:
# inside app.py, running in the web container
import redis
r = redis.Redis(host="redis", port=6379) # "redis" = the service nameCritical distinction:
| Where code runs | What localhost means |
|---|---|
| Your own machine | Your machine |
Inside the web container |
The web container itself — not Redis, not your machine |
If you tried host="localhost" inside the web container, it would fail to
connect — localhost inside a container only ever refers to that same
container’s own network namespace. This is why service-to-service
communication always uses the service name as the hostname, while your
own machine reaches a service via localhost:<published_port> (only if
that service publishes a port with ports:).
Persisting data with volumes #
Containers are ephemeral by design — delete a container, and any data written inside it is gone. For anything you want to survive restarts (a database’s data, for example), use a volume.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
volumes:
- db_data:/var/lib/postgresql/data # named volume
volumes:
db_data: # declared here, managed by Dockerdb_data:/var/lib/postgresql/data— Docker creates and manages a storage location on your host, and mounts it into the container at that path. Data written there survivesdocker compose down— it’s only deleted if you rundocker compose down -vordocker volume rm.
You can also use a bind mount to map a specific folder on your machine into a container — useful for live-reloading code during development:
services:
web:
build: .
volumes:
- ./app:/app # host_path:container_pathEnvironment variables and .env files
#
Hardcoding secrets (API keys, passwords) into a Dockerfile or
docker-compose.yml is bad practice — they’d end up committed to version
control. Instead, use environment variables.
.env (add this to .gitignore!)
DB_PASSWORD=supersecretdocker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}Docker Compose automatically reads a .env file in the same directory and
substitutes ${VAR} references at startup — no extra configuration needed.
A subtlety worth knowing: .env is shared between your local shell and
Compose’s variable substitution. If a variable needs a different value
inside a container versus on your host machine (a common case: a service
hostname like db inside Docker vs. localhost on your machine), don’t rely
on ${VAR} substitution for that one — set it as a literal value directly in
docker-compose.yml instead, so it can’t accidentally be overridden by
whatever’s in .env.
Startup order and health checks #
depends_on controls the order containers start in, but not whether the
service inside is actually ready to accept connections yet. A database
container can report as “started” long before Postgres has finished
initializing.
For services where readiness matters, combine depends_on with a
healthcheck:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
web:
build: .
depends_on:
db:
condition: service_healthy # wait for db's healthcheck to passNot every image ships with tools like curl or wget for a healthcheck
command to use — some minimal images have neither. In those cases, a common
fallback pattern is to add a small retry loop directly inside your
application’s startup code, so it waits and retries the connection itself
rather than depending on the orchestration layer to get the timing right.
A complete example: web app + database #
Putting it together — a Flask app, a Postgres database, and a named volume for persistence:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
retries: 5
web:
build: .
ports:
- "5000:5000"
environment:
DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
volumes:
db_data:Notice db:5432 in DATABASE_URL — db is the service name, resolved
automatically by Compose’s internal DNS, exactly as covered in “How Containers Talk to Each Other”.
Common beginner pitfalls #
- Using
localhostbetween containers — always use the service name instead;localhostinside a container only ever refers to itself. - Forgetting
0.0.0.0in your app’s bind address — an app that binds to127.0.0.1inside a container is only reachable from inside that container, even with a port published. Bind to0.0.0.0so it accepts connections from outside. - Expecting
depends_onalone to guarantee readiness — it only waits for the container process to start, not for the application inside to be ready. Pair it with a healthcheck or an application-level retry loop. - Single-file bind mounts behaving unreliably — mounting one specific file (rather than a whole directory) can be inconsistent across platforms. Prefer mounting a directory, or baking the file into a custom image at build time.
- Losing data unexpectedly —
docker compose down -vdeletes volumes. Leave off-vunless you specifically want a clean slate.
Resources #
This tutorial covers the core mental model of Docker and Compose. From here, topics like multi-stage builds, Docker networks beyond the Compose default, and production deployment (Kubernetes, ECS, etc.) build directly on these same fundamentals.