Docker Compose gives you the same setup on your laptop and your server: one command builds and starts the API and the frontend. This guide covers both Dockerfiles, the one setting people get wrong (build-time variables for the frontend), and going to production.

The backend image

With uv, the image is small and the install is reproducible from the lockfile:

FROM python:3.12-slim COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv WORKDIR /app COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev COPY app ./app EXPOSE 8000 CMD ["/app/.venv/bin/fastapi", "run", "app/main.py", "--port", "8000"] 

Copying pyproject.toml and uv.lock before the code means dependency layers are cached until the dependencies change. Pin the uv image to a version in production instead of latest.

The frontend image

A React build is static files, so build with Node and serve with nginx:

FROM node:24-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . ARG API_URL SITE_URL RUN npm run build FROM nginx:alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html 

With an nginx.conf that falls back to the app for client-side routes:

server { listen 80; root /usr/share/nginx/html; location / { try_files $uri $uri/index.html /index.html; } } 

Build-time vs run-time settings

This trips everyone up once. The frontend's settings (the API URL, public keys, analytics ids) are baked into the JavaScript at build time. Setting them as container environment variables does nothing; they must be build args , and you rebuild when they change. The backend reads its settings at run time from the environment.

compose.yml

services: backend: build: ./backend env_file: .env ports: - "8000:8000" frontend: build: context: ./frontend args: API_URL: ${API_URL:-http://localhost:8000} SITE_URL: ${SITE_URL:-http://localhost:5173} ports: - "5173:80" 

Compose reads ${API_URL} from your .env file next to compose.yml, so one file feeds both.

docker compose up --build -d docker compose logs -f backend 

Production

  1. Set public URLs in .env: API_URL=https://api.example.com, SITE_URL=https://example.com, then rebuild (docker compose up --build -d).
  2. Put HTTPS in front. Bind the ports to localhost ("127.0.0.1:8000:8000") and use a reverse proxy on the host: nginx with certbot, Caddy, or Cloudflare Tunnel with no open ports at all.
  3. Real client IPs. If the proxy reaches the API over Docker's network, the API sees the proxy's container address. Set uvicorn's FORWARDED_ALLOW_IPS to that address (for example the bridge gateway 172.18.0.1) so X-Forwarded-For is trusted.
  4. Restart policy. Add restart: unless-stopped to both services.
  5. Updates: git pull && docker compose up --build -d, then check the health endpoint.

Using it with FastAPI Supabase Starter

The starter ships both Dockerfiles and a compose.yml with every build arg listed (SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, API_URL, SITE_URL, OAUTH_PROVIDERS, TURNSTILE_SITE_KEY, SENTRY_DSN, GA_MEASUREMENT_ID). Fill in .env and run docker compose up --build: the app is on port 5173 and the API on 8000. There's no database container, because Supabase hosts Postgres. See the quick start and Deploy to production.