Deployment
Containerize the app
Package the frontend and backend into one image, add a password gate, and serve the SPA from FastAPI.
By the end of this stage the whole application fits in one Docker image, the React build served as static files, the FastAPI backend behind them, and a password gate in front, ready for Azure to run.
daveebbelaar/invoice-review2:14:53The single-container decision
Dave is explicit that this is a demo choice, not his default: "not what I would generally do, but it is going to help us in the demonstration." His team usually deploys backend and frontend separately. For a tutorial, one container wins on every axis that matters here, one resource to create, one URL to share, one thing to delete afterwards, and the lowest possible cost. The trade-offs of the shortcut, and what the grown-up version looks like, get their own page at the end.
Collapsing to one container changes how the halves talk. Locally, Vite serves the frontend on 5173 and FastAPI answers on 8000, with CORS bridging the two origins. In the container there is one origin. The React app compiles to static files and FastAPI serves them itself, so API calls become same-origin relative requests and CORS stops mattering in production.
The multi-stage Dockerfile
The Dockerfile at the repo root has two stages, one per toolchain:
FROM node:22-bookworm-slim AS frontend-build
WORKDIR /frontend
RUN corepack enable && corepack prepare pnpm@11.3.0 --activate
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
ENV VITE_API_BASE_URL=/
RUN pnpm build
FROM python:3.12-slim-bookworm AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FRONTEND_DIST_DIR=/app/frontend/dist
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /usr/local/bin/uv
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY backend/app ./app
COPY --from=frontend-build /frontend/dist /app/frontend/dist
RUN mkdir -p /app/data/uploads
EXPOSE 8000
CMD ["uv", "run", "--locked", "--no-sync", "uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]The first stage is a Node image that builds the SPA with pnpm. The second is a slim Python image that copies uv from its official image, installs the locked backend dependencies before copying the code (so dependency layers cache across code changes), then pulls the built dist/ folder over from the first stage. The Node toolchain never reaches the final image. The CMD is the same uvicorn factory invocation as local dev, minus --reload.
VITE_ values bake at build time
ENV VITE_API_BASE_URL=/ is set during the frontend build, not at runtime. Vite inlines it into the compiled JavaScript, which is why env.ts treats / as "same origin, use relative fetches". Changing it later means rebuilding the image, not editing a container variable.
The password gate
There is one problem with putting this on the internet. The app has no login, and every processed document spends your Azure credits. Dave adjusts the deployment plan on the spot: "when you put things on the internet, if you don't have a firewall, everyone can access that." The fix is deliberately modest, a single shared password, not user accounts.
The gate is a middleware in front of the API:
class AccessPasswordMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
settings = get_settings()
if not settings.auth_enabled:
return await call_next(request)
path = request.url.path
if (
path == "/health"
or path.startswith("/api/auth/")
or not path.startswith("/api/")
):
return await call_next(request)
if not is_authenticated(request, settings):
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
return await call_next(request)Three kinds of paths stay open, each for a reason. /health lets the platform probe liveness, /api/auth/ lets you actually log in, and everything outside /api/ lets the SPA load and render the login page. Every other API route returns 401 without a valid session.
The session is a cookie holding an HMAC-SHA256 signature, built and checked in backend/app/auth/session.py. Login compares the submitted password in constant time (hmac.compare_digest over SHA-256 digests), sets the httponly cookie for seven days, and marks it secure when the request arrived over HTTPS, which Container Apps signals through the x-forwarded-proto header. No database, no user table, just a signature only the server can produce.
Configuration enforces its own consistency. Auth switches on when APP_ACCESS_PASSWORD is set, and the validator in backend/app/config.py refuses to start if the password is set without APP_SESSION_SECRET. Locally you leave both unset and the gate disappears, which is why ./scripts/dev.sh works exactly as before. On the frontend, App.tsx asks GET /api/auth/session on load and shows LoginPage.tsx until the session checks out.
Serving the SPA
The last piece of create_app makes FastAPI the web server. If a frontend build exists, found via FRONTEND_DIST_DIR or the local frontend/dist, it mounts the hashed assets and adds a catch-all:
frontend_dist = settings.resolve_frontend_dist()
if frontend_dist is not None:
app.mount("/assets", StaticFiles(directory=frontend_dist / "assets"), name="assets")
@app.get("/{full_path:path}")
def spa_fallback(full_path: str) -> FileResponse:
candidate = frontend_dist / full_path
if full_path and candidate.is_file():
return FileResponse(candidate)
return FileResponse(frontend_dist / "index.html")The fallback is what makes client-side routing work. Any path that is not a real file gets index.html, and React takes it from there. Locally, where no dist exists, the block never activates and nothing changes.
Checkpoint
- You can explain what each Dockerfile stage produces and why Node is absent from the final image
- You can name the three path groups that bypass the password gate, and why each must
- You know why
VITE_API_BASE_URL=/is set in the Dockerfile and not in Azure