Part 8: Package as one container
Running the app today means starting uvicorn for the backend and Vite for the frontend. That's fine on a laptop and wrong 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 frontend is just static files once it's built, and FastAPI can serve static files. So we build the React app, copy the result into the Python image, and let uvicorn serve both the API and the frontend.
Ask the assistant for it.
Write a Dockerfile that builds the frontend with Node, then builds a Python
image with uv that copies the compiled frontend into a static folder and runs
the backend with uvicorn. One image should serve both the API and the frontend.
The build context is the repo root.
We get a multi-stage backend/Dockerfile in which the Node stage compiles the
frontend to a dist/ folder. The Python stage then copies that dist/ into
static/ and runs uvicorn.
The Dockerfile looks like this:
FROM python:3.14-slim as builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --frozen --no-dev
FROM node:20-alpine as frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend .
RUN npm run build
FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY backend/app ./app
COPY --from=frontend-builder /frontend/dist ./static
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
The Node stage is throwaway: its only output is the dist/ directory that gets
copied into the final image. The shipped image has no Node in it, just Python
and the static files.
The serving half is in app/main.py, which mounts the static folder and falls
back to index.html so the single-page app's routes work on refresh:
if os.path.exists("static"):
app.mount("/assets", StaticFiles(directory="static/assets"), name="assets")
@app.get("/{full_path:path}")
async def catch_all(full_path: str):
file_path = f"static/{full_path}"
if os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse("static/index.html")
Build and run it
Build from the repo root, since the Dockerfile copies from both backend/ and
frontend/:
docker build -f backend/Dockerfile -t snake-arena .
Inside the container there's no database server. We point it at a SQLite file and turn on debug seeding so there's data to see.
The run command sets both as environment variables:
docker run -p 8000:8000 -e DATABASE_URL=sqlite:///./snake.db -e DEBUG=true snake-arena
Open http://localhost:8000 and the whole app is served
from one container, with the frontend and API on the same port. One problem
remains: the SQLite file lives inside the container and disappears when the
container is removed. A throwaway database is fine for a smoke test and wrong for
a real deployment. In the next part we give it a real Postgres database in
Part 9: Postgres in a container.