π³ Part 4 β Docker + Docker Compose
Key idea: Docker eliminates βworks on my machine.β Every developer and every server runs the exact same environment.
Navigation
| β Part 3: Virtualenv & Testing | Part 5: PostgreSQL β |
1. What Docker Does
Without Docker, software behaves differently on different machines because of different OS versions, tool versions, and configuration. Docker solves this by packaging everything a service needs into an image, which runs as a container anywhere Docker is installed.
Image vs Container
| Concept | Analogy | Description |
|---|---|---|
| Image | Recipe | Blueprint for building a container. Read-only. Built once. |
| Container | Cooked meal | A running instance of an image. Can be started and stopped. |
2. FastAPI Dockerfile
A Dockerfile is a script of instructions for building a Docker image.
backend/Dockerfile
# Use the official Python 3.11 slim image as the base
# "slim" means it's stripped down β smaller file size, faster to download
FROM python:3.11-slim
# Set the working directory inside the container
# All subsequent commands run from /app
WORKDIR /app
# Copy requirements.txt first β before the rest of the code
# Docker caches layers; if requirements.txt hasn't changed, pip install is skipped
COPY requirements.txt .
# Install Python dependencies
# --no-cache-dir reduces image size by not caching the pip download files
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code into the container
COPY . .
# Tell Docker this container will listen on port 8000 (documentation only)
EXPOSE 8000
# The command to run when the container starts
# --host 0.0.0.0 means "listen on all network interfaces" (required inside Docker)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Line-by-line explanation
| Instruction | What it does |
|---|---|
FROM python:3.11-slim |
Start from an official Python 3.11 image |
WORKDIR /app |
Set the working directory inside the container |
COPY requirements.txt . |
Copy only requirements first (enables layer caching) |
RUN pip install ... |
Install Python dependencies |
COPY . . |
Copy all application code |
EXPOSE 8000 |
Documents that the container uses port 8000 |
CMD [...] |
The command to run when the container starts |
3. Build and Run Commands
The following commands build your image and run a container from it.
Build the image (run from the folder containing your Dockerfile):
docker build -t backend-app .
Run the container and map port 8000 on your machine to port 8000 inside the container:
docker run -p 8000:8000 backend-app
Access the API at http://localhost:8000/docs.
Tip:
-t backend-appgives the image a name (tag). Without it, youβd have to refer to it by its auto-generated ID.
4. Debugging Docker Containers
| Command | Purpose |
|---|---|
docker ps |
List running containers (id, name, ports) |
docker ps -a |
List all containers including stopped ones |
docker logs <container_id> |
View stdout/stderr output from a container |
docker stop <container_id> |
Gracefully stop a running container |
docker exec -it <container_id> bash |
Open an interactive shell inside the container |
Tip: You only need the first few characters of a container ID.
docker logs abc1works if thatβs enough to be unique.
5. Docker Networking
Inside Docker Compose, containers communicate using service names as hostnames, not localhost.
Why?
localhostinside a container refers to that container itself β not your host machine, not other containers.
| Where you are | How to reach another service |
|---|---|
| Your host machine | localhost:8000 |
| Inside the backend container | db:5432 (use the service name db) |
This is a very common source of confusion. Remember: use service names inside Docker Compose.
6. Docker Compose
Docker Compose lets you define and run multiple containers together using a single configuration file. Instead of running multiple docker run commands, you run one command.
docker-compose.yml
version: "3.9" # Docker Compose file format version
services:
# ββ Backend service ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
backend:
build: ./backend # Build from the backend/ folder's Dockerfile
ports:
- "8000:8000" # Map host port 8000 β container port 8000
environment:
- DB_HOST=db # Use the 'db' service name as the database host
- DB_NAME=appdb
- DB_USER=postgres
- DB_PASSWORD=password
depends_on:
- db # Start the db container before this one
# ββ Database service βββββββββββββββββββββββββββββββββββββββββββββββββββββ
db:
image: postgres:15 # Use the official PostgreSQL 15 image (no build needed)
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: appdb # Create this database on first start
volumes:
- postgres_data:/var/lib/postgresql/data # Persist data between container restarts
# Named volumes are managed by Docker; data survives container restarts
volumes:
postgres_data:
7. Connecting to the Docker PostgreSQL Instance
Once your Docker Compose stack is running (with docker compose up --build), the PostgreSQL database is live inside a container. You can connect to it from your local machine in two ways: using the psql command-line client, or using pgAdmin (the GUI tool covered in Part 5).
Pre-requisite: Ensure the
dbservice in yourdocker-compose.ymlexposes port 5432:db: image: postgres:15 ports: - "5432:5432" # Expose container port 5432 to your machine's port 5432If you add this, restart with
docker compose down && docker compose up --build.
Option A β Connect via psql (Command-Line)
psql is the official PostgreSQL command-line client. It comes bundled with a PostgreSQL installation, or you can install it standalone.
πͺ Windows β using psql
If you have PostgreSQL installed locally, psql is available in Git Bash or Command Prompt. Otherwise, install it from postgresql.org/download/windows.
psql -h localhost -p 5432 -U postgres -d appdb
# Password: password (as set in docker-compose.yml)
π Mac β using psql
Install via Homebrew if not already present:
brew install libpq
brew link --force libpq
Then connect:
psql -h localhost -p 5432 -U postgres -d appdb
π§ Linux β using psql
sudo apt install postgresql-client -y
psql -h localhost -p 5432 -U postgres -d appdb
Alternative: psql inside the container (no local install needed)
You can also open a shell directly inside the running container β no local PostgreSQL client needed:
# Get the container name (look for the postgres container)
docker compose ps
# Open psql inside the container
docker exec -it <container_name> psql -U postgres -d appdb
Replace <container_name> with the name shown by docker compose ps (e.g., my-project-db-1).
Once connected, you see the appdb=# prompt. Youβre inside PostgreSQL.
Option B β Connect via pgAdmin (GUI)
See Part 5, section 11 for full pgAdmin installation and connection instructions. Use:
- Host:
localhost - Port:
5432 - Database:
appdb - Username:
postgres - Password:
password
8. Step-by-Step CRUD Operations Inside Docker
Once connected to the running PostgreSQL instance (via psql or pgAdmin), you can run SQL commands to create, read, update, and delete data.
Step 1 β Create the table (first time only)
-- Create the users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
age INTEGER
);
Verify it was created:
-- In psql: list all tables
\dt
Or in pgAdmin, browse the table in the sidebar: Databases β appdb β Schemas β public β Tables.
Step 2 β CREATE (Insert data)
-- Insert a user
INSERT INTO users (name, email, age)
VALUES ('Alice', 'alice@example.com', 28);
-- Insert another user
INSERT INTO users (name, email, age)
VALUES ('Bob', 'bob@example.com', 34);
Output: INSERT 0 1 β one row was added each time.
Step 3 β READ (Query data)
-- Get all users
SELECT * FROM users;
Expected result:
id | name | email | age
----+-------+--------------------+-----
1 | Alice | alice@example.com | 28
2 | Bob | bob@example.com | 34
(2 rows)
-- Get only users older than 30
SELECT * FROM users WHERE age > 30;
-- Get a specific user by id
SELECT * FROM users WHERE id = 1;
Step 4 β UPDATE (Change data)
-- Update Alice's age
-- Always include WHERE β without it, every row is updated!
UPDATE users SET age = 29 WHERE id = 1;
Confirm:
SELECT * FROM users WHERE id = 1;
Step 5 β DELETE (Remove data)
-- Delete Bob
-- Always include WHERE β without it, every row is deleted!
DELETE FROM users WHERE id = 2;
Confirm:
SELECT * FROM users;
-- Only Alice should remain
Quit psql
\q
Or press Ctrl+D.
Persistence check: Stop and restart Docker Compose, then reconnect and run
SELECT * FROM users;β Alice should still be there. This confirms the volume is working.
9. Docker Compose Commands
| Command | Purpose |
|---|---|
docker compose up --build |
Build images (if changed) and start all services |
docker compose up -d |
Start services in the background (detached mode) |
docker compose down |
Stop and remove containers (data in volumes is kept) |
docker compose logs |
View logs from all services |
docker compose logs backend |
View logs from a specific service only |
docker compose ps |
List running Compose services |
10. Environment Variables
Never hardcode secrets or configuration. Use environment variables.
In docker-compose.yml, pass variables to a container:
environment:
- DB_HOST=db
- DB_NAME=appdb
- DB_USER=postgres
- DB_PASSWORD=password
In Python code, read them with os.getenv():
import os # Standard library module for interacting with the OS
# os.getenv("KEY", "default") returns the env var, or the default if not set
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_NAME = os.getenv("DB_NAME", "appdb")
DB_USER = os.getenv("DB_USER", "postgres")
DB_PASS = os.getenv("DB_PASSWORD", "password")
Why not hardcode? If you push a hardcoded password to GitHub, it becomes public. Environment variables keep secrets out of source code.
11. Real Docker Failures and Fixes
| Failure | Cause | Fix |
|---|---|---|
Connection refused from backend to db |
depends_on starts the container but doesnβt wait for PostgreSQL to be ready |
Add a retry loop in your app, or use a health-check based depends_on |
Port already in use |
Another process is using port 8000 | Stop the other process or change the port mapping |
| Container keeps restarting | Application crashes on start | Run docker compose logs <service> to read the error |
| Changes not reflected | Old image is cached | Run docker compose up --build to force rebuild |
Module not found |
Dependency not in requirements.txt |
Add it to requirements.txt and rebuild |
database "appdb" does not exist |
Volume from old run still exists | Run docker compose down -v to delete the volume, then restart |
12. Mini Project
- Write a
Dockerfilefor your FastAPI backend. - Build the image:
docker build -t my-backend . - Run it:
docker run -p 8000:8000 my-backend - Verify it works at
http://localhost:8000/docs - Write a
docker-compose.ymlwith your backend and a PostgreSQL database. - Start both with:
docker compose up --build - Connect to the running PostgreSQL container using psql or pgAdmin.
- Create the
userstable and insert at least two rows via CRUD operations.
π Push to GitHub when done. See Part 1 β section 1.11 for the push guide.
Exercises
- Add a
HEALTHCHECKinstruction to the Dockerfile. - Use
docker compose logs backendto find an error you introduced intentionally. - Change the database password in
docker-compose.ymland verify the backend can still connect.
Part 4 Summary
| Concept | Key Takeaway |
|---|---|
| Image vs container | Image is the blueprint; container is the running instance |
| Dockerfile | Step-by-step instructions to build a Docker image |
| Docker Compose | Orchestrates multiple containers with one config file |
| Networking | Use service names (not localhost) between containers |
| Environment variables | Never hardcode config β use os.getenv() |
| Connect to Docker DB | Expose port 5432 in Compose, then connect with psql or pgAdmin using localhost:5432 |
| CRUD in Docker | Use psql or pgAdmin to run INSERT, SELECT, UPDATE, DELETE against the containerised database |
Navigation
| β Part 3: Virtualenv & Testing | Part 5: PostgreSQL β |