0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74
01110100 01101000 01100101 01110010 01100101 00100000 01100001 01110010 01100101 00100000 00110001 00110000 00100000 01110100 01111001 01110000 01100101 01110011 0xdeadbeef 0xcafebabe 0x1337
0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74 0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65
01010111 01100101 00100000 01101000 01100001 01100011 01101011 00100000 01110100 01101000 01100101 00100000 01110000 01101100 01100001 01101110 01100101 01110100 01100110 00110000 01111000 01100110 00110100 01100100 01100101
0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x64 0x72 0x6f 0x70 0x20 0x79 0x6f 0x75 0x72 0x20 0x73 0x68 0x65 0x6c 0x6c 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6b 0x69 0x6c 0x6c 0x20 0x79 0x6f 0x75 0x72 0x20 0x74 0x68 0x72 0x65 0x61 0x64 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6c 0x6f 0x73 0x65 0x20 0x79 0x6f 0x75 0x72 0x20 0x70 0x61 0x63 0x6b 0x65 0x74 0x20 0x61 0x6e 0x64 0x20 0x64 0x65 0x73 0x65 0x72 0x74 0x20 0x79 0x6f 0x75 0x72 0x20 0x71 0x75 0x65 0x75 0x65
Published on

Build and Deploy the Todo App with Docker Compose

Build and Deploy the Todo App with Docker Compose

Welcome back! We have a working full-stack todo app: FastAPI on the backend, React on the frontend, JWT auth wired between them. Now it is time to package it so it runs the same way everywhere, whether that is your laptop, a homelab server, or a cloud VM.

In this post we will containerize both services with Docker and wire them together using Docker Compose. We will also cover how SQLite behaves inside a container and what to watch out for before taking this to production.


Why Docker?

The short version: it eliminates "works on my machine." You define the runtime environment in a Dockerfile, build an image from it, and that image runs identically anywhere Docker is installed. Dependencies, Python version, Node version, all of it locked in.

Docker Compose is a layer on top that lets you define multiple containers in a single docker-compose.yml and bring them all up with one command.


Project Structure

Here is the assumed layout at the root of your project:

todo-app/
  todo-api/
    Dockerfile
    requirements.txt
    main.py
    ...
  todo-ui/
    Dockerfile
    package.json
    src/
    ...
  docker-compose.yml
  .env
  .dockerignore

The docker-compose.yml and .env sit at the root. The Dockerfiles live inside each service directory.


The .dockerignore File

Set up a .dockerignore at the repo root before writing any Dockerfiles. Docker sends your entire project directory to the Docker daemon when building an image. Without a .dockerignore, that includes virtual environments, node_modules, build artifacts, and .env files. It makes builds slow and images larger than they need to be.

**/__pycache__
**/*.pyc
**/venv
**/.venv
**/env
**/.env
**/node_modules
**/dist
**/.git

The **/ prefix ensures these patterns match in any subdirectory.


Dockerizing the Backend

Create todo-api/Dockerfile:

FROM python:3.11.5-slim-bookworm

WORKDIR /app

COPY todo-api/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt

COPY todo-api/ /app/

EXPOSE 8000

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

A few things worth understanding here.

python:3.11.5-slim-bookworm is a minimal Debian image with Python. The -slim variant skips system packages a Python app does not need, which keeps the image smaller.

Copy requirements.txt before the rest of the code. Docker builds images in layers and each instruction is cached. If you copy everything at once, any change to your source code invalidates the layer that installed dependencies, forcing a full pip install on every build. Copying requirements.txt first means the pip layer only re-runs when your dependencies actually change.

--host 0.0.0.0 is required inside a container. By default uvicorn binds to 127.0.0.1, which is the container's own loopback interface. Docker cannot route traffic from outside the container to that address. 0.0.0.0 tells uvicorn to listen on all interfaces inside the container, which Docker then maps to the host.


Dockerizing the Frontend

Create todo-ui/Dockerfile:

FROM node:20-alpine AS builder

WORKDIR /app

COPY todo-ui/package*.json ./
RUN npm ci

COPY todo-ui/ ./

ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

FROM nginx:alpine

COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

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

This is a multi-stage build. The first stage uses a Node image to install dependencies and compile the TypeScript into static files. The second stage copies just those built files into a minimal nginx image. The final image has no Node.js, no source code, and no node_modules. It is much smaller than a single-stage build.

ARG VITE_API_URL matters here. Vite bakes environment variables into the built JavaScript at compile time, not at runtime. VITE_API_URL needs to be available during npm run build, not when the container starts. ARG is for build-time variables. ENV is for runtime variables. We use both because ARG makes the value available to the build, and assigning it to ENV makes Vite pick it up.


SQLite in a Container

SQLite stores everything in a single file. Without any configuration, that file lives inside the container and gets destroyed every time the container is recreated.

The fix is a Docker named volume. It is a persistent directory managed by Docker that survives container restarts and rebuilds.

In database.py the path comes from an environment variable:

DATABASE_URL = getenv("DATABASE_URL", "sqlite:///./todos.db")

The default ./todos.db resolves relative to the working directory (/app). We will mount the volume at /app/data and set DATABASE_URL to point there.


Docker Compose

Create docker-compose.yml at the repo root:

version: '3.9'

services:
  todo-api:
    build:
      context: .
      dockerfile: todo-api/Dockerfile
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: sqlite:////app/data/todos.db
      SECRET_KEY: ${SECRET_KEY}
      ALGORITHM: ${ALGORITHM}
      ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES}
    volumes:
      - todo_data:/app/data
    networks:
      - todo-net

  todo-ui:
    build:
      context: .
      dockerfile: todo-ui/Dockerfile
      args:
        VITE_API_URL: ${VITE_API_URL}
    ports:
      - "3000:80"
    depends_on:
      - todo-api
    networks:
      - todo-net

networks:
  todo-net:
    driver: bridge

volumes:
  todo_data:

A few things here.

DATABASE_URL: sqlite:////app/data/todos.db uses four slashes. SQLite URLs use three slashes for a relative path (sqlite:///relative) or four for an absolute path (sqlite:////absolute). We use an absolute path to avoid any ambiguity.

${SECRET_KEY} and the other variables are pulled from the .env file at the repo root. Docker Compose automatically reads .env from the same directory as docker-compose.yml.

todo_data is a named Docker volume. Docker manages it, it persists across container lifecycles, and it lives outside the container filesystem.

depends_on controls startup order, not readiness. If the API is slow to start, the UI can still boot and try requests before the API is ready. For development this is usually fine. For production, add a health check.


The .env File

Create .env at the repo root:

SECRET_KEY=your_generated_secret_key_here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
VITE_API_URL=http://localhost:8000

Generate a real secret key with openssl rand -hex 32.

This file must never be committed to version control. Add it to .gitignore:

.env

For a team environment or CI/CD pipeline, use a secrets manager (AWS Secrets Manager, HashiCorp Vault, GitHub Actions secrets) instead of a file on disk.


Building and Running

From the repo root:

docker compose up --build

The first build takes a few minutes. After that, layers are cached and subsequent builds are fast. Once both services are up:

To run in the background:

docker compose up --build -d

To stop:

docker compose down

The todo_data volume persists after docker compose down. To remove everything including the volume:

docker compose down -v

Production Considerations

SQLite is a development database. It works fine for a single-container deployment where one process is writing at a time. The moment you need to scale the API to multiple containers, SQLite breaks down because file-based locking does not work across separate container instances. Switch to PostgreSQL for anything that needs to scale. With SQLAlchemy the change is mostly just updating DATABASE_URL and installing psycopg2.

CORS origins. In main.py we hardcoded "http://localhost:5173". For production that needs to be your real frontend domain. Pass it in as an environment variable.

HTTPS. Neither container handles TLS. In production you need a reverse proxy like Caddy or nginx in front of both services to terminate SSL. Caddy is particularly easy to set up with automatic Let's Encrypt certs.

Health checks. Add a health check so Compose waits for the API to be genuinely ready:

todo-api:
  ...
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:8000/"]
    interval: 10s
    timeout: 5s
    retries: 3

Up Next

We have the full stack running in containers. The next post covers the Docker vs Podman question: what each one is, where they differ in practice, and why Podman is worth knowing for homelab and self-hosted scenarios.