Containerize a Multi-Service SaaS App
Build a containerized FastAPI, PostgreSQL, and Nginx stack with Docker Compose.
Introduction
30 Second Summary
When a startup ships a SaaS product, every developer on the team needs to run the exact same stack on their machine. Containers solve this by packaging your app and all its dependencies into a single, portable unit that runs identically everywhere.
In this project, you will containerize a multi-service application using Docker and Docker Compose. You will build a FastAPI server, connect it to a PostgreSQL database, and place an Nginx reverse proxy in front.
What You'll Build
A containerized "School Management API" with three services (API, database, reverse proxy) orchestrated by Docker Compose on your local machine.
By the end of this project, you'll have:
- A production-grade Dockerfile using multi-stage builds, a non-root user, and proper layer caching.
- A Docker Compose setup orchestrating three services with health-aware startup ordering and network isolation.
- Persistent data using named volumes that survive container restarts, plus full lifecycle commands to stop, restart, and destroy your stack.
- Secret Mission: Add a Redis caching layer as a fourth service and implement the cache-aside pattern for read-heavy endpoints.
Are there any prerequisites?
You should have basic Python and command-line experience. This project assumes you are running Ubuntu Linux with VS Code, Python, and Git already installed.
Install Docker Engine and Set Up the Project
To run a multi-service application in production, you need a way to package each service into an isolated, reproducible unit. Docker solves this by letting you define each service as a container that runs the same way on every machine.
In this step, you will install Docker Engine on your Ubuntu system and create the folder structure for your School Management API project.
In this step, get ready to:
- Install Docker Engine and Docker Compose on Ubuntu.
- Verify Docker works by running a test container.
- Create the project directory structure.
Install Docker Engine on Ubuntu
Docker Engine is the runtime that builds and runs containers. On Ubuntu, you install it from Docker's official apt repository to get the latest stable version.
- Set up Docker's apt repository by running these commands:
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
What does this do?
These commands download Docker's official GPG key (which proves the packages are genuine) and configure your system to pull packages from Docker's repository instead of the default Ubuntu repository.
The final sudo apt update refreshes your package list so your system knows about the Docker packages now available.
Seeing a permission error?
Make sure you type sudo before each command. These commands modify system-level files that require root access.
help me fix a permission error when setting up the Docker apt repository
- Install the Docker Engine packages by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
What are all these packages?
- docker-ce is the Docker Engine itself.
- docker-ce-cli is the command-line interface you use to interact with Docker.
- containerd.io is the container runtime that actually manages container processes.
- docker-buildx-plugin enables advanced image building features.
- docker-compose-plugin adds the docker compose command for orchestrating multi-container applications.
The installer will ask you to confirm the installation. Press Y and then Enter to proceed.
- Add your user to the docker group so you can run Docker commands without sudo:
sudo usermod -aG docker $USER
- Activate the group change by running:
newgrp docker
Why add yourself to the docker group?
By default, Docker commands require sudo because the Docker daemon runs as root. Adding your user to the docker group grants permission to communicate with the daemon directly.
The newgrp docker command applies the group change to your current terminal session without requiring a full logout.
Verify Docker and Docker Compose are working
Before writing any application code, you need to confirm that Docker Engine and Docker Compose are both functional.
- Run the hello-world test container by running:
docker run hello-world
You should see a message starting with "Hello from Docker!" followed by an explanation of what just happened. This confirms the Docker Engine can pull images and run containers.
Seeing a permission denied error?
If you see a message about connecting to the Docker daemon socket, the group change has not taken effect yet. Try logging out and logging back in to your terminal session, then run docker run hello-world again.
help me fix Docker permission denied errors after adding myself to the docker group
- Confirm Docker Compose is available by running:
docker compose version
You should see a version number printed (e.g. Docker Compose version v2.x.x). This confirms the Compose plugin installed correctly.
Why docker compose with a space?
Older tutorials reference docker-compose (with a hyphen). That was a separate Python binary. The modern version is a Go plugin integrated into the Docker CLI, invoked with docker compose (a space). This is what you installed via docker-compose-plugin.
- View Docker's system-wide configuration by running:
docker info
You should see details about your Docker installation including the storage driver, number of containers, and operating system info. This confirms everything is configured correctly.
Create the project directory structure
Your containerized application needs a clear folder structure. Each service gets its own directory to keep Dockerfiles, configuration, and source code organized.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and subdirectories by running these commands:
# Create the main project directory and service subdirectories
mkdir -p school-saas/api school-saas/nginx
cd school-saas
What does this structure look like?
- school-saas/ is the project root that will hold your compose.yaml and .env files.
- school-saas/api/ will contain the FastAPI application code and its Dockerfile.
- school-saas/nginx/ will hold the Nginx reverse proxy configuration.
- Confirm the directories exist by running:
ls
You should see api and nginx listed in the output.
Don't see both folders?
Make sure you ran cd school-saas after creating the directories. The ls command shows the contents of your current directory.
help me check that my school-saas project directories were created correctly
- Open the school-saas folder in VS Code by running:
code .
VS Code opens with the school-saas folder loaded in the Explorer sidebar. You should see the empty api and nginx folders listed.
Your Docker environment is ready and your project structure is in place. Next up, you will write the FastAPI application and build your first Docker image.
Build the FastAPI Application and Dockerfile
Docker is installed and your project structure is ready. Now it is time to build the application that will run inside your containers.
In this step, you will write a FastAPI application with CRUD endpoints for managing students, then package it into a production-ready Docker image using a multi-stage build.
In this step, get ready to:
- Write the FastAPI application with database connection and student endpoints.
- Write a multi-stage Dockerfile that produces a slim, secure production image.
- Build and inspect the Docker image to understand how layers work.
Write the FastAPI application
The API is the core of your SaaS application. It connects to PostgreSQL and exposes endpoints for creating and retrieving student records.
- Open the school-saas folder in VS Code by running this command:
code .
What does this command do?
The code . command opens the current directory in VS Code. Your terminal is already inside school-saas/, so VS Code opens with the full project visible in the Explorer sidebar.
- In the VS Code Explorer sidebar, right-click the api folder and select New File.
- Name the file requirements.txt and press Enter.
- Paste the following content into requirements.txt:
fastapi==0.139.0
uvicorn[standard]==0.34.0
psycopg2-binary==2.9.12
What does this file do?
This file pins the exact versions of your Python dependencies. Pinning versions ensures your container builds are reproducible.
- fastapi is the web framework for building the API.
- uvicorn is the ASGI server that runs the FastAPI app.
- psycopg2-binary is the PostgreSQL database adapter for Python.
- Save requirements.txt with Ctrl+S.
Now create the main application file.
- Right-click the api folder again and select New File.
- Name the file main.py and press Enter.
- Paste the following database connection layer into main.py:
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="School Management API")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
def get_db_connection():
conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
return conn
def init_db():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
grade VARCHAR(10) NOT NULL
)
""")
conn.commit()
cur.close()
conn.close()
What does this code do?
- DATABASE_URL reads the database connection string from an environment variable. Docker Compose will inject this later.
- get_db_connection() opens a connection to PostgreSQL using RealDictCursor, which returns rows as dictionaries instead of tuples.
- init_db() creates the students table if it does not already exist. This runs once when the app starts.
- Add the Pydantic models and app lifecycle code below the init_db function:
class StudentCreate(BaseModel):
name: str
email: str
grade: str
class StudentResponse(BaseModel):
id: int
name: str
email: str
grade: str
@app.on_event("startup")
def startup():
init_db()
@app.get("/health")
def health_check():
return {"status": "healthy"}
What does this code do?
- StudentCreate defines the shape of incoming request data (name, email, grade).
- StudentResponse defines what the API returns, including the auto-generated id.
- The startup event calls init_db() when the server first boots, ensuring the table exists before handling requests.
- The /health endpoint returns a simple status check. Docker will use this as a healthcheck later.
- Add the CRUD endpoints below the health_check function:
@app.post("/students", response_model=StudentResponse, status_code=201)
def create_student(student: StudentCreate):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"INSERT INTO students (name, email, grade) VALUES (%s, %s, %s) RETURNING id, name, email, grade",
(student.name, student.email, student.grade),
)
new_student = cur.fetchone()
conn.commit()
return new_student
except psycopg2.errors.UniqueViolation:
conn.rollback()
raise HTTPException(status_code=409, detail="Email already exists")
finally:
cur.close()
conn.close()
@app.get("/students", response_model=list[StudentResponse])
def list_students():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, name, email, grade FROM students ORDER BY id")
students = cur.fetchall()
cur.close()
conn.close()
return students
@app.get("/students/{student_id}", response_model=StudentResponse)
def get_student(student_id: int):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, name, email, grade FROM students WHERE id = %s", (student_id,))
student = cur.fetchone()
cur.close()
conn.close()
if not student:
raise HTTPException(status_code=404, detail="Student not found")
return student
What does this code do?
- POST /students inserts a new student and returns the created record. It catches duplicate emails and returns a 409 conflict.
- GET /students returns all students ordered by ID.
- GET /students/{student_id} fetches a single student or returns 404 if not found.
- The finally block ensures the database connection always closes, even if an error occurs.
- Save main.py with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Double check you have saved both api/requirements.txt and api/main.py.
ⓧ I'd like to double check the full code
Here is the complete api/requirements.txt:
fastapi==0.139.0
uvicorn[standard]==0.34.0
psycopg2-binary==2.9.12
Here is the complete api/main.py:
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="School Management API")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
def get_db_connection():
conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
return conn
def init_db():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
grade VARCHAR(10) NOT NULL
)
""")
conn.commit()
cur.close()
conn.close()
class StudentCreate(BaseModel):
name: str
email: str
grade: str
class StudentResponse(BaseModel):
id: int
name: str
email: str
grade: str
@app.on_event("startup")
def startup():
init_db()
@app.get("/health")
def health_check():
return {"status": "healthy"}
@app.post("/students", response_model=StudentResponse, status_code=201)
def create_student(student: StudentCreate):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"INSERT INTO students (name, email, grade) VALUES (%s, %s, %s) RETURNING id, name, email, grade",
(student.name, student.email, student.grade),
)
new_student = cur.fetchone()
conn.commit()
return new_student
except psycopg2.errors.UniqueViolation:
conn.rollback()
raise HTTPException(status_code=409, detail="Email already exists")
finally:
cur.close()
conn.close()
@app.get("/students", response_model=list[StudentResponse])
def list_students():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, name, email, grade FROM students ORDER BY id")
students = cur.fetchall()
cur.close()
conn.close()
return students
@app.get("/students/{student_id}", response_model=StudentResponse)
def get_student(student_id: int):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, name, email, grade FROM students WHERE id = %s", (student_id,))
student = cur.fetchone()
cur.close()
conn.close()
if not student:
raise HTTPException(status_code=404, detail="Student not found")
return student
Write the Dockerfile with a multi-stage build
A multi-stage build separates the build environment from the production environment. The first stage installs dependencies. The second stage copies only what is needed to run the app.
Why use a multi-stage build?
Without multi-stage builds, your image would include pip, compilers, and other build tools that are unnecessary at runtime. Multi-stage builds keep your production image small and reduce the attack surface.
This is the single most important Dockerfile optimization a Technical Architect needs to know.
- Right-click the api folder and select New File.
- Name the file Dockerfile (no extension) and press Enter.
- Paste the following multi-stage Dockerfile:
FROM python:3.12-slim-bookworm AS builder
WORKDIR /build
COPY requirements.txt .
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim-bookworm
WORKDIR /app
RUN addgroup --system appgroup && \
adduser --system --ingroup appgroup appuser
COPY --from=builder /opt/venv /opt/venv
COPY main.py .
ENV PATH="/opt/venv/bin:$PATH"
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
Stage 1 (builder):
- Starts from python:3.12-slim-bookworm and creates a virtual environment at /opt/venv.
- Installs all Python dependencies into the venv. This stage is thrown away after the build.
Stage 2 (production):
- Starts from a fresh python:3.12-slim-bookworm image with no build tools.
- Creates a non-root user (appuser) so the container does not run as root.
- Copies only the virtual environment and main.py into the final image.
- Sets the CMD to run uvicorn on port 8000.
- Save Dockerfile with Ctrl+S.
File named Dockerfile.txt by accident?
Some editors add a .txt extension automatically. Check the Explorer sidebar to confirm the file is named exactly Dockerfile with no extension. If it has one, right-click the file and select Rename to fix it.
help me rename my Dockerfile
✔️ Awesome, I've got everything!
Great. Make sure api/Dockerfile is saved and has no file extension.
ⓧ I'd like to double check the full code
Here is the complete api/Dockerfile:
FROM python:3.12-slim-bookworm AS builder
WORKDIR /build
COPY requirements.txt .
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim-bookworm
WORKDIR /app
RUN addgroup --system appgroup && \
adduser --system --ingroup appgroup appuser
COPY --from=builder /opt/venv /opt/venv
COPY main.py .
ENV PATH="/opt/venv/bin:$PATH"
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Build the image and inspect the layers
With the application code and Dockerfile written, you can now build your first Docker image and see how it is structured.
- Open the integrated terminal in VS Code by pressing Ctrl+`.
- Build the Docker image by running this command:
docker build -t school-api ./api
What does this command do?
- docker build reads the Dockerfile and builds an image from it.
- -t school-api tags (names) the image as school-api.
- ./api tells Docker to use the api/ directory as the build context (where it looks for the Dockerfile and source files).
You should see Docker pull the python:3.12-slim-bookworm base image, install your dependencies in the builder stage, then produce the final slim image.
- Verify the image was created by running:
docker images
You should see school-api listed with a latest tag and a size of approximately 150-200 MB.
- Inspect the image layers to understand how the image was built:
docker history school-api
What does docker history show?
Each row in the output represents a layer in your image. You can see the CREATED BY column showing which Dockerfile instruction created each layer, and the SIZE column showing how much disk space it adds.
Notice that only the production stage layers appear. The builder stage layers are not included in the final image, which is what makes multi-stage builds efficient.
Build failing with an error?
Make sure your terminal is in the school-saas/ directory (not inside api/). The command ./api is a relative path from school-saas/.
Also confirm that api/Dockerfile has no file extension and that api/requirements.txt is saved.
help me debug my Docker build
Your application is built and packaged into a Docker image. Next up, you will orchestrate this image alongside PostgreSQL and Nginx using Docker Compose.
Orchestrate Services with Docker Compose
In the previous step, you built a production-grade Docker image for your FastAPI application. That image can run in isolation, but your School Management API needs a PostgreSQL database and a reverse proxy in front of it to function like a real SaaS platform.
Docker Compose lets you define and run multiple containers as a single application stack. In this step, you will write a compose.yaml that wires together three services (database, API, and proxy) with networks, volumes, and health-aware startup ordering.
In this step, get ready to:
- Create a compose.yaml defining three services with healthchecks, networks, and a named volume.
- Write the Nginx reverse proxy configuration and environment variables file.
- Launch the full stack and verify all services are healthy and reachable.
Create the compose.yaml file
A compose.yaml file declares every service your application needs, how they connect to each other, and the order they should start in. Instead of running three separate docker run commands with long flag lists, you describe the desired state once and let Compose handle the rest.
- Create a file called compose.yaml in the root of your school-saas folder by running:
touch compose.yaml
- Open compose.yaml in VS Code and paste the following configuration:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
api:
build: ./api
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on:
db:
condition: service_healthy
networks:
- frontend
- backend
expose:
- "8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
proxy:
image: nginx:stable-alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
networks:
- frontend
volumes:
pgdata:
networks:
frontend:
backend:
What does this configuration do?
This file defines three services that form your application stack:
- db runs PostgreSQL 16. Its data is stored in a named volume (pgdata) so it survives container restarts. The healthcheck uses pg_isready to confirm the database is accepting connections.
- api builds from your ./api Dockerfile. It waits for db to be healthy before starting, preventing connection errors on boot.
- proxy runs Nginx and forwards traffic to the API. It only starts after api is healthy.
- Two networks isolate traffic. The backend network connects only db and api. The frontend network connects api and proxy. The proxy can never talk directly to the database.
Why two networks instead of one?
By default, Docker Compose puts all services on a single shared network. Every container can reach every other container. That works for development, but it violates the principle of least privilege.
Splitting into frontend and backend means the Nginx proxy has no route to PostgreSQL. If the proxy is ever compromised, the attacker cannot reach the database directly. This is the same network segmentation pattern used in production SaaS architectures.
- Save compose.yaml.
- Confirm the file exists by running:
ls compose.yaml
You should see compose.yaml listed in the output.
File not showing up?
- Make sure you created the file in the school-saas root folder, not inside api/ or nginx/.
- Run pwd to verify your terminal is in school-saas/.
Still stuck? Help me find where my compose.yaml ended up
✔️ Awesome, I've got everything!
Great. Make sure you have saved compose.yaml before moving on.
ⓧ I'd like to double check the full code
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
api:
build: ./api
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on:
db:
condition: service_healthy
networks:
- frontend
- backend
expose:
- "8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
proxy:
image: nginx:stable-alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
networks:
- frontend
volumes:
pgdata:
networks:
frontend:
backend:
Create the Nginx configuration and environment file
In production, you never expose your application server directly to the internet. Nginx sits in front as a reverse proxy, forwarding incoming HTTP requests to your API. This gives you a single entry point for TLS termination, rate limiting, and load balancing later.
- Open the nginx/ directory you created earlier and create a file called nginx.conf by running:
touch nginx/nginx.conf
- Open nginx/nginx.conf in VS Code and paste the following:
upstream api_server {
server api:8000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://api_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
What does this Nginx config do?
- upstream api_server defines a backend pool pointing to the api service on port 8000. Docker Compose DNS resolves the service name api to the container's IP automatically.
- listen 80 makes Nginx accept traffic on port 80 inside the container. The compose.yaml maps your host's port 80 to this.
- The proxy_set_header lines forward the original client's IP and protocol info so your API logs reflect the real visitor, not the proxy's internal IP.
- Save nginx/nginx.conf.
Seeing a permission denied error?
- Make sure the nginx/ directory already exists. If not, run mkdir -p nginx first.
- Check that you are in the school-saas/ directory, not inside api/.
Need more help? Help me troubleshoot my nginx.conf file creation
✔️ Awesome, I've got everything!
Great. Make sure nginx/nginx.conf is saved.
ⓧ I'd like to double check the full code
upstream api_server {
server api:8000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://api_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Now you need an environment file so Compose can inject database credentials into the services without hardcoding them in compose.yaml.
- Create a .env file in the school-saas root by running:
touch .env
- Open .env in VS Code and paste the following:
POSTGRES_USER=schooladmin
POSTGRES_PASSWORD=schoolpass123
POSTGRES_DB=school_db
DATABASE_URL=postgresql://schooladmin:schoolpass123@db:5432/school_db
What do these variables do?
- POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB are read by the official PostgreSQL image on first boot to create the database and user automatically.
- DATABASE_URL is the connection string your FastAPI app uses. The hostname db matches the service name in compose.yaml. Docker Compose resolves it to the database container's internal IP.
Docker Compose automatically loads a .env file from the same directory as compose.yaml. The ${VARIABLE} placeholders in your Compose file get replaced with these values at runtime.
- Save .env.
- Confirm both new files exist by running:
ls .env nginx/nginx.conf
You should see both file paths listed in the output.
One of the files is missing?
- If .env is missing, note that files starting with a dot are hidden by default. Run ls -a to see hidden files.
- If nginx/nginx.conf is missing, make sure you saved it inside the nginx/ subdirectory, not the project root.
Need help? Help me find my missing .env or nginx.conf file
✔️ Awesome, I've got everything!
Great. Make sure both .env and nginx/nginx.conf are saved before continuing.
ⓧ I'd like to double check the full code
POSTGRES_USER=schooladmin
POSTGRES_PASSWORD=schoolpass123
POSTGRES_DB=school_db
DATABASE_URL=postgresql://schooladmin:schoolpass123@db:5432/school_db
Launch the stack and verify all services
All three configuration files are in place. Time to bring the entire stack to life with a single command.
- Launch all services by running:
docker compose up --build
What does --build do?
The --build flag tells Compose to rebuild any images that have a build: directive before starting. Without it, Compose reuses a cached image even if you changed your Dockerfile or source code.
Compose pulls the PostgreSQL and Nginx images, builds your API image, then starts services in dependency order. You will see output showing each container being created, health checks running, and services starting.
Wait until you see the db container report Healthy, followed by the api container starting, then proxy starting. The terminal will stream logs from all three services.
- Open a second terminal tab (keep the Compose logs running in the first one).
- Test the API through the Nginx proxy by running:
curl http://localhost/health
You should see the response:
{"status":"healthy"}
This confirms that your request went through Nginx on port 80, was forwarded to the API on port 8000, and received a valid response.
- Check the status of all services by running:
docker compose ps
You should see all three services listed with a status of Up and (healthy).
- Inspect the combined logs by running:
docker compose logs
What should I see in the logs?
- db logs show PostgreSQL initializing the database and reporting ready to accept connections.
- api logs show uvicorn starting on port 8000 and the startup event creating the students table.
- proxy logs show Nginx starting its worker processes.
Services not starting or exiting immediately?
- If db fails, check your .env file has no extra spaces around the = signs. YAML reads ${POSTGRES_USER} exactly as written in .env.
- If api exits with a connection error, the healthcheck on db may not have passed yet. Run docker compose up --build again to retry with fresh containers.
- If proxy fails with a config error, double-check your nginx/nginx.conf syntax matches the code above exactly.
- If port 80 is already in use, stop the conflicting process or change the host port in compose.yaml to something like "8080:80".
Still stuck? Help me debug my Docker Compose services
Your three-service stack is running and reachable through a single URL. Next up, you will test data persistence, explore container networking, and practice the production lifecycle commands you will use every day as a Technical Architect.
Test Persistence, Networking, and Lifecycle
Your three-service stack is running, but a production system needs more than a first successful boot.
In this step, you will prove that your data survives container restarts, verify that network isolation keeps the database hidden from the proxy, and practice the lifecycle commands you will use every day as a Technical Architect.
In this step, get ready to:
- Test data persistence across container restarts using named volumes.
- Explore container networking and confirm network isolation between services.
- Practice production lifecycle commands for stopping, starting, and rebuilding services.
Verify data persistence with named volumes
A database is only useful if the data inside it survives a restart. Your compose.yaml mounts a named volume called pgdata to PostgreSQL's data directory. Let's prove it works.
- Create a student record through the Nginx proxy by running this command:
curl -X POST http://localhost/students \
-H "Content-Type: application/json" \
-d '{"name": "Alice Chen", "email": "alice@school.edu", "grade": "A"}'
What does this command do?
This sends a POST request to your API through the Nginx reverse proxy on port 80. The API inserts the student into PostgreSQL and returns the new record with an auto-generated id.
- You should see a JSON response like this:
{"id":1,"name":"Alice Chen","email":"alice@school.edu","grade":"A"}
- Confirm the record exists by fetching all students:
curl http://localhost/students
You should see an array containing your new student record.
- Now bring the entire stack down by running:
docker compose down
What does docker compose down do?
This stops and removes all containers and networks created by Compose. However, named volumes are preserved by default. Your pgdata volume still exists on disk with all of PostgreSQL's data files inside it.
- Bring the stack back up in detached mode by running:
docker compose up -d
- Wait about 15 seconds for all health checks to pass, then verify your data survived the restart:
curl http://localhost/students
You should see the same student record you created earlier. The named volume preserved your data across the full container lifecycle.
Getting an empty array or connection refused?
If you see an empty array, the volume may not have persisted. Run docker volume ls and confirm you see a volume with pgdata in the name.
If you see a connection error, the services may still be starting. Run docker compose ps and wait until all services show a healthy status before retrying.
help me debug why my Docker volume isn't persisting data across restarts
- Now test what happens when you explicitly destroy volumes. Bring the stack down with the -v flag:
docker compose down -v
- Bring the stack back up:
docker compose up -d
- Wait about 15 seconds for the health checks, then query the students endpoint:
curl http://localhost/students
You should see an empty array []. The -v flag deleted the pgdata volume, destroying all stored data.
Why does this matter in production?
This is exactly why production databases use managed services or external storage. A single docker compose down -v wipes everything. In development, the -v flag is a convenient reset. In production, treat it with extreme caution.
Explore container networking and DNS resolution
Your Compose file defines two networks: frontend (proxy + api) and backend (api + db). This means the proxy should be able to reach the API, but it should NOT be able to reach the database directly. Let's prove this.
- Test that the API container can reach the database by running:
docker compose exec api ping -c 3 db
You should see successful ping responses. The API and the database share the backend network, so Docker's internal DNS resolves db to the database container's IP address.
How does Docker DNS work here?
On user-defined bridge networks, Docker runs an embedded DNS server. Every service name in your compose.yaml becomes a hostname that resolves to that container's IP. This is why your application code connects to db:5432 instead of a hardcoded IP address.
- Now test that the proxy container CANNOT reach the database by running:
docker compose exec proxy ping -c 3 db
You should see the error ping: bad address 'db'. The proxy is only on the frontend network. It cannot resolve db because that service only exists on the backend network.
Why is network isolation important?
If an attacker compromises the Nginx proxy, they still cannot reach your database. The network boundary is a defense layer. This is the same principle used in production cloud architectures where databases sit in private subnets.
- List all Docker networks on your system by running:
docker network ls
You should see your two custom networks listed. Compose prefixes them with your project directory name, so they appear as school-saas_frontend and school-saas_backend.
- Inspect the backend network to see which containers are attached by running:
docker network inspect school-saas_backend
In the output, look at the Containers section. You should see only the api and db services listed. The proxy is absent from this network.
Seeing a different network name?
Docker Compose uses your project folder name as the prefix. If your folder is not named school-saas, the network names will differ. Check the output of docker network ls and use whatever name appears with _backend at the end.
help me find and inspect my Docker Compose networks
Practice production lifecycle commands
In production, you rarely tear down the entire stack. Instead, you stop, restart, or rebuild individual services. These are the commands you will use daily.
- Stop all containers without removing them by running:
docker compose stop
This halts the processes but keeps the containers and their filesystems intact. Unlike docker compose down, nothing is deleted.
- Restart the stopped services by running:
docker compose start
Your services resume exactly where they left off. Data and state are preserved because the containers were never removed.
- Restart just the API service by running:
docker compose restart api
When would you restart a single service?
After updating environment variables or when a service enters a bad state, you can restart just that one service without disturbing the database or proxy. This minimizes downtime.
- Rebuild the API image without affecting other services by running:
docker compose build api
This rebuilds only the api image from the Dockerfile. If you change main.py or requirements.txt, you run this command before bringing the service back up.
- Follow the live logs of the API service by running:
docker compose logs -f api
- You should see uvicorn's startup messages and any request logs. Press Ctrl+C to stop following.
Combine rebuild and restart
After making code changes, the common workflow is docker compose build api followed by docker compose up -d api. This rebuilds and restarts just the one service while the rest of the stack keeps running.
Service not responding after restart?
Run docker compose ps to check the status of all services. If any show as unhealthy or restarting, check the logs with docker compose logs <service> to see what went wrong.
help me troubleshoot a Docker Compose service that won't start
You have now validated every critical aspect of your containerized system: data persists across restarts, network isolation protects your database, and you know the lifecycle commands to manage individual services in production.
Secret mission
Add a Redis Caching Layer
Your API hits PostgreSQL on every single request. In production, read-heavy endpoints like listing students would hammer your database under load. Can you add a Redis caching layer as a fourth service and implement the cache-aside pattern to serve repeated reads from memory instead?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Running containers do consume RAM while active. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (4 services: db, redis, api, proxy) consuming RAM while running.
- Docker images (school-api built locally, plus pulled images postgres:16, redis:7-alpine, nginx:stable-alpine) stored on disk.
- Docker volume (pgdata) storing PostgreSQL data on your local disk.
- Project files (school-saas/ directory on your Desktop).
- Docker Engine installed via apt in Step 1.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the stack.
- Your containers are already running in the foreground. To keep them running in the background, stop them with Ctrl+C and restart in detached mode by running:
- Docker Engine remains installed and ready for your next project.
docker compose up -d
Your services will keep running in the background until you explicitly stop them.
Pause - I'll come back to this later
Stop your running containers to free up RAM. Your containers, images, volumes, and project files remain on disk so you can resume instantly.
- Stop all running containers by running:
docker compose stop
- When you are ready to resume, start the stopped containers by running:
docker compose start
Your data in the pgdata volume and all your project files stay intact.
Delete - I don't want to use this again
Remove all project resources and free up disk space. This is not reversible.
- Navigate to your project directory by running:
cd ~/Desktop/school-saas
- Remove all containers, named volumes, networks, and locally-built images by running:
docker compose down -v --rmi local
- Remove the pulled base images (postgres, redis, nginx) by running:
docker rmi postgres:16 redis:7-alpine nginx:stable-alpine python:3.12-slim-bookworm
- Delete the project directory by running:
cd ~/Desktop
rm -rf school-saas
- If you also want to completely remove Docker Engine from your system, run:
sudo apt purge docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo rm -rf /var/lib/docker
sudo rm -rf /var/lib/containerd
After these commands, all project resources and Docker data are removed from your machine.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a fully containerized multi-service application using Docker and Docker Compose. This is the exact architecture pattern that production SaaS platforms run on every day.
You've learned how to:
- Built a FastAPI application and containerized it with a production-grade multi-stage Dockerfile using a non-root user and optimized layer caching.
- Orchestrated three services (API, PostgreSQL, Nginx) with Docker Compose using health-aware startup ordering and depends_on conditions.
- Implemented network isolation so the database is only reachable from the API, and achieved data persistence with named volumes that survive container restarts.
- Secret Mission: Added Redis as a fourth service and implemented the cache-aside pattern with automatic cache invalidation on writes.
Ready to quiz yourself?