Backend Development
Hands-onIntermediate 45 min· Ship it · 17 of 20

Containerise your backend with Docker

Package a FastAPI backend into a Docker image, run it as a container, and use Docker Compose to start it together with a Postgres database. This is the standard way teams ship backends.

What you will build: a small Brew & Bean API packaged as a Docker image, then a Docker Compose setup that runs the API and a Postgres database with one command.

Prerequisites: Docker Desktop (Windows or macOS) or Docker Engine (Linux) installed; running docker --version should print a version. You should be comfortable with the terminal and have seen a FastAPI app before (see *Build an API with Python and FastAPI*). Docker Desktop on Windows needs virtualisation turned on in the BIOS and usually WSL 2; the installer guides you. If your laptop is low on RAM, close other apps first.

The classic problem: "it works on my machine". Your laptop has Python 3.12 and one set of libraries; the server has something different. A container solves this by bundling your code, the runtime and the libraries into one package that runs the same everywhere.

Images, containers and the shipping-container idea

Docker vocabulary

TermMeaningAnalogy
ImageA read-only package: OS files, Python, your code, librariesA recipe or a frozen meal
ContainerA running instance of an imageA meal being served
DockerfileA text file with the steps to build an imageThe recipe card
RegistryA place to store and download images (Docker Hub, GitHub Container Registry)A warehouse
VolumeStorage that outlives a containerA fridge that stays when the plate is cleared
ComposeA YAML file describing several containers that work togetherA menu for a whole meal

A container is not a full virtual machine. It shares the host's kernel, so it starts in about a second and uses far less memory. You can run many on a laptop.

Step 1: the app to package

Create the project

  1. 1Make a folder: mkdir brewbean-docker then cd brewbean-docker.
  2. 2Inside it make a folder app and an empty file app/__init__.py.
  3. 3Create requirements.txt and app/main.py with the code below.
requirements.txt
fastapi
uvicorn[standard]
sqlalchemy
psycopg[binary]

In a real project, pin versions (for example the output of pip freeze) so builds are repeatable.

app/main.py
import os

from fastapi import FastAPI
from pydantic import BaseModel
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine, insert, select, text

# The database address comes from an environment variable, not from the code
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./brewbean.db")
engine = create_engine(DATABASE_URL, pool_pre_ping=True)

metadata = MetaData()
orders = Table(
    "orders",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("customer_name", String(100), nullable=False),
    Column("item_id", String(50), nullable=False),
)

app = FastAPI(title="Brew & Bean API")


@app.on_event("startup")
def create_tables():
    metadata.create_all(engine)


class OrderIn(BaseModel):
    customer_name: str
    item_id: str


@app.get("/healthz")
def healthz():
    return {"status": "ok"}


@app.get("/readyz")
def readyz():
    with engine.connect() as conn:
        conn.execute(text("SELECT 1"))
    return {"status": "ok", "database": "up"}


@app.post("/orders", status_code=201)
def create_order(body: OrderIn):
    with engine.begin() as conn:
        result = conn.execute(insert(orders).values(**body.model_dump()))
    return {"id": result.inserted_primary_key[0], **body.model_dump()}


@app.get("/orders")
def list_orders():
    with engine.connect() as conn:
        rows = conn.execute(select(orders)).mappings().all()
    return [dict(r) for r in rows]

With no DATABASE_URL set it uses a local SQLite file; in Docker Compose we will point it at Postgres. The app code does not change between the two. (The on_event startup hook still works in current FastAPI; newer code may use lifespan handlers instead.)

Step 2: write the Dockerfile

A Dockerfile is read top to bottom. Each instruction creates a layer, and Docker caches layers. If nothing changed in a layer (and the layers above it), Docker reuses it. That is why we copy requirements.txt and install packages before copying the rest of the code: you edit code often but change dependencies rarely, so most rebuilds skip the slow install.

Dockerfile
FROM python:3.12-slim

# Don't write .pyc files; print logs immediately instead of buffering them
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# 1. Dependencies first (cached unless requirements.txt changes)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 2. Then the application code
COPY app ./app

# 3. Run as a normal user, not root
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

# 0.0.0.0 is essential: 127.0.0.1 inside a container is unreachable from outside
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Save as a file named exactly Dockerfile (no extension). The slim image is a smaller Debian-based Python. EXPOSE is documentation; publishing the port happens in docker run -p.

Why each choice matters:

python:3.12-slim is much smaller than the full image, so it downloads and deploys faster (useful on slow networks).

--no-cache-dir keeps pip's download cache out of the image.

USER appuser limits the damage if someone breaks into your app. Running as root inside a container is a common beginner mistake.

CMD uses the list (exec) form so uvicorn receives stop signals properly and shuts down cleanly.

.dockerignore
.git
.venv
venv
__pycache__
*.pyc
.env
*.db
Dockerfile
docker-compose.yml

Files listed here are not sent to Docker when you build. It keeps images small and keeps secrets like .env out of the image.

Never bake secrets into an image

Anything you COPY into an image can be read by anyone who gets the image. Do not copy .env files or put passwords in the Dockerfile. Pass secrets at run time as environment variables (or through your platform's secrets feature).

Step 3: build and run

Build the image and start a container

  1. 1Build: docker build -t brewbean-api . (the dot means "use this folder"). The first build downloads the base image, so it takes a minute.
  2. 2Run: docker run --rm -p 8000:8000 --name brewbean brewbean-api. The -p 8000:8000 flag maps your laptop's port 8000 to the container's port 8000.
  3. 3In a second terminal test it: curl http://localhost:8000/healthz.
  4. 4Create an order: curl -X POST http://localhost:8000/orders -H "Content-Type: application/json" -d '{"customer_name":"Rahul","item_id":"espresso"}' (on Windows PowerShell use curl.exe or the browser docs page at /docs).
  5. 5Stop the container with Ctrl+C. Because of --rm, it is deleted on exit, and so is the SQLite file inside it.
Output of the healthz request
{"status":"ok"}

That last point is important: a container's own filesystem is temporary. When the container is removed, your data is gone. Databases need a volume, which we add next. Also useful: open http://localhost:8000/docs in a browser to try the API.

Everyday Docker commands

CommandWhat it does
docker psList running containers
docker ps -aList all containers, including stopped ones
docker logs -f brewbeanFollow a container's logs
docker exec -it brewbean shOpen a shell inside a running container
docker stop brewbeanStop a container
docker imagesList images on your machine
docker image rm brewbean-apiDelete an image
docker system pruneRemove unused containers, networks and dangling images (asks first)

Step 4: Docker Compose with Postgres

A real backend has a database. You could run Postgres with a long docker run command and link them by hand, but Docker Compose lets you describe both in one file. Compose also creates a private network where each service can reach the others by its service name. So from the API, the database host is simply db, not localhost.

docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: brew
      POSTGRES_PASSWORD: brewpass
      POSTGRES_DB: brewbean
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U brew -d brewbean"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+psycopg://brew:brewpass@db:5432/brewbean
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

The password here is only for local practice. In production, use your host's secrets feature and a strong password. The pgdata volume keeps database files when containers are recreated.

Reading the file:

db uses the official postgres:16 image and creates a user, password and database from the environment variables.

volumes stores the data in a named volume pgdata, so it survives docker compose down.

healthcheck runs pg_isready repeatedly. depends_on with service_healthy makes the API wait until Postgres actually accepts connections, not merely until its container has started. Without this, the API often crashes on start because the database is not ready.

DATABASE_URL uses host db (the service name) and the driver postgresql+psycopg, which matches the psycopg package in requirements.txt.

Start everything

  1. 1From the project folder run docker compose up --build. It builds the API image and starts both services.
  2. 2Wait for a line from uvicorn saying it is running on http://0.0.0.0:8000.
  3. 3Open http://localhost:8000/readyz. It should report the database as up.
  4. 4Create an order with the curl -X POST command from Step 3, then open http://localhost:8000/orders to see it.
  5. 5Stop with Ctrl+C, then run docker compose up again (in the background with -d if you like). The order is still there, because of the volume.
  6. 6To stop and remove the containers use docker compose down. To also delete the database data use docker compose down -v.
What /readyz returns
{"status":"ok","database":"up"}

Reading errors

If the API container exits at start, run docker compose logs api. The last lines of the Python error usually name the problem: a wrong DATABASE_URL, a missing package in requirements.txt, or a typo in app.main:app.

Good habits

Dockerfile checklist

  1. Use a small, specific base image tag (python:3.12-slim, not latest).
  2. Copy dependency files first, install, then copy code, to use the layer cache.
  3. Add a .dockerignore so .git, virtual environments and .env are not copied.
  4. Run as a non-root user.
  5. Read configuration from environment variables; one image runs in every environment.
  6. Keep one main process per container; use Compose for the database, cache and so on.

How this comes up in interviews. Expect: what is the difference between an image and a container; why copy requirements before the code (layer caching); how data persists (volumes); how containers talk to each other (a shared network and service names); and why 0.0.0.0 instead of 127.0.0.1. Deploying the image to a server is covered in *Deploy a backend to the cloud*.

Prompt · Review my Dockerfile
Review this Dockerfile for a [FastAPI / Express] backend. Check for: image size, layer caching, running as root, secrets copied into the image, missing .dockerignore entries and anything that would break in production. Suggest a corrected version and explain each change in one sentence.

[paste Dockerfile]

What to expect: A good answer mentions copying dependency files first, a non-root user and a slim base image. Verify the suggested file builds before trusting it.

Try it: Add a second environment and a faster rebuild

1. Change app/main.py by adding a GET /menu route that returns two items, then run docker compose up --build. Watch the build output: which steps say CACHED? 2. Add a LOG_LEVEL environment variable to the api service in Compose and read it in main.py with os.getenv. 3. Run docker compose down -v and start again. Confirm your orders are gone and explain why.

Key takeaways

  1. An image is a package of your code plus its runtime; a container is a running instance of it.
  2. Order Dockerfile steps from least to most frequently changing so the layer cache saves time.
  3. Bind your server to 0.0.0.0 inside a container and publish the port with -p.
  4. A container's filesystem is temporary: use volumes for database data.
  5. Compose runs several services on a private network; services reach each other by service name.
  6. Never put secrets in an image; pass configuration through environment variables.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why does the Dockerfile copy requirements.txt and run pip install before copying the rest of the code?

2. Your API in a container is started with --host 127.0.0.1 and port 8000 is published, but the browser cannot reach it. What is the likely cause?

3. In docker-compose.yml, which host should the API use to reach the Postgres service named db?

4. You run docker compose down and later docker compose up. Your orders are still there. Which line is responsible?

5. Which is the safest way to give the container a database password in production?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free