DEV Community

Cover image for Deploy FastAPI and React with Docker Compose
Aravind Govindhasamy
Aravind Govindhasamy

Posted on Originally published at starter.budeglobal.in on

Deploy FastAPI and React with Docker Compose

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"]

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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; }
}

Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)