Skip to main content
  1. Guides/

Docker & Docker Compose: A Beginner's Tutorial

·1851 words·9 mins·

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 version

Your first container
#

Try running a public image with no setup at all:

docker run hello-world

This 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 port 8080 on your machine to port 80 inside 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/logs

Writing 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

flask

Dockerfile

# 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-app
  • docker build -t my-flask-app . — build an image from the Dockerfile in the current directory (.), tagging it my-flask-app
  • docker 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 needed

Run it:

docker compose up

This single command builds the web image (if needed), pulls the redis image, creates a shared network, and starts both containers.

Stop everything:

docker compose down

Useful 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 services

How 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 name

Critical 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 Docker
  • db_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 survives docker compose down — it’s only deleted if you run docker compose down -v or docker 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_path

Environment 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=supersecret

docker-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 pass

Not 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_URLdb 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 localhost between containers — always use the service name instead; localhost inside a container only ever refers to itself.
  • Forgetting 0.0.0.0 in your app’s bind address — an app that binds to 127.0.0.1 inside a container is only reachable from inside that container, even with a port published. Bind to 0.0.0.0 so it accepts connections from outside.
  • Expecting depends_on alone 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 unexpectedlydocker compose down -v deletes volumes. Leave off -v unless 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.

Angelo Varlotta
Author
Angelo Varlotta
If you can’t explain it simply, you don’t understand it well enough – Albert Einstein