Containerize a Python API with Docker
Build a containerized Flask API with PostgreSQL using Docker multi-stage builds.
Introduction
30 Second Summary
Every developer has heard the phrase "it works on my machine." Docker solves that problem by packaging your code, dependencies, and environment into a single portable unit called a container.
In this project, you will containerize a Python API connected to a database, orchestrate both services with a single command, and learn how to debug containers when things go wrong.
What You'll Build
A containerized Flask API connected to a PostgreSQL database, orchestrated with Docker Compose so the entire stack starts with a single command.
By the end of this project, you'll have:
- A production-grade multi-stage Dockerfile that separates build and runtime concerns into a lean Python image.
- A Docker Compose stack wiring your API to PostgreSQL with health checks, secrets, and named volumes.
- Hands-on experience debugging containers with tools like docker exec, docker logs, and docker inspect.
- Secret Mission: Add a Redis caching layer to your multi-service stack.
Are there any prerequisites?
You should have basic command line experience (navigating folders, running commands) and basic Python familiarity. No prior Docker knowledge is required.
Set Up Your Project
Before Docker can containerize anything, you need an application to containerize. In this step, you'll create the Flask API that will eventually run inside a container.
The app itself is deliberately simple. Three endpoints, a database connection, and a password reader. Docker is the star of this project, so the application code stays lean.
In this step, get ready to:
- Verify Docker is ready on your machine.
- Create the project folder and all required files.
- Write a Flask API with database-backed endpoints.
Verify Docker Desktop is running
Docker Desktop is already running in your system tray. Let's confirm both Docker Engine and Docker Compose are accessible from your terminal.
- In VS Code, open the integrated terminal by pressing Ctrl+`.
- Check your Docker Engine version by running this command:
docker --version
- Check your Docker Compose version by running:
docker compose version
You should see version numbers for both commands. The exact numbers depend on your Docker Desktop installation.
Why docker compose with a space?
Docker Compose v2 uses docker compose (with a space) as a subcommand of the Docker CLI. The older v1 syntax docker-compose (with a hyphen) is end-of-life. Always use the space form.
Seeing 'command not found'?
Make sure Docker Desktop is running. Check for the whale icon in your system tray. If it's not there, launch Docker Desktop and wait for it to finish starting before retrying.
help me troubleshoot Docker not being recognized in my terminal
Create the project folder structure
You need a dedicated folder on your Desktop to hold all the project files. This makes the project easy to find and keeps your Docker build context clean.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir docker-flask-api
cd docker-flask-api
- Create all the project files and the db/ subfolder by running these commands:
macOS
touch app.py requirements.txt .dockerignore Dockerfile compose.yaml
mkdir db
touch db/password.txt
Windows
New-Item app.py, requirements.txt, .dockerignore, Dockerfile, compose.yaml -ItemType File
mkdir db
New-Item db/password.txt -ItemType File
- Confirm the files exist by running:
ls
You should see app.py, requirements.txt, .dockerignore, Dockerfile, compose.yaml, and the db folder in the output.
Missing some files?
Double-check you ran the file creation commands from inside the docker-flask-api folder. Run pwd (macOS) or cd (Windows) to confirm your current directory ends with docker-flask-api.
help me figure out why my project files weren't created in the right folder
- Open the docker-flask-api folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the docker-flask-api folder, and click Open.
You should see all your project files listed in the Explorer sidebar on the left.
Write the Flask API code
Your application has two parts: a list of dependencies that Docker will install, and the Python code itself. Let's start with the dependency list.
- Open requirements.txt in the Explorer sidebar and add the following content:
flask==3.1.3
psycopg2-binary==2.9.11
What are these packages?
Flask is a lightweight Python web framework for building APIs. psycopg2-binary is the Python driver for connecting to PostgreSQL databases.
Pinning exact versions (==) ensures everyone building this image gets identical dependencies. No surprises across machines.
- Save requirements.txt.
- Open db/password.txt and add this single line:
mysecretpassword
Why store the password in a file?
Docker Compose mounts this file as a secret at runtime. Unlike plain environment variables, file-based secrets don't appear in docker inspect output or process listings. This is the production-recommended pattern for sensitive values.
- Save db/password.txt.
Now for the main application. You'll build app.py in two sections: the database helpers that manage connections, and the API routes that handle requests.
- Open app.py and add the imports, app initialization, and database helper functions:
import os
from flask import Flask, jsonify
import psycopg2
app = Flask(__name__)
def get_db_password():
# Read the database password from a mounted secrets file
password_file = os.environ.get("POSTGRES_PASSWORD_FILE", "")
if password_file and os.path.exists(password_file):
with open(password_file, "r") as f:
return f.read().strip()
return os.environ.get("POSTGRES_PASSWORD", "secret")
def get_db_connection():
# Connect to PostgreSQL using environment variables for configuration
return psycopg2.connect(
host=os.environ.get("POSTGRES_SERVER", "db"),
user=os.environ.get("POSTGRES_USER", "postgres"),
dbname=os.environ.get("POSTGRES_DB", "visits_db"),
password=get_db_password(),
)
def init_db():
# Create the visits table if it doesn't already exist
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.commit()
cur.close()
conn.close()
What does this code do?
get_db_password() reads the database password from a file mounted by Docker secrets. If the file doesn't exist, it falls back to an environment variable.
get_db_connection() uses environment variables like POSTGRES_SERVER and POSTGRES_DB to connect to PostgreSQL. Docker Compose will set these variables when it starts the container.
init_db() creates a visits table the first time the app starts. The IF NOT EXISTS clause makes this safe to run multiple times.
- Save app.py.
- You won't see output yet. The next section adds the API routes that use these database helpers.
- Still in app.py, add the API routes and entry point below the code you just wrote:
@app.route("/")
def health():
# Simple health check endpoint
return jsonify({"status": "healthy", "service": "flask-api"})
@app.route("/visits", methods=["POST"])
def create_visit():
# Record a new visit in the database
conn = get_db_connection()
cur = conn.cursor()
cur.execute("INSERT INTO visits DEFAULT VALUES RETURNING id, timestamp")
visit = cur.fetchone()
conn.commit()
cur.close()
conn.close()
return jsonify({"id": visit[0], "timestamp": str(visit[1])}), 201
@app.route("/visits", methods=["GET"])
def get_visits():
# Retrieve the 10 most recent visits from the database
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, timestamp FROM visits ORDER BY id DESC LIMIT 10")
rows = cur.fetchall()
cur.close()
conn.close()
visits = [{"id": row[0], "timestamp": str(row[1])} for row in rows]
return jsonify({"visits": visits, "count": len(visits)})
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=5000)
What do these routes do?
/ is a health check. It returns a simple JSON response so you can quickly confirm the API is alive.
POST /visits records a new visit with an auto-generated timestamp. GET /visits retrieves the 10 most recent entries from the database.
The if __name__ == "__main__" block calls init_db() to create the table, then starts Flask on port 5000. The host="0.0.0.0" makes the app accessible from outside the container.
- Save app.py.
- You won't see output yet. In the next step, you'll package this app into a Docker image and run it.
Seeing a red underline under psycopg2?
Your code editor might flag psycopg2 as unresolved because the package isn't installed locally. This is expected. Docker will install it inside the container when you build the image in the next step.
help me understand why my editor shows import errors for packages that Docker will install
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files: requirements.txt, app.py, and db/password.txt.
ⓧ I'd like to double check the full code
Here's the complete app.py for comparison:
import os
from flask import Flask, jsonify
import psycopg2
app = Flask(__name__)
def get_db_password():
password_file = os.environ.get("POSTGRES_PASSWORD_FILE", "")
if password_file and os.path.exists(password_file):
with open(password_file, "r") as f:
return f.read().strip()
return os.environ.get("POSTGRES_PASSWORD", "secret")
def get_db_connection():
return psycopg2.connect(
host=os.environ.get("POSTGRES_SERVER", "db"),
user=os.environ.get("POSTGRES_USER", "postgres"),
dbname=os.environ.get("POSTGRES_DB", "visits_db"),
password=get_db_password(),
)
def init_db():
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.commit()
cur.close()
conn.close()
@app.route("/")
def health():
return jsonify({"status": "healthy", "service": "flask-api"})
@app.route("/visits", methods=["POST"])
def create_visit():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("INSERT INTO visits DEFAULT VALUES RETURNING id, timestamp")
visit = cur.fetchone()
conn.commit()
cur.close()
conn.close()
return jsonify({"id": visit[0], "timestamp": str(visit[1])}), 201
@app.route("/visits", methods=["GET"])
def get_visits():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, timestamp FROM visits ORDER BY id DESC LIMIT 10")
rows = cur.fetchall()
cur.close()
conn.close()
visits = [{"id": row[0], "timestamp": str(row[1])} for row in rows]
return jsonify({"visits": visits, "count": len(visits)})
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=5000)
And here's requirements.txt:
flask==3.1.3
psycopg2-binary==2.9.11
And db/password.txt:
mysecretpassword
Your app code is ready and waiting. Next up, you'll write the Dockerfile that packages this application into a lean, portable container image.
Write a Multi-Stage Dockerfile
Your project structure is ready, and the Flask API code is written. Now it's time to package your application into a container image that can run anywhere.
A naive Dockerfile would install build tools and dependencies all in one layer, bloating your final image. Multi-stage builds solve this by separating the build environment from the runtime environment, producing a lean image that contains only what your app needs to run.
In this step, get ready to:
- Write a builder stage that installs Python dependencies into a virtual environment.
- Write a production stage that copies only the venv and app code into a slim base image.
- Create a .dockerignore file and build the image.
Write the builder stage
The builder stage is a temporary environment where you install all dependencies. It uses the full python:3.13 image, which includes compilers and build tools needed for packages like psycopg2-binary.
- In VS Code, open the empty Dockerfile in your docker-flask-api folder.
- Start writing the builder stage by pasting this code into the file:
# syntax=docker/dockerfile:1
# === Build stage: install dependencies ===
FROM python:3.13 AS builder
WORKDIR /app
RUN python -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
What does this code do?
- # syntax=docker/dockerfile:1 enables the latest BuildKit frontend features for your Dockerfile.
- FROM python:3.13 AS builder starts the build stage using the full Python image. The AS builder label lets you reference this stage later.
- RUN python -m venv /app/venv creates an isolated virtual environment so dependencies stay portable.
- COPY requirements.txt . copies only the requirements file first. Docker can cache this layer and skip it on future builds if requirements haven't changed.
- pip install --no-cache-dir installs packages without storing the pip download cache, keeping the layer smaller.
Why copy requirements.txt before the app code?
Docker caches each layer independently. If you copy all files first and then install, changing a single line in app.py would invalidate the cache and force a full reinstall of dependencies.
By copying requirements.txt separately, Docker reuses the cached install layer as long as your dependencies haven't changed. This makes rebuilds much faster.
- Save Dockerfile.
You won't see a change yet. The next chunk completes the Dockerfile with the production stage.
Write the production stage
The production stage starts from a much smaller base image. It copies only the virtual environment from the builder, leaving behind all the compilers and build tools.
- In Dockerfile, add the production stage below the builder stage by pasting this code:
# === Production stage: minimal runtime image ===
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PATH="/app/venv/bin:$PATH"
COPY --from=builder /app/venv /app/venv
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
What does this code do?
- FROM python:3.13-slim starts a fresh stage using the slim Python image. This image has no compilers or build tools, which dramatically reduces the final image size.
- PYTHONUNBUFFERED=1 ensures Python logs print immediately instead of being buffered. This is critical for debugging containerized apps.
- PYTHONDONTWRITEBYTECODE=1 prevents Python from writing .pyc files, which are unnecessary in containers.
- COPY --from=builder /app/venv /app/venv pulls the pre-built virtual environment from the builder stage. The build tools stay behind in the discarded builder.
- EXPOSE 5000 documents that the container listens on port 5000.
- CMD ["python", "app.py"] sets the default command that runs when the container starts.
- Save Dockerfile.
You won't see a change yet. The next substep creates the .dockerignore and builds the image.
Seeing a syntax error in VS Code?
Make sure you have exactly two FROM instructions in your Dockerfile. The first starts with FROM python:3.13 AS builder and the second with FROM python:3.13-slim. Each FROM begins a new stage.
Also check that COPY --from=builder has two hyphens before from, not one.
help me fix my multi-stage Dockerfile syntax
✔️ Awesome, I've got everything!
Great. Double check you've saved your Dockerfile before moving on.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile so far:
# syntax=docker/dockerfile:1
# === Build stage: install dependencies ===
FROM python:3.13 AS builder
WORKDIR /app
RUN python -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# === Production stage: minimal runtime image ===
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PATH="/app/venv/bin:$PATH"
COPY --from=builder /app/venv /app/venv
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
Create the .dockerignore file and build the image
A .dockerignore file tells Docker which files to exclude from the build context. This keeps the build fast and prevents sensitive files from ending up in your image.
- Open the empty .dockerignore file in your docker-flask-api folder.
- Define the exclusion rules by adding these entries:
__pycache__
*.pyc
.git
.env
venv/
db/
Why exclude db/ from the build context?
The db/ folder contains password.txt, which is your database password. You do not want it baked into the image layer. Instead, it gets mounted at runtime via Docker Compose secrets.
The other entries (__pycache__, venv/, .git) prevent unnecessary files from being sent to the Docker daemon, speeding up builds.
- Save .dockerignore.
Now let's build the image to confirm everything works.
- Build the Docker image by running this command in your terminal (make sure you're in the docker-flask-api folder):
docker build -t flask-api .
The first build downloads the base images, so it may take a minute or two. You should see build progress in your terminal ending with a line that says FINISHED.
- Confirm the image was created by running:
docker images flask-api
You should see a table with columns REPOSITORY, TAG, IMAGE ID, CREATED, and SIZE. The flask-api image should appear with the latest tag.
Why is the image so small?
The multi-stage build means your final image uses python:3.13-slim as its base. This image has no compilers, no build headers, and no pip cache.
The full python:3.13 image only existed in the builder stage, which was discarded after the virtual environment was copied out. You get all the dependencies without the build bloat.
Build failing with an error?
- Check that Docker Desktop is running. Look for its icon in your system tray.
- Make sure your terminal is in the docker-flask-api folder (the same folder that contains your Dockerfile). The . at the end of the build command tells Docker to use the current directory as the build context.
- Verify that requirements.txt exists in the same folder and contains flask==3.1.3 and psycopg2-binary==2.9.11 on separate lines.
help me debug my Docker build failure
✔️ Awesome, I've got everything!
Great. Double check you've saved both Dockerfile and .dockerignore before moving on.
ⓧ I'd like to double check the full code
Here is the complete .dockerignore:
__pycache__
*.pyc
.git
.env
venv/
db/
Your production image is built and lean. Next up, you'll wire this image to a PostgreSQL database using Docker Compose.
Orchestrate with Docker Compose
You have a working Docker image, but it runs in isolation. Your Flask API needs a PostgreSQL database to store data, and starting two containers manually with the right network settings would be tedious and error-prone.
Docker Compose solves this by letting you define your entire multi-service stack in a single YAML file. One command starts everything, wires the networking, and ensures services start in the correct order.
In this step, get ready to:
- Define a PostgreSQL database service with a health check and persistent storage.
- Define the API service with dependency ordering so it waits for the database.
- Start the full stack and test the API endpoints.
Define the database service
Every Compose file starts with a services: key. You will define the PostgreSQL service first because the API depends on it.
- In VS Code, open compose.yaml and paste the following code:
services:
db:
image: postgres:17
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=visits_db
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- "5432"
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
What does this code do?
The db service pulls the official postgres:17 image and runs as the postgres user. The restart: always policy means Docker restarts it automatically if it crashes.
The secrets section mounts the password from db/password.txt (the file you created earlier) into the container at /run/secrets/db-password. This is more secure than passing passwords as plain environment variables.
The healthcheck runs pg_isready every 10 seconds to confirm PostgreSQL is accepting connections. Other services can wait for this check to pass before starting.
The volumes section at the bottom declares a named volume called db-data that persists your database files even when containers are removed.
Why secrets instead of plain environment variables?
Environment variables are visible in docker inspect output and process listings. Anyone with access to the host can read them. File-based secrets are only mounted inside the container at a specific path, making them harder to accidentally expose.
- Save compose.yaml.
You won't see a change yet. The next substep adds the API service that completes the file.
Define the API service with dependency ordering
The API service builds your Dockerfile and connects to the database. The key detail is depends_on with condition: service_healthy. This tells Compose to wait until the database health check passes before starting the API.
- In compose.yaml, find the line that says db: and add the following code above it (below services:):
server:
build:
context: .
ports:
- "5000:5000"
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=visits_db
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
What does this code do?
The build: context: . tells Compose to build an image using the Dockerfile in the current directory. No need to run docker build separately.
The environment variables tell your Flask app how to find the database. Notice POSTGRES_SERVER=db. Compose automatically creates a network where services can reach each other by name. Your API connects to db as a hostname, and Docker resolves it to the database container's IP address.
The depends_on with condition: service_healthy prevents the API from starting until pg_isready reports success. Without this, the API would crash trying to connect to a database that hasn't finished starting.
- Make sure there is a blank line between the end of the server service and the start of the db: service.
- Save compose.yaml.
You won't see a change yet. The next substep starts the stack and verifies everything works.
YAML indentation looking off?
YAML is whitespace-sensitive. Each indentation level uses exactly 2 spaces. The server: and db: keys must be indented 2 spaces under services:. Their child keys (build, ports, etc.) must be indented 4 spaces from the left margin.
help me fix YAML indentation errors in my compose.yaml
✔️ Awesome, I've got everything!
Great. Double check you have saved compose.yaml.
ⓧ I'd like to double check the full code
services:
server:
build:
context: .
ports:
- "5000:5000"
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=visits_db
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
db:
image: postgres:17
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=visits_db
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- "5432"
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
Bring the stack up
Everything is defined. Time to start both services with a single command and see them work together.
- Start the entire stack by running this command in your terminal:
docker compose up --build
What does this command do?
The --build flag tells Compose to rebuild your image from the Dockerfile before starting. Without it, Compose would use a cached image and miss any code changes.
Compose starts the db service first, waits for its health check to pass, then starts the server service. You will see logs from both containers interleaved in your terminal.
Watch the output in your terminal. You should see PostgreSQL initializing, the health check passing, and then your Flask API starting up.
What should I see?
After about 10-20 seconds, you should see output showing the database is ready (a line mentioning database system is ready to accept connections) followed by your Flask app starting on port 5000.
- Open a second terminal window. Test the health endpoint by running:
curl http://localhost:5000/
You should see a JSON response: {"service":"flask-api","status":"healthy"}.
- Create a visit record by running:
curl -X POST http://localhost:5000/visits
You should see a JSON response with an id and timestamp confirming the visit was saved to PostgreSQL.
- Retrieve all visits by running:
curl http://localhost:5000/visits
You should see a JSON response listing the visit you just created, with a count of 1.
Seeing connection refused?
If curl returns a connection error, check the terminal running docker compose up. If you see the API crashing with a database connection error, the health check may not be configured correctly. Verify that depends_on has condition: service_healthy (not just depends_on: db without the condition).
Also confirm the ports mapping says "5000:5000" under the server service. Without this mapping, the container's port is not exposed to your host machine.
help me debug why my Docker Compose API is returning connection refused
Your multi-service stack is running. Both containers communicate over Docker's internal network, data persists in a named volume, and the health check ensures everything starts in the right order. Next up, you will learn how to debug and inspect these containers when things go wrong.
Debug and Inspect Containers
Your stack is running and serving requests. But what happens when something goes wrong inside a container?
In this step, you will use Docker's built-in debugging tools to peer inside your running containers, examine their network configuration, and intentionally break your health check to understand why it matters.
In this step, get ready to:
- Inspect container status, networks, and logs using Docker CLI commands.
- Get a shell inside a running container and explore its filesystem.
- Break and fix the health check to understand why dependency ordering matters.
Inspect running containers
Docker gives you several commands to check what your containers are doing without interrupting them. Think of it as reading the dashboard gauges while the engine is running.
- Check the status of your running stack by running this command:
docker compose ps
What does this show?
This command lists all containers in your Compose project. You will see columns including NAME, SERVICE, STATUS, and PORTS.
The db service should show a health status of "healthy" because of the pg_isready health check you configured. The server service shows its port mapping (5000:5000).
- Inspect the API container's network settings by running:
docker inspect docker-flask-api-server-1
What should I look for?
This outputs a large JSON object with everything Docker knows about the container. Scroll through it and look for the NetworkSettings section near the bottom.
Inside NetworkSettings.Networks, you will find the container's IP address on the Compose-created network. Both the server and db containers share this network, which is how they communicate using service names as hostnames.
- View the PostgreSQL startup logs by running:
docker compose logs db
What do the logs show?
You will see PostgreSQL's initialization output, including messages about the database system being ready to accept connections. These logs are helpful when diagnosing connection issues between your API and the database.
Not seeing two containers?
Make sure your stack is still running. If you stopped it earlier, bring it back up by running docker compose up --build from the docker-flask-api folder.
Help me troubleshoot my containers
Get inside a running container
Sometimes logs are not enough. You need to get a shell inside the container and look around, just like SSH-ing into a remote server.
- Open a shell inside the API container by running:
docker exec -it docker-flask-api-server-1 sh
What does this command do?
The docker exec command runs a new process inside an already-running container. The -it flags give you an interactive terminal. The sh at the end is the shell program to launch.
You are now inside the container. Your terminal prompt changes to show you are in a different environment.
- Check that the virtual environment exists, verify installed packages, and confirm the secrets file is mounted by running these commands:
ls /app/venv/bin
pip list
cat /run/secrets/db-password
What should I see?
The /app/venv/bin directory contains Python and pip executables. The pip list command shows only Flask, psycopg2-binary, and their dependencies. No build tools like gcc or make are present because the multi-stage build left those behind in the builder stage.
The cat /run/secrets/db-password command prints mysecretpassword, confirming that Docker mounted the secret file at runtime.
- Exit the container shell by running:
exit
You are back in your regular terminal. The container keeps running undisturbed.
Getting a "No such container" error?
The container name includes your project folder name. If your folder is named differently, run docker compose ps first to see the exact container names, then use that name in your docker exec command.
Help me find the correct container name
Intentionally break and fix the health check
The best way to understand why health checks matter is to see what happens without them. You will temporarily remove the health check, watch the race condition happen, then restore it.
- Stop the stack by running:
docker compose down
- In VS Code, open compose.yaml.
- Find the depends_on block under the server service. It currently looks like this:
depends_on:
db:
condition: service_healthy
- Replace those three lines with the short form, so it now looks like this:
depends_on:
- db
- Next, find the healthcheck block under the db service:
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
- Delete those five lines entirely.
- Save compose.yaml.
- Restart the stack by running:
docker compose up --build
Watch the terminal output carefully. The API container starts immediately, even though PostgreSQL has not finished initializing. You may see connection errors from the Flask app as it tries to reach a database that is not ready yet.
Why does this happen?
The short form depends_on: - db only waits for the db container to start. It does not wait for PostgreSQL to finish initialization and accept connections.
This is the race condition that condition: service_healthy solves. With a health check, Compose waits until pg_isready returns success before starting the dependent service.
- Stop the broken stack by pressing Ctrl+C in the terminal, then run:
docker compose down
- Now restore the health check. In compose.yaml, change the depends_on block under server back to the long form:
depends_on:
db:
condition: service_healthy
- Add the healthcheck block back under the db service (after the expose section):
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
- Save compose.yaml and bring the stack back up by running:
docker compose up --build
This time, watch the output. The db container starts first, Compose waits for its health check to pass, and only then does the server container start. No connection errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved compose.yaml with the health check fully restored.
ⓧ I'd like to double check the full code
Here is what your compose.yaml should look like with the health check restored:
services:
server:
build:
context: .
ports:
- "5000:5000"
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=visits_db
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
db:
image: postgres:17
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=visits_db
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- "5432"
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
Still seeing connection errors after restoring the health check?
Make sure you ran docker compose down before restarting. Old containers from the broken run can interfere. Also verify your YAML indentation matches the code above exactly.
Help me fix my restored health check
You now know how to get inside a container, inspect its network, read its logs, and understand why health checks prevent race conditions. Next up, you will put everything you have learned to use in a bonus challenge.
Secret mission
Add a Redis Cache Layer
Your API hits the database on every single GET request. In production, that gets expensive fast. Add a Redis caching layer as a third service in your Compose stack, so repeated reads return instantly from memory instead of querying PostgreSQL every time.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker Desktop, so there are no ongoing cloud costs. Running containers do consume RAM while active, so stopping them frees up system resources.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Running containers (server, db, redis) that consume RAM while active.
- Docker images (flask-api, postgres:17, redis:7) stored locally.
- Docker named volume db-data for PostgreSQL data, stored locally.
- Project files in the docker-flask-api folder on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your containerized stack.
- Your containers continue running and the API stays accessible at http://localhost:5000.
- If you closed your terminal, bring the stack back up in detached mode by running docker compose up -d from the docker-flask-api folder.
- Docker Desktop stays in your system tray and manages the containers in the background.
Pause - I'll come back to this later
Stop the containers to free up RAM, but keep all your data and images intact so you can restart quickly later.
- Stop all running containers without removing them or their data by running this command from the docker-flask-api folder:
docker compose stop
- Your containers, images, volumes, and project files all remain intact.
- To restart later, navigate to the docker-flask-api folder and run docker compose up.
Delete - I don't want to use this again
Remove all containers, volumes, images, and project files. This is a full teardown.
- Stop and remove all containers and the named volume by running this from the docker-flask-api folder:
docker compose down -v
- Remove the locally-built flask-api image:
docker rmi flask-api
- Optionally remove the pulled base images (postgres:17, redis:7, python:3.13, python:3.13-slim):
docker rmi postgres:17 redis:7 python:3.13 python:3.13-slim
- Delete the project folder. On macOS or Linux:
cd ~/Desktop
rm -rf docker-flask-api
On Windows (PowerShell):
cd ~\Desktop
Remove-Item -Recurse -Force docker-flask-api
Nice Work!
Nice Work!
Nice work! You just built and deployed a containerized multi-service application from scratch using Docker.
You've learned how to:
- Written a multi-stage Dockerfile that separates build dependencies from runtime, producing a lean production image for a Python Flask API.
- Orchestrated a multi-service stack with Docker Compose using health checks, named volumes, secrets, and proper dependency ordering.
- Debugged running containers using docker exec, docker logs, docker inspect, and docker compose ps to understand container internals and troubleshoot issues.
- Secret Mission: Added a Redis cache layer, coordinating three services with proper health checks and startup ordering.
Ready to quiz yourself?