- Published on
Build and Deploy the Todo App with Docker Compose


- Name
- Chad Wilson
- @NetPenguins
Series
Full Stack Todo App
- 01Todo API Creation with FastAPI
- 02Building a User Interface for Your To-Do Application
- 03Securing FastAPI with OAuth2 and JWT
- 04Build and Deploy the Todo App with Docker Compose
- 05Docker vs Podman: Which One for Your Homelab?
- 06Keycloak SSO with FastAPI
- 07Authentik SSO with FastAPI
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 /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:
- Frontend: http://localhost:3000
- API docs: http://localhost:8000/docs
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.