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
| Term | Meaning | Analogy |
|---|---|---|
| Image | A read-only package: OS files, Python, your code, libraries | A recipe or a frozen meal |
| Container | A running instance of an image | A meal being served |
| Dockerfile | A text file with the steps to build an image | The recipe card |
| Registry | A place to store and download images (Docker Hub, GitHub Container Registry) | A warehouse |
| Volume | Storage that outlives a container | A fridge that stays when the plate is cleared |
| Compose | A YAML file describing several containers that work together | A 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
- 1Make a folder:
mkdir brewbean-dockerthencd brewbean-docker. - 2Inside it make a folder
appand an empty fileapp/__init__.py. - 3Create
requirements.txtandapp/main.pywith the code below.
fastapi
uvicorn[standard]
sqlalchemy
psycopg[binary]In a real project, pin versions (for example the output of pip freeze) so builds are repeatable.
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.
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.
.git
.venv
venv
__pycache__
*.pyc
.env
*.db
Dockerfile
docker-compose.ymlFiles 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
- 1Build:
docker build -t brewbean-api .(the dot means "use this folder"). The first build downloads the base image, so it takes a minute. - 2Run:
docker run --rm -p 8000:8000 --name brewbean brewbean-api. The-p 8000:8000flag maps your laptop's port 8000 to the container's port 8000. - 3In a second terminal test it:
curl http://localhost:8000/healthz. - 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). - 5Stop the container with Ctrl+C. Because of
--rm, it is deleted on exit, and so is the SQLite file inside it.
{"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
| Command | What it does |
|---|---|
docker ps | List running containers |
docker ps -a | List all containers, including stopped ones |
docker logs -f brewbean | Follow a container's logs |
docker exec -it brewbean sh | Open a shell inside a running container |
docker stop brewbean | Stop a container |
docker images | List images on your machine |
docker image rm brewbean-api | Delete an image |
docker system prune | Remove 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.
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
- 1From the project folder run
docker compose up --build. It builds the API image and starts both services. - 2Wait for a line from uvicorn saying it is running on
http://0.0.0.0:8000. - 3Open
http://localhost:8000/readyz. It should report the database as up. - 4Create an order with the
curl -X POSTcommand from Step 3, then openhttp://localhost:8000/ordersto see it. - 5Stop with Ctrl+C, then run
docker compose upagain (in the background with-dif you like). The order is still there, because of the volume. - 6To stop and remove the containers use
docker compose down. To also delete the database data usedocker compose down -v.
{"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
- Use a small, specific base image tag (
python:3.12-slim, notlatest). - Copy dependency files first, install, then copy code, to use the layer cache.
- Add a .dockerignore so
.git, virtual environments and.envare not copied. - Run as a non-root user.
- Read configuration from environment variables; one image runs in every environment.
- 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*.
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
- An image is a package of your code plus its runtime; a container is a running instance of it.
- Order Dockerfile steps from least to most frequently changing so the layer cache saves time.
- Bind your server to 0.0.0.0 inside a container and publish the port with -p.
- A container's filesystem is temporary: use volumes for database data.
- Compose runs several services on a private network; services reach each other by service name.
- 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.