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
-
Set public URLs in
.env:API_URL=https://api.example.com,SITE_URL=https://example.com, then rebuild (docker compose up --build -d). -
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. -
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_IPSto that address (for example the bridge gateway172.18.0.1) soX-Forwarded-Foris trusted. -
Restart policy. Add
restart: unless-stoppedto both services. -
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.
Top comments (0)