Part 9: Package as one container

Right now running the app means starting uvicorn for the backend and Vite for the frontend.

That's fine on a laptop but not good for deployment. For deployment we want one image that contains the FastAPI backend and the compiled frontend, so there's a single thing to build, run, and ship.

One image, two build stages

We build the React app into static files and copy them into the Python image. From there, uvicorn serves both the API and the frontend from one container.

Ask the assistant for it.

Create a Dockerfile that builds the frontend with Node, then builds a Python
image with backend with frontend static files. Backend should serve the frontend.

Here the TanStack Start template causes a problem. Plain npm run build produces a server bundle, not the static SPA we assumed. There's no ready folder of files for FastAPI to serve, so the first image the assistant builds doesn't work. A workshop participant who works on frontend noticed it: we need a static build, not the server one.

The assistant fixes it by adding a second build step, npm run build:static-shell (a small scripts/prerender-static-shell.mjs that prerenders the static shell), which writes the static client into dist/client. The backend serves that folder. The assistant even installs Playwright and clicks through the running container to confirm the app loads.

We end up with a two-stage Dockerfile. The Node stage builds the frontend and the static shell. The uv/Python stage installs the backend and copies dist/client into static/.

The full Dockerfile looks like this:

FROM node:24-bookworm-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
RUN npm run build:static-shell

FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS backend
ENV PYTHONUNBUFFERED=1 \
    UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    SNAKE_ROYALE_DATABASE_URL=sqlite:////data/snake_royale.db \
    SNAKE_ROYALE_STATIC_DIR=/app/static
WORKDIR /app
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --frozen --no-dev
COPY backend ./
COPY --from=frontend-build /frontend/dist/client ./static
RUN mkdir -p /data
EXPOSE 8000
VOLUME ["/data"]
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

You don't need to understand TanStack Start's server build to fix this. When the first attempt fails, give the error and the hint back to the assistant. It then finds a build that works.

Build and run it

The Dockerfile sits at the repo root, since it copies from both backend/ and frontend/.

Build from there:

docker build -t snake-royale .

Inside the container there's no database server, so the image defaults to a SQLite file under /data.

Mount a named volume there so the data stays when you remove the container:

docker run -it --rm \
  -p 8000:8000 \
  -e SNAKE_ROYALE_DATABASE_URL="sqlite:////data/snake_royale.db" \
  -v snake-royale-data:/data \
  snake-royale

Open http://localhost:8000 and the whole app is served from one container, with the frontend and API on the same port. Sign up, submit a score, then stop and rerun the container - the volume keeps the data.

Commit the Dockerfile:

git add .
git commit -m "Add Dockerfile"

Next we swap SQLite for Postgres in Part 10: Postgres in a container.

Questions & Answers

Sign up to ask questions, track your progress, and get access to other workshops · Already have an account? Sign in