HSHSKY Lab
workflows5 min read

I Had Claude Code Dockerize a FastAPI + React App — Here Are the Non-Obvious Parts

Dockerfiles, an alembic migration entrypoint, timestamp-only versioning, and a docker-compose that plugs into a shared postgres — how the pieces fit together for a real production deploy.

In-Article Ad — Replace with ad code after approval

I needed to move a side project — a FastAPI backend, Vite/React frontend, PostgreSQL database — from local dev onto a production server via Docker. The individual pieces aren't complicated, but how they connect has a few non-obvious decisions. I handed the whole thing to Claude Code and worked through it piece by piece.

Frontend: Multi-Stage Build, Demo Mode Off

The frontend is Vite + React + TypeScript. The Dockerfile is a standard two-stage build: Node 20 compiles the app, nginx serves the static output.

# frontend/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
ENV VITE_DEMO_MODE=false
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

The VITE_DEMO_MODE=false line matters: the app ships with a mock adapter that intercepts all API calls and returns canned data, which is how it runs on Vercel without a backend. Setting this at build time bakes the real-API path into the JS bundle. Forget it and the deployed app talks to nobody.

The nginx config handles SPA routing (try_files $uri $uri/ /index.html) and proxies /api/ to the backend container.

Tip

The multi-stage build means node_modules (350 MB+) never touches the final image — only the compiled dist/ gets copied to nginx. Final image size is around 50 MB.

Backend: Migrations Before the Server Starts

The backend is FastAPI with SQLAlchemy + asyncpg, managed by Alembic. The Dockerfile is straightforward, but the entry point isn't.

# backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN chmod +x entrypoint.sh

EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
# backend/entrypoint.sh
#!/usr/bin/env bash
set -e

alembic upgrade head
exec python -m uvicorn app.main:app --host 0.0.0.0 --port 8000

Running alembic upgrade head in the entrypoint means every docker compose up automatically applies any pending migrations before the server accepts traffic. No separate migration job, no SSH-in-and-run-manually step. The exec replaces the shell process with uvicorn so signals (SIGTERM from docker stop) reach the server directly.

Warning

If migrations fail — bad SQL, missing column, anything — the container exits immediately and docker compose up shows the error. This is the right behavior: you want a broken migration to surface loudly, not let a half-migrated app start serving requests.

deploy.sh: Timestamp Tags, No latest

Both services get the same deploy script pattern. The frontend version:

# frontend/deploy.sh
#!/usr/bin/env bash
set -euo pipefail

REGISTRY="${REGISTRY:-registry.example.com}"
NAMESPACE="${NAMESPACE:-myorg}"
IMAGE_NAME="${IMAGE_NAME:-myapp-frontend}"
PLATFORM="${PLATFORM:-linux/amd64}"

VERSION="$(date +%Y%m%d-%H%M%S)"
FULL_IMAGE="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}"

cd "$(dirname "$0")"

echo "==> Building ${FULL_IMAGE}:${VERSION} (platform: ${PLATFORM})"
docker buildx build \
  --platform "${PLATFORM}" \
  -t "${FULL_IMAGE}:${VERSION}" \
  --push \
  .

echo ""
echo "Pushed: ${FULL_IMAGE}:${VERSION}"

docker buildx build finishing and pushing to a private registry

Two decisions worth explaining:

No latest tag. Every push gets only a timestamp tag (20260616-143022). The server's .env sets FRONTEND_TAG and BACKEND_TAG explicitly to the version you want running. This means rollback is: edit .env, docker compose pull <service>, docker compose up -d <service>. No guessing what latest actually is.

--platform linux/amd64 always. The build machine is Intel Mac, so it's a no-op here — but making it explicit means the script works correctly if someone runs it from an Apple Silicon machine later. docker buildx with --push builds in BuildKit and pushes directly to the registry without loading locally, which is faster for images you're not running on the build machine.

docker-compose: The Backend Needs Two Networks

The production server already runs a shared postgres container — other projects use it too. Rather than spin up a second postgres in this compose stack, the backend connects to that existing container.

# docker-compose.yml
services:
  backend:
    image: ${REGISTRY}/${NAMESPACE}/myapp-backend:${BACKEND_TAG}
    environment:
      DATABASE_URL: ${DATABASE_URL}
      SECRET_KEY: ${SECRET_KEY}
      LLM_API_KEY: ${LLM_API_KEY}
      LLM_BASE_URL: ${LLM_BASE_URL:-https://api.openai.com/v1}
      LLM_MODEL: ${LLM_MODEL:-gpt-4}
    networks:
      - default
      - postgres_net
    restart: unless-stopped

  frontend:
    image: ${REGISTRY}/${NAMESPACE}/myapp-frontend:${FRONTEND_TAG}
    ports:
      - "80:80"
    depends_on:
      - backend
    restart: unless-stopped

networks:
  postgres_net:
    external: true
    name: ${POSTGRES_NETWORK}

The backend is on two networks: default (the compose-managed network, shared with the frontend container so nginx can resolve backend:8000) and postgres_net (the external network where the shared postgres container lives). Without default in the list, adding explicit networks: to the backend service would remove it from the compose default network — and the nginx proxy would stop resolving.

To find POSTGRES_NETWORK, run this on the server against the running postgres container:

docker inspect <postgres-container> \
  --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}'

Pick the non-bridge entry. That's POSTGRES_NETWORK. DATABASE_URL points at that container by name: postgresql+asyncpg://myapp:password@<container-name>:5432/myapp.

docker compose up -d showing the frontend and backend containers both Started

The One Thing That Bit Us After Deploy

Everything deployed cleanly — migrations ran, uvicorn started, nginx started. And then every single API call returned 404.

The nginx config used a variable in proxy_pass (the standard Docker lazy-DNS trick) but this silently strips the request path and forwards only the literal URI from the directive. Full writeup and fix in How a Variable in nginx.conf Silently Stripped All My API Paths.

Bottom Line

The individual files here are each about ten lines. The non-obvious parts are: baking VITE_DEMO_MODE=false into the build, putting alembic upgrade head in the entrypoint rather than a separate job, skipping latest in favor of explicit timestamp tags, and making the backend join two networks in compose. Claude Code generated all of it and caught the deploy-time nginx bug in the follow-up. The whole setup — from first Dockerfile to working deploy — took one session.

In-Article Ad — Replace with ad code after approval

Tags

claude codedockerfastapireactnginxalembicpostgresqlworkflow
H

Written by HSKY

Developer writing about AI coding tools — Claude Code, Cursor, agents, and the workflows that make them work.