Containerize a Full-Stack App

Run a React, FastAPI, and PostgreSQL app with one Docker Compose command.

Introduction

30 Second Summary

Every developer has heard "it works on my machine." Now imagine three services that all need to talk to each other, and every teammate needs the exact same setup.

In this project, you will containerize a full-stack task tracker application with a React frontend, FastAPI backend, and PostgreSQL database. All three services launch together with a single docker compose up command.

What You'll Build

A production-grade containerized application using multi-stage Docker builds, Docker Compose orchestration, Nginx reverse proxying, health checks, and persistent storage.

By the end of this project, you'll have:

  • Two multi-stage Dockerfiles that produce lean, secure images for the React frontend and FastAPI backend.
  • A Docker Compose configuration wiring three services together with health checks, dependency ordering, and a custom network.
  • A working Nginx reverse proxy that serves static files and routes API requests to the backend.
  • A Redis caching layer added as a fourth service to extend your Compose stack (Secret Mission).

Are there any prerequisites?

You should have basic Docker experience, like running containers and writing simple Dockerfiles. Some Python familiarity and comfort with REST APIs is also helpful.

You do not need Node.js or Python installed on your machine. Everything runs inside Docker containers.

Set Up the Project Structure

Before you can containerize an application, you need the application itself. In this step, you will create the full project from scratch on your local machine.

The task tracker has two halves: a FastAPI backend that stores tasks in a database, and a React frontend that displays them. You will create all the source files now, then containerize them in the next step.

In this step, get ready to:
  • Create the project directory structure with folders for the backend and frontend.
  • Build the FastAPI backend with CRUD endpoints and a health check.
  • Build the React frontend that displays tasks and lets you add new ones.
Create the project directory structure

Every containerized application starts with a clean folder structure. You will create separate directories for the backend and frontend so each service gets its own Dockerfile later.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and its subdirectories by running these commands:
mkdir task-tracker
cd task-tracker
mkdir backend, frontend, frontend/src
  • Confirm the folders exist by running:
ls

You should see backend and frontend listed in the output.

Don't see the folders?

Make sure you ran each command on its own line. If mkdir failed, confirm you are inside the task-tracker folder by running pwd.

Help me create my project directories on Windows.

Now verify that Docker is ready on your machine.

  • Check that Docker is installed by running:
docker --version
  • Check that Docker Compose is available by running:
docker compose version

You should see version numbers printed for both commands.

Seeing 'command not found'?

Make sure Docker Desktop is running. Look for the Docker whale icon in your system tray. If Docker Desktop is not installed, download it from docker.com/products/docker-desktop.

Help me get Docker running on my machine.

  • Open the task-tracker folder in VS Code by running:
code .

VS Code opens with your project folder loaded in the Explorer sidebar.

Create the backend application files

The backend is a FastAPI application that exposes a REST API for managing tasks. It connects to a PostgreSQL database using SQLAlchemy.

  • In VS Code's Explorer sidebar, right-click the backend folder and select New File.
  • Name the file requirements.txt and paste the following content:
fastapi==0.136.1
uvicorn[standard]==0.34.0
sqlalchemy==2.0.40
psycopg2-binary==2.9.10
pydantic==2.11.1

What does this file do?

This file lists every Python package the backend needs. When Docker builds the backend image, it reads this file to install the exact versions. Pinning versions ensures your container builds are reproducible.

  • Save requirements.txt.
  • Create a new file in the backend folder named database.py and paste the following content:
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

# Read the database URL from environment variables (Docker will set this)
DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql://taskuser:taskpass@db:5432/taskdb"
)

# Create the database engine with connection health checking
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

What does this code do?

  • DATABASE_URL reads the connection string from an environment variable. Docker Compose will inject this at runtime.
  • The default value postgresql://taskuser:taskpass@db:5432/taskdb uses db as the hostname. That is the service name in Docker Compose, which Docker resolves automatically.
  • pool_pre_ping=True checks if a database connection is alive before using it. This prevents errors when the database container restarts.
  • Save database.py.
  • Create a new file in the backend folder named models.py and paste the following content:
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from database import Base


class Task(Base):
    __tablename__ = "tasks"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(200), nullable=False)
    completed = Column(Boolean, default=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())

What does this code do?

This defines the Task model that maps to a tasks table in PostgreSQL. Each task has a title, a completed flag, and a timestamp. SQLAlchemy creates this table automatically when the app starts.

  • Save models.py.
  • Create a new file in the backend folder named main.py. This is the main application file. Paste the first section (imports, app setup, and data models):
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from database import engine, get_db, Base
from models import Task

# Create all database tables on startup
Base.metadata.create_all(bind=engine)

app = FastAPI(title="Task Tracker API")

# Allow requests from any origin (the frontend container will proxy through Nginx)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# Pydantic models for request/response validation
class TaskCreate(BaseModel):
    title: str


class TaskResponse(BaseModel):
    id: int
    title: str
    completed: bool

    model_config = {"from_attributes": True}

What does this code do?

  • Base.metadata.create_all(bind=engine) tells SQLAlchemy to create the tasks table if it does not exist yet. This runs every time the container starts.
  • The CORS middleware allows the frontend to make API requests. In production, you would lock this down to specific origins.
  • TaskCreate and TaskResponse are Pydantic models that validate incoming data and shape outgoing responses.
  • Now add the API endpoint functions below the Pydantic models in the same main.py file:

@app.get("/api/health")
def health_check(db: Session = Depends(get_db)):
    try:
        db.execute(text("SELECT 1"))
        return {"status": "healthy", "database": "connected"}
    except Exception:
        raise HTTPException(status_code=503, detail="Database unavailable")


@app.get("/api/tasks", response_model=list[TaskResponse])
def get_tasks(db: Session = Depends(get_db)):
    return db.query(Task).order_by(Task.created_at.desc()).all()


@app.post("/api/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate, db: Session = Depends(get_db)):
    db_task = Task(title=task.title)
    db.add(db_task)
    db.commit()
    db.refresh(db_task)
    return db_task


@app.patch("/api/tasks/{task_id}", response_model=TaskResponse)
def toggle_task(task_id: int, db: Session = Depends(get_db)):
    task = db.query(Task).filter(Task.id == task_id).first()
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task.completed = not task.completed
    db.commit()
    db.refresh(task)
    return task


@app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
    task = db.query(Task).filter(Task.id == task_id).first()
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    db.delete(task)
    db.commit()

What do these endpoints do?

  • /api/health pings the database and returns a status. Docker will use this endpoint for health checks.
  • /api/tasks (GET) returns all tasks sorted by newest first.
  • /api/tasks (POST) creates a new task.
  • /api/tasks/{task_id} (PATCH) toggles a task between completed and not completed.
  • /api/tasks/{task_id} (DELETE) removes a task permanently.
  • Save main.py.

Your backend folder should now contain four files: requirements.txt, database.py, models.py, and main.py.

Missing a file in the backend folder?

Check that you created each file inside the backend folder, not in the project root. In VS Code's Explorer sidebar, all four files should appear nested under backend/.

Help me organize my backend files correctly.

Awesome, I've got everything!

Double check that you have saved all four files in the backend folder.

I'd like to double check the full code

Here is backend/requirements.txt:

fastapi==0.136.1
uvicorn[standard]==0.34.0
sqlalchemy==2.0.40
psycopg2-binary==2.9.10
pydantic==2.11.1

Here is backend/database.py:

import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql://taskuser:taskpass@db:5432/taskdb"
)

engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Here is backend/models.py:

from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from database import Base


class Task(Base):
    __tablename__ = "tasks"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(200), nullable=False)
    completed = Column(Boolean, default=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())

Here is backend/main.py:

from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from database import engine, get_db, Base
from models import Task

Base.metadata.create_all(bind=engine)

app = FastAPI(title="Task Tracker API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


class TaskCreate(BaseModel):
    title: str


class TaskResponse(BaseModel):
    id: int
    title: str
    completed: bool

    model_config = {"from_attributes": True}


@app.get("/api/health")
def health_check(db: Session = Depends(get_db)):
    try:
        db.execute(text("SELECT 1"))
        return {"status": "healthy", "database": "connected"}
    except Exception:
        raise HTTPException(status_code=503, detail="Database unavailable")


@app.get("/api/tasks", response_model=list[TaskResponse])
def get_tasks(db: Session = Depends(get_db)):
    return db.query(Task).order_by(Task.created_at.desc()).all()


@app.post("/api/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate, db: Session = Depends(get_db)):
    db_task = Task(title=task.title)
    db.add(db_task)
    db.commit()
    db.refresh(db_task)
    return db_task


@app.patch("/api/tasks/{task_id}", response_model=TaskResponse)
def toggle_task(task_id: int, db: Session = Depends(get_db)):
    task = db.query(Task).filter(Task.id == task_id).first()
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task.completed = not task.completed
    db.commit()
    db.refresh(task)
    return task


@app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
    task = db.query(Task).filter(Task.id == task_id).first()
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    db.delete(task)
    db.commit()
Create the frontend application files

The frontend is a React application built with Vite. It displays tasks from the API and lets users add new ones. You do not need Node.js installed on your machine because Docker will handle the build inside a container.

  • In VS Code's Explorer sidebar, right-click the frontend folder and select New File.
  • Name the file package.json and paste the following content:
{
  "name": "task-tracker-frontend",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.4.1",
    "vite": "^6.3.4"
  }
}

What does this file do?

This defines the project metadata and dependencies for the frontend. When Docker builds the frontend image, it runs npm ci to install these packages inside the container. The build script compiles your React code into static files that Nginx can serve.

  • Save package.json.
  • Create a new file in the frontend folder named vite.config.js and paste the following content:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// Vite config for local development (proxy API to backend)
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://localhost:8000",
    },
  },
});

What does this config do?

The proxy setting is for local development only. In production, Nginx handles the API proxying instead. You will configure that in Step 2 when you write the Dockerfiles.

  • Save vite.config.js.
  • Create a new file in the frontend folder named index.html and paste the following content:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Task Tracker</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this file do?

This is the HTML entry point. Vite uses it during the build process to locate your JavaScript source. The div#root element is where React mounts the entire application.

  • Save index.html.
  • Create a new file inside the frontend/src folder named main.jsx and paste the following content:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./App.css";

// Mount the React app to the DOM
ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

What does this file do?

This is the React entry point. It renders the App component into the div#root element from index.html.

  • Save main.jsx.
  • Create a new file inside frontend/src named App.jsx and paste the following content:
import { useState, useEffect } from "react";

function App() {
  const [tasks, setTasks] = useState([]);
  const [newTask, setNewTask] = useState("");

  // Fetch all tasks from the API when the component loads
  useEffect(() => {
    fetchTasks();
  }, []);

  async function fetchTasks() {
    const res = await fetch("/api/tasks");
    const data = await res.json();
    setTasks(data);
  }

  // Handle form submission to create a new task
  async function addTask(e) {
    e.preventDefault();
    if (!newTask.trim()) return;
    await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: newTask }),
    });
    setNewTask("");
    fetchTasks();
  }

  // Toggle a task's completed status
  async function toggleTask(id) {
    await fetch(`/api/tasks/${id}`, { method: "PATCH" });
    fetchTasks();
  }

  // Delete a task permanently
  async function deleteTask(id) {
    await fetch(`/api/tasks/${id}`, { method: "DELETE" });
    fetchTasks();
  }

  return (
    <div className="container">
      <h1>Task Tracker</h1>
      <form onSubmit={addTask}>
        <input
          type="text"
          value={newTask}
          onChange={(e) => setNewTask(e.target.value)}
          placeholder="Add a new task..."
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {tasks.map((task) => (
          <li key={task.id} className={task.completed ? "completed" : ""}>
            <span onClick={() => toggleTask(task.id)}>{task.title}</span>
            <button onClick={() => deleteTask(task.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

What does this component do?

  • fetchTasks() calls the backend API to load all tasks when the page opens.
  • addTask() sends a POST request to create a new task, then refreshes the list.
  • toggleTask() sends a PATCH request to mark a task as complete or incomplete.
  • deleteTask() sends a DELETE request to remove the task.

All API calls go to /api/tasks. In production, Nginx will proxy these requests to the backend container automatically.

  • Save App.jsx.
  • Create the final file inside frontend/src named App.css and paste the following content:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #f5f5f5;
  color: #333;
}

.container {
  max-width: 600px;
  margin: 40px auto;
  padding: 20px;
}

h1 {
  text-align: center;
  margin-bottom: 20px;
  color: #1a1a2e;
}

form {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
}

input {
  flex: 1;
  padding: 10px 14px;
  border: 2px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
}

input:focus {
  outline: none;
  border-color: #4361ee;
}

button {
  padding: 10px 20px;
  background: #4361ee;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background: #3a56d4;
}

ul {
  list-style: none;
}

li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  background: white;
  border-radius: 6px;
  margin-bottom: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

li span {
  cursor: pointer;
  flex: 1;
}

li.completed span {
  text-decoration: line-through;
  color: #999;
}

li button {
  padding: 6px 12px;
  background: #e63946;
  font-size: 14px;
}

li button:hover {
  background: #c5303c;
}

What does this stylesheet do?

This gives the task tracker a clean, modern appearance. Completed tasks get a strikethrough style. The delete button uses a contrasting red color to signal a destructive action.

  • Save App.css.

Your frontend folder should now contain package.json, vite.config.js, and index.html at the top level, plus main.jsx, App.jsx, and App.css inside the src subfolder.

Files in the wrong place?

The three src/ files must be inside frontend/src/, not directly inside frontend/. If you accidentally put them in the wrong folder, drag them into the correct location in VS Code's Explorer sidebar.

Help me fix my frontend file structure.

Awesome, I've got everything!

Double check that you have saved all six frontend files in their correct locations.

I'd like to double check the full code

Here is frontend/package.json:

{
  "name": "task-tracker-frontend",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.4.1",
    "vite": "^6.3.4"
  }
}

Here is frontend/vite.config.js:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://localhost:8000",
    },
  },
});

Here is frontend/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Task Tracker</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Here is frontend/src/main.jsx:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./App.css";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Here is frontend/src/App.jsx:

import { useState, useEffect } from "react";

function App() {
  const [tasks, setTasks] = useState([]);
  const [newTask, setNewTask] = useState("");

  useEffect(() => {
    fetchTasks();
  }, []);

  async function fetchTasks() {
    const res = await fetch("/api/tasks");
    const data = await res.json();
    setTasks(data);
  }

  async function addTask(e) {
    e.preventDefault();
    if (!newTask.trim()) return;
    await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: newTask }),
    });
    setNewTask("");
    fetchTasks();
  }

  async function toggleTask(id) {
    await fetch(`/api/tasks/${id}`, { method: "PATCH" });
    fetchTasks();
  }

  async function deleteTask(id) {
    await fetch(`/api/tasks/${id}`, { method: "DELETE" });
    fetchTasks();
  }

  return (
    <div className="container">
      <h1>Task Tracker</h1>
      <form onSubmit={addTask}>
        <input
          type="text"
          value={newTask}
          onChange={(e) => setNewTask(e.target.value)}
          placeholder="Add a new task..."
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {tasks.map((task) => (
          <li key={task.id} className={task.completed ? "completed" : ""}>
            <span onClick={() => toggleTask(task.id)}>{task.title}</span>
            <button onClick={() => deleteTask(task.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

Here is frontend/src/App.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #f5f5f5;
  color: #333;
}

.container {
  max-width: 600px;
  margin: 40px auto;
  padding: 20px;
}

h1 {
  text-align: center;
  margin-bottom: 20px;
  color: #1a1a2e;
}

form {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
}

input {
  flex: 1;
  padding: 10px 14px;
  border: 2px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
}

input:focus {
  outline: none;
  border-color: #4361ee;
}

button {
  padding: 10px 20px;
  background: #4361ee;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background: #3a56d4;
}

ul {
  list-style: none;
}

li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  background: white;
  border-radius: 6px;
  margin-bottom: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

li span {
  cursor: pointer;
  flex: 1;
}

li.completed span {
  text-decoration: line-through;
  color: #999;
}

li button {
  padding: 6px 12px;
  background: #e63946;
  font-size: 14px;
}

li button:hover {
  background: #c5303c;
}

Your full-stack application code is ready. Next up, you will write multi-stage Dockerfiles that package this code into lean, production-grade container images.

Write the Multi-Stage Dockerfiles

Your project structure is ready with a FastAPI backend and React frontend. Right now, these files only live on your machine. To run them anywhere, you need to package each service into a container image.

Docker multi-stage builds let you separate the build environment from the runtime environment. The result is smaller, more secure images that leave build tools behind.

In this step, get ready to:
  • Write a multi-stage Dockerfile for the FastAPI backend with a non-root user and health check.
  • Write a multi-stage Dockerfile for the React frontend with Nginx as a reverse proxy.
  • Build both images and verify they are under 200 MB each.
Create the backend Dockerfile

A typical Python Docker image with all build tools included can be over 1 GB. A multi-stage build installs dependencies in a temporary "builder" stage, then copies only the installed packages into a slim production image.

What is a multi-stage Docker build?

A multi-stage build uses multiple FROM statements in a single Dockerfile. Each FROM starts a new stage with a fresh filesystem.

The first stage handles heavy lifting like installing dependencies. The final stage copies only the finished artifacts into a minimal base image. Everything else (compilers, package managers, source code) is discarded.

  • Create a new file called Dockerfile inside the backend/ folder and paste the following code:
# Stage 1: Install dependencies
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production image
FROM python:3.12-slim

WORKDIR /app

# Install curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application code
COPY . .

# Create non-root user
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/api/health || exit 1

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

This Dockerfile has two stages:

  • Stage 1 (builder) uses python:3.12-slim to install all Python packages from requirements.txt into a custom /install directory using --prefix.
  • Stage 2 (production) starts fresh from python:3.12-slim, installs curl for health checks, then copies the installed packages from the builder stage with COPY --from=builder.
  • The useradd and USER appuser lines create a non-root user so the container does not run as root. This is a security best practice.
  • The HEALTHCHECK instruction tells Docker to periodically hit /api/health to confirm the app is running. Docker Compose uses this to decide when the backend is ready.
  • Save backend/Dockerfile.

You will not see a change yet. The next substep adds the .dockerignore file, and you will build the image in the final substep.

Seeing a red underline or syntax warning in VS Code?

VS Code may not recognize Dockerfile syntax by default. Make sure the file is named exactly Dockerfile with no file extension. If VS Code still shows warnings, you can ignore them safely.

Help me fix a Dockerfile syntax warning in VS Code.

When Docker builds an image, it sends every file in the build directory to the Docker engine. A .dockerignore file tells Docker which files to skip, keeping the build context small and preventing sensitive files from leaking into images.

  • Create a new file called .dockerignore inside the backend/ folder and paste the following:
__pycache__
*.pyc
.venv
.env
.git
.gitignore
Dockerfile
.dockerignore

Why exclude these files?

  • __pycache__ and *.pyc are compiled Python bytecode files. Docker rebuilds these inside the container.
  • .env may contain secrets. Excluding it prevents credentials from being baked into the image.
  • The Dockerfile itself and .git are not needed at runtime. Skipping them speeds up the build.
  • Save backend/.dockerignore.

Awesome, I've got everything!

Great. Double check you have saved both files in the backend/ folder.

I'd like to double check the full code

Here is the complete backend/Dockerfile:

# Stage 1: Install dependencies
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production image
FROM python:3.12-slim

WORKDIR /app

# Install curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application code
COPY . .

# Create non-root user
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/api/health || exit 1

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

And the complete backend/.dockerignore:

__pycache__
*.pyc
.venv
.env
.git
.gitignore
Dockerfile
.dockerignore
Create the frontend Dockerfile and Nginx config

The React frontend needs a different build pipeline. Vite compiles the React source code into static HTML, CSS, and JavaScript files. Those static files then need a web server to serve them to the browser.

Nginx is a lightweight web server that handles two jobs here: serving the built React files and proxying API requests from the browser to the FastAPI backend. You will configure Nginx first, then write the Dockerfile that uses it.

Why does Nginx proxy API requests?

The React app makes fetch requests to /api/tasks. In production, the browser sends those requests to the same server that served the page. Nginx intercepts anything starting with /api/ and forwards it to the backend container over Docker's internal network.

This means the backend port never needs to be exposed to the host machine. Only the Nginx entry point is public.

  • Create a new file called nginx.conf inside the frontend/ folder and paste the following configuration:
server {
    listen 80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    # Health check endpoint for Docker
    location /health {
        access_log off;
        return 200 'OK';
        add_header Content-Type text/plain;
    }

    # Proxy API requests to the backend
    location /api/ {
        proxy_pass http://backend:8000;
        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;
    }

    # Serve React app and handle client-side routing
    location / {
        try_files $uri $uri/ /index.html;
    }
}

What does this Nginx config do?

  • location /health creates a lightweight endpoint that Docker can ping to verify the container is alive. It returns a plain 200 OK without logging the request.
  • location /api/ matches any request starting with /api/ and forwards it to the FastAPI backend at http://backend:8000. The name backend is resolved by Docker's internal DNS to the backend container's IP address.
  • location / serves the React static files. The try_files directive falls back to index.html for any unknown path. This is required for React's client-side routing to work on page refresh.
  • Save frontend/nginx.conf.

You will not see a change yet. This config gets copied into the Docker image in the next step.

Now you need the Dockerfile that uses this Nginx config. The frontend Dockerfile has two stages: a Node.js stage that builds the React app into static files, and an Nginx stage that serves them.

  • Create a new file called Dockerfile inside the frontend/ folder and paste the following code:
# Stage 1: Build React app
FROM node:24-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 2: Serve with Nginx
FROM nginx:1.27-alpine

# Remove default config
RUN rm /etc/nginx/conf.d/default.conf

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy built static files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1

CMD ["nginx", "-g", "daemon off;"]

What does this Dockerfile do?

  • Stage 1 (builder) uses node:24-alpine to install npm packages and run npm run build. Vite compiles the React code into static files in a dist/ folder.
  • Stage 2 starts from nginx:1.27-alpine, a tiny image that only knows how to serve files over HTTP. It replaces the default Nginx config with your custom nginx.conf, then copies the built files from Stage 1.
  • The package-lock.json* wildcard on the COPY line means Docker will not error if the lock file does not exist yet. The npm ci command installs dependencies from the lock file for reproducible builds.
  • The health check uses wget instead of curl because the nginx:alpine image ships with wget but not curl.
  • Save frontend/Dockerfile.

Finally, the frontend also needs a .dockerignore to keep the build context clean. This is especially important because node_modules can be hundreds of megabytes.

  • Create a new file called .dockerignore inside the frontend/ folder and paste the following:
node_modules
dist
.git
.gitignore
Dockerfile
.dockerignore
README.md

Why exclude node_modules and dist?

node_modules is rebuilt inside the container by npm ci. Sending it to Docker would waste time and bandwidth. dist is the build output folder. Docker creates it fresh during the build stage, so any local copy would just be stale.

  • Save frontend/.dockerignore.
  • Confirm all three new frontend files exist by running this in your terminal:
ls frontend/Dockerfile, frontend/nginx.conf, frontend/.dockerignore

You should see all three file names listed in the output.

Awesome, I've got everything!

Great. Double check you have saved all three files in the frontend/ folder.

I'd like to double check the full code

Here is the complete frontend/Dockerfile:

# Stage 1: Build React app
FROM node:24-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 2: Serve with Nginx
FROM nginx:1.27-alpine

# Remove default config
RUN rm /etc/nginx/conf.d/default.conf

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy built static files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1

CMD ["nginx", "-g", "daemon off;"]

Here is the complete frontend/nginx.conf:

server {
    listen 80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    # Health check endpoint for Docker
    location /health {
        access_log off;
        return 200 'OK';
        add_header Content-Type text/plain;
    }

    # Proxy API requests to the backend
    location /api/ {
        proxy_pass http://backend:8000;
        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;
    }

    # Serve React app and handle client-side routing
    location / {
        try_files $uri $uri/ /index.html;
    }
}

And the complete frontend/.dockerignore:

node_modules
dist
.git
.gitignore
Dockerfile
.dockerignore
README.md
Build each image and compare sizes

Both Dockerfiles are ready. Time to build the images and see the size savings from multi-stage builds.

  • Make sure your terminal is in the task-tracker/ project root folder (the parent of backend/ and frontend/).
  • Build the backend image by running this command:
docker build -t tasktracker-backend ./backend

What does this command do?

  • -t tasktracker-backend tags (names) the image so you can reference it later.
  • ./backend tells Docker to use the backend/ folder as the build context. Docker looks for a Dockerfile in that folder automatically.

The first build takes a minute or two while Docker downloads the python:3.12-slim base image and installs packages. Subsequent builds are much faster because Docker caches each layer.

Seeing an error during the backend build?

  • If you see failed to read dockerfile, make sure your file is named exactly Dockerfile (capital D, no extension) inside the backend/ folder.
  • If you see a pip error about packages not being found, double check that requirements.txt exists in the backend/ folder with the correct package names.
  • Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.

Help me debug a Docker build error for my backend image.

  • Build the frontend image by running this command:
docker build -t tasktracker-frontend ./frontend

This build takes longer because npm needs to download all the React dependencies and then compile the app.

Frontend build failing with an npm error?

  • If you see an error about package-lock.json, open frontend/Dockerfile and change RUN npm ci to RUN npm install for the first build. The npm ci command requires a lock file, which does not exist yet since you created package.json manually.
  • If you see failed to read dockerfile, confirm the file is named Dockerfile inside the frontend/ folder.

Help me fix my frontend Docker build error.

Both images are built. Now check how big they are.

  • List your Docker images by running this command:
docker images tasktracker-backend
docker images tasktracker-frontend

You should see both images listed with their sizes. The backend image should be around 170-200 MB. The frontend image should be around 40-50 MB.

Why are these images so small?

Without multi-stage builds, a single-stage Python image with build tools can reach over 1 GB. A single-stage Node.js image with node_modules can exceed 760 MB.

Your multi-stage frontend image is under 50 MB because the final Nginx Alpine stage contains only the compiled HTML, CSS, and JavaScript files. Node.js and all build tools are discarded with the builder stage. Smaller images download faster, start faster, and have a smaller attack surface.

Images larger than expected?

Make sure your .dockerignore files are saved in the correct folders. Without them, Docker copies everything (including node_modules, .git, and other junk) into the build context.

Help me figure out why my Docker images are larger than expected.

Your Dockerfiles are battle-tested and your images are lean. Next up, you will wire all three services together with Docker Compose so they launch with a single command.

Wire Everything Together with Docker Compose

You have two lean, production-grade Docker images built and tested. Each one runs on its own, but right now they have no way to talk to each other or to a database.

Docker Compose solves this by letting you define every service in a single YAML file. In this step, you'll create a compose.yaml that wires together PostgreSQL, FastAPI, and React so they all launch with one command.

In this step, get ready to:
  • Create a compose.yaml defining three services with health checks and dependency ordering.
  • Set up environment variables with a .env file for database credentials.
  • Launch the full stack and verify data persists across container restarts.
Create the compose.yaml file

A compose.yaml file describes your entire application stack: which containers to run, how they connect, and what order they start in. Docker Compose reads this file and manages everything for you.

  • Create a new file called compose.yaml in the same folder where your backend and frontend folders are (not inside either of those folders).
  • Paste the following into the file:
services:
  db:
    image: postgres:17-alpine
    container_name: tasktracker-db
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network
    restart: unless-stopped

  backend:
    build:
      context: ./backend
    container_name: tasktracker-backend
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

  frontend:
    build:
      context: ./frontend
    container_name: tasktracker-frontend
    ports:
      - "3000:80"
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

volumes:
  postgres_data:

networks:
  app-network:
    driver: bridge

What does this file do?

This file defines three services that work together as a single application:

  • db runs PostgreSQL 17-alpine from Docker Hub. The healthcheck uses pg_isready to confirm the database is actually accepting connections, not just that the container started.
  • backend builds from your ./backend Dockerfile. The depends_on with condition: service_healthy makes it wait until the database health check passes before starting.
  • frontend builds from your ./frontend Dockerfile and maps host port 3000 to container port 80 (Nginx). It waits for the backend to be healthy before starting.
  • postgres_data is a named volume that stores database files outside the container. This means your data survives container restarts.
  • app-network is a custom bridge network. Containers on the same network can reach each other by service name (e.g. db, backend) instead of IP addresses.

Why is only the frontend port exposed?

The ports: "3000:80" mapping on the frontend is the only entry point from your host machine. The backend port 8000 is NOT exposed to the host because Nginx proxies API requests internally through the Docker network. This is a production best practice: only expose the entry point.

  • Save compose.yaml.

You won't be able to run this file yet because the environment variables are not defined. You'll create the .env file in the next substep.

Seeing YAML syntax errors in VS Code?

  • YAML is indentation-sensitive. Each level of nesting must use exactly 2 spaces, not tabs.
  • Make sure the file is saved as compose.yaml (not docker-compose.yaml or compose.yml). Both .yaml and .yml work, but compose.yaml is the Compose V2 standard.
  • Help me fix YAML indentation in my compose.yaml

Awesome, I've got everything!

Double check you've saved compose.yaml.

I'd like to double check the full code

services:
  db:
    image: postgres:17-alpine
    container_name: tasktracker-db
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network
    restart: unless-stopped

  backend:
    build:
      context: ./backend
    container_name: tasktracker-backend
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

  frontend:
    build:
      context: ./frontend
    container_name: tasktracker-frontend
    ports:
      - "3000:80"
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - app-network
    restart: unless-stopped

volumes:
  postgres_data:

networks:
  app-network:
    driver: bridge
Set up environment variables

Your compose.yaml references variables like ${POSTGRES_USER} instead of hardcoding credentials directly. Docker Compose automatically loads these values from a .env file in the same folder as compose.yaml.

  • Create a new file called .env in the same folder as compose.yaml and paste the following:
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=taskpass
POSTGRES_DB=taskdb

What does this file do?

  • POSTGRES_USER sets the username for the PostgreSQL database.
  • POSTGRES_PASSWORD sets the password. For local development, taskpass is fine. In production, you would use a strong, unique password.
  • POSTGRES_DB is the name of the database that PostgreSQL creates on first startup.

Docker Compose substitutes these values everywhere you see the ${...} syntax in compose.yaml.

  • Save .env.

It is also good practice to include a .env.example file. This template shows teammates what variables they need without exposing real credentials.

  • Create a new file called .env.example in the same folder and paste the following:
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=taskdb

Why keep a .env.example?

Your real .env file should never be committed to Git because it contains credentials. The .env.example file uses a placeholder password (changeme) so anyone cloning the repo knows which variables to set up.

  • Save .env.example.
  • Verify Docker Compose can read your configuration by running this command in your PowerShell terminal:
docker compose config

You should see the fully resolved compose file printed to your terminal, with all ${...} placeholders replaced by the actual values from .env (e.g. taskuser, taskpass, taskdb).

Seeing variable substitution warnings?

  • Make sure your .env file is in the same folder as compose.yaml. Not inside backend or frontend.
  • Check that the file is named exactly .env with the leading dot. On Windows, VS Code is the easiest way to create dotfiles.
  • Make sure your PowerShell terminal is in the same folder as compose.yaml when you run the command.
  • Help me debug my Docker Compose environment variable setup
Launch the full stack and verify persistence

Everything is in place. Your compose.yaml defines the services, and your .env provides the credentials. Time to bring the full stack up.

  • Make sure your PowerShell terminal is in the same folder as compose.yaml.
  • Build and start all three services by running this command:
docker compose up --build

What does this command do?

  • --build tells Compose to rebuild the images from your Dockerfiles before starting. Without this flag, Compose reuses cached images.
  • Compose starts services in health-check order: db first, then backend (once db is healthy), then frontend (once backend is healthy).

This will take a minute or two on the first run because Docker needs to pull the PostgreSQL image and build both application images. You'll see logs streaming from all three services in your terminal.

What should I see?

Look for these three signals in the log output:

  • tasktracker-db logs database system is ready to accept connections.
  • tasktracker-backend logs Uvicorn running on http://0.0.0.0:8000.
  • tasktracker-frontend starts without errors (Nginx does not print a "ready" message, so no errors means success).

Containers failing to start?

  • If you see port is already allocated, another process is using port 3000. Stop whatever is using that port, or change "3000:80" to a different host port in compose.yaml.
  • If the backend exits with a database connection error, check that your .env file values match the exact variable names in compose.yaml.
  • If the frontend build fails, make sure all your frontend files (package.json, index.html, src/ files) are saved and match the code from Step 1.
  • Help me debug why my Docker Compose services are failing to start

Once all three containers are running, it is time to test the app.

  • Open your browser and go to http://localhost:3000.

You should see the Task Tracker app with an input field and an Add button.

  • Type Learn Docker Compose into the input field.
  • Click Add.
  • Add a second task: Build multi-stage Dockerfiles.

Both tasks should appear in the list. Now the real test: do they survive a full restart?

  • Go back to your PowerShell terminal and press Ctrl+C to stop the running containers.
  • Tear down the containers (but keep the data volume) by running:
docker compose down

What does docker compose down do?

This command removes the containers and the custom network, but it does NOT remove named volumes. Your postgres_data volume still holds your database files on disk.

  • Bring everything back up by running:
docker compose up

Why no --build this time?

You did not change any code, so the previously built images are still cached. Skipping --build makes the startup much faster.

  • Once the services are healthy, refresh http://localhost:3000 in your browser.

Your two tasks (Learn Docker Compose and Build multi-stage Dockerfiles) should still be there. The named volume kept your PostgreSQL data intact even though the containers were completely destroyed and recreated.

Tasks disappeared after restart?

  • If you accidentally ran docker compose down -v (with the -v flag), that deletes all named volumes including your database data. Run docker compose up again and add new tasks.
  • Check that the volumes section at the bottom of compose.yaml includes postgres_data: and that the db service references it under its own volumes key.
  • Help me figure out why my PostgreSQL data did not persist

Your full-stack app is running with three containers talking to each other over a custom network. Next up, you'll learn the essential Docker commands for inspecting, debugging, and managing this multi-container application.

Inspect, Debug, and Manage Your Containers

Your full-stack application is up and running. All three services launched with a single docker compose up --build command, and tasks persist through container restarts.

When something goes wrong in production, you need to know how to peek inside containers, read their logs, and understand why a service is unhealthy. In this step, you'll learn the essential Docker commands for managing your multi-container stack.

In this step, get ready to:
  • Use Docker commands to inspect container status, health, and logs.
  • Simulate a database failure and observe how services recover.
  • Verify data persistence and clean up your containers.
Inspect service status and view logs

Docker Compose gives you a set of commands for checking on your running services without opening Docker Desktop. These commands work in any terminal, including remote servers where there is no GUI.

  • Check the status and health of all your services by running this command:
docker compose ps

You should see all three services listed: tasktracker-db, tasktracker-backend, and tasktracker-frontend. Each one should show a status of Up and a health state of healthy.

What does docker compose ps show?

This command is the multi-container equivalent of docker ps. It lists only the services defined in your compose.yaml, along with each service's container name, command, state, health status, and port mappings.

  • View the backend service's log output by running:
docker compose logs backend

You should see Uvicorn startup messages and any HTTP requests the backend has handled so far.

  • View the database service's log output by running:
docker compose logs db

You should see PostgreSQL initialization messages, including the line database system is ready to accept connections.

Pro tip: follow logs in real time

Add the -f flag to stream logs continuously, just like tail -f on a log file. For example, docker compose logs -f backend keeps the log stream open until you press Ctrl+C.

Not seeing healthy status?

If a service shows as starting instead of healthy, give it a minute. Health checks run on intervals (10-30 seconds depending on the service), so it takes a moment after startup for the health state to update.

If a service shows unhealthy, check its logs with docker compose logs <service-name> to see what went wrong.

Help me debug an unhealthy Docker Compose service

Sometimes logs alone are not enough. You can open a shell directly inside a running container to inspect files, check environment variables, and run diagnostic commands.

  • Shell into the running backend container by running:
docker exec -it tasktracker-backend bash

What does docker exec -it do?

  • docker exec runs a command inside a running container.
  • -i keeps standard input open so you can type.
  • -t allocates a terminal so the shell prompt renders correctly.
  • bash is the shell to start inside the container.

Your terminal prompt changes to something like appuser@<container-id>:/app$. You are now inside the backend container.

  • List the application files inside the container by running:
ls

You should see your Python files: main.py, database.py, models.py, and requirements.txt. These are the files your Dockerfile copied into the image.

  • Check which user the container is running as by running:
whoami

You should see appuser in the output. This confirms the non-root user you created in your Dockerfile is active.

  • Exit the container shell by running:
exit

Your terminal prompt returns to your host machine's PowerShell. The container keeps running in the background.

Simulate a database failure and observe recovery

Health checks do more than show green checkmarks. They detect real failures and help you understand how your services depend on each other. You are about to break something on purpose to see this in action.

  • Stop the database service by running:
docker compose stop db

This stops the PostgreSQL container without removing it. The backend and frontend containers keep running.

  • Wait about 30-60 seconds for the next health check cycle to run, then check the service status by running:
docker compose ps

The db service shows as stopped. The backend service is still running, but its health state should change to unhealthy after a few failed checks.

Why does the backend become unhealthy?

The backend's HEALTHCHECK runs curl -f http://localhost:8000/api/health every 30 seconds. The /api/health endpoint tries to query the database with SELECT 1. With the database stopped, that query fails and the endpoint returns a 503 error.

After 3 consecutive failures (the retries: 3 setting in your Dockerfile), Docker marks the container as unhealthy.

  • Open your browser and go to http://localhost:3000.

The page loads because Nginx is still serving the React static files. However, adding or loading tasks fails because the API requests hit the backend, which can no longer reach the database.

  • Restart the database service by running:
docker compose start db
  • Wait about 30-60 seconds for health checks to pass, then check the status again by running:
docker compose ps

All three services should show as healthy again. Refresh http://localhost:3000 in your browser and confirm your tasks are still there.

Why did the data survive?

docker compose stop pauses the container without removing it. The container's filesystem and the named volume remain intact on disk. When you start the container again, PostgreSQL picks up right where it left off.

Now verify the named volume directly using Docker commands.

  • List all Docker volumes on your machine by running:
docker volume ls

You should see a volume ending in _postgres_data. The prefix is your project's directory name.

  • Inspect the volume to see where Docker stores it on disk. Copy the full volume name from the previous output and run:
docker volume inspect [[VOLUME_NAME="your-project_postgres_data"]]

What does volume inspect show?

The output shows metadata about the volume, including the Mountpoint (where Docker stores the data on your host filesystem), the Driver (which is local by default), and the volume's creation timestamp. This data lives outside any container, which is why it survives container stops and removals.

Test data persistence and clean up

Stopping a container keeps it on disk. Removing a container deletes the container itself, but named volumes can survive independently. Understanding this distinction is important for managing production data.

  • Stop and remove all containers and networks (but not volumes) by running:
docker compose down

What does docker compose down remove?

Without the -v flag, docker compose down removes containers and the custom network, but leaves named volumes untouched. Your PostgreSQL data is safe.

  • Confirm the volume still exists by running:
docker volume ls

You should still see the _postgres_data volume in the list. The containers are gone, but the data remains.

  • Bring everything back up in detached mode by running:
docker compose up -d

What does the -d flag do?

The -d flag runs containers in detached mode, which means they run in the background. Your terminal stays free for other commands instead of streaming logs. You can always check logs later with docker compose logs.

  • Wait for all services to become healthy by running:
docker compose ps

Once all three services show healthy, open http://localhost:3000 in your browser.

Your tasks from before should still be there. The named volume preserved your PostgreSQL data across a full container teardown and recreation.

Tasks are missing after restarting?

Make sure you ran docker compose down without the -v flag. Running docker compose down -v deletes the named volumes along with your data. If that happened, add a few new tasks so you have some data for the next part.

Help me figure out why my Docker volume data disappeared

Now that you have confirmed persistence works, clean up everything including the volumes.

  • Remove all containers, networks, and volumes by running:
docker compose down -v
  • Confirm the volume has been removed by running:
docker volume ls

The _postgres_data volume should no longer appear in the list. Everything is cleaned up.

When to use -v and when to skip it

  • docker compose down (no -v): use this when you want to restart fresh containers but keep your data. This is common during development.
  • docker compose down -v: use this when you want a completely clean slate. This deletes all named volumes and the data inside them.

Pro tip: quick reference card

Here is a summary of the commands you learned in this step.

  • docker compose ps shows service status and health.
  • docker compose logs <service> shows logs for one service.
  • docker exec -it <container> bash opens a shell inside a running container.
  • docker compose stop <service> pauses a service without removing it.
  • docker compose start <service> resumes a stopped service.
  • docker compose down removes containers and networks, keeps volumes.
  • docker compose down -v removes everything including volumes.

Secret mission

Add a Redis Caching Layer

Your Compose stack runs three services. In production, backends often cache expensive database queries to speed up response times. Can you add Redis as a fourth service and wire it into the backend to cache task counts with a TTL?

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local machine using Docker Desktop, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers: tasktracker-db, tasktracker-backend, tasktracker-frontend.
  • Docker images built for the frontend and backend services.
  • Docker named volume: postgres_data.
  • Docker custom network: app-network.
  • Project files in your task-tracker directory (backend, frontend, nginx, Compose files, .env).

Keep everything running

No action needed. Choose this if you are still actively building or want to continue experimenting with your containers.

  • Your containers stay running until you explicitly stop them or restart Docker Desktop.
  • Task data persists in the postgres_data named volume.
  • The app remains accessible at http://localhost:3000.

Pause - I'll come back to this later

Stop running containers to free up system memory and CPU without losing any data or configuration.

  • Run this command to stop all containers without removing them:
docker compose stop
  • Your containers, volumes, networks, and images are all preserved.
  • When you are ready to resume, run this command from your project directory:
docker compose start
  • All three services will resume exactly where they left off.

Delete - I don't want to use this again

Remove all containers, networks, volumes, and images associated with this project for a clean slate.

  • Run this command to remove all containers, networks, and the named PostgreSQL volume:
docker compose down -v
  • Run this command to remove any dangling images left behind by the multi-stage builds:
docker image prune
  • Delete the project folder from your local machine if you no longer need the source files.

Mission accomplished

That's a Wrap!

You just containerized a full-stack application from scratch. A React frontend, a FastAPI backend, and a PostgreSQL database are all running together from a single docker compose up command. This is the kind of project that proves you understand containerization beyond the basics.

Here's what you learned:

  • Built two multi-stage Dockerfiles that produce lean, secure images with non-root users and optimized build contexts.
  • Wired three services together with Docker Compose using health checks, dependency ordering, named volumes, and custom networks.
  • Configured Nginx as a reverse proxy to serve React static files and route API requests to the backend over Docker's internal network.
  • Added a Redis caching layer as a fourth service, demonstrating how to extend a Compose stack with new dependencies.

Ready to quiz yourself?