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 great for shipping. 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.

We build that image here.

One image, two build stages

The plan is straightforward to follow. 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.

This is where the TanStack Start template bites. 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. In the workshop a participant who does frontend spotted it: we need a static build, not the server one.

The assistant works it out 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. That folder is what the backend serves. It 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 get past this. What matters is the workflow. The first attempt fails, and after you feed the error and the hint back to the assistant, it converges on 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 survives the container being removed:

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"

We'll skip that here and swap SQLite for Postgres instead 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