Deployment
Deployment
Ship the app to Azure Container Apps as one container, mount an Azure Files share for SQLite, and know exactly what it costs.
By the end of this stage Invoice Review runs on a public URL in your Azure subscription. One container serves both the API and the frontend, the SQLite database lives on an Azure Files share so it survives restarts, and the whole footprint still sits in the one resource group you can delete with a single command.
A project that only runs on your laptop is not a product. And since this build has been an Azure project from the first stage, it deploys to Azure too, using the same CLI discipline as the provisioning stages.
Why Container Apps, and why one container
Azure gives you half a dozen ways to run this app, so the choice deserves one honest paragraph. App Service is the classic web-app host, AKS is full Kubernetes and far more machinery than a demo deserves, and Static Web Apps would split the frontend from the backend into two deployments. Azure Container Apps sits in the sweet spot for this project. You hand it a container, it gives you HTTPS ingress, secrets, log streaming, and per-second consumption billing with a monthly free grant, and the Kubernetes underneath stays invisible.
The shape is deliberately simple. One image, built in the cloud, serving everything:
FastAPI serves the built React bundle as static files next to the API. One URL, no CORS between services, one thing to deploy, one log stream to read. Splitting frontend hosting from the API is the right call at team scale; here it would double the moving parts without teaching anything new.
Prepare the app
Two small changes make the app deployable, and they are the only code this stage touches.
First, teach FastAPI to serve the frontend build. After the API routers, mount the frontend/dist output as static files with an SPA fallback to index.html, guarded so local development without a build keeps working exactly as before.
Second, point the frontend at the same origin. VITE_* values are baked in at build time, and inside the container the API is the origin, so the Docker build sets:
VITE_API_BASE_URL=/Then write a two-stage Dockerfile at the repo root. A Node stage builds the frontend, a Python stage gets uv, the locked backend environment, and the built bundle:
FROM node:22-alpine AS frontend
# pnpm install --frozen-lockfile && pnpm build, with VITE_API_BASE_URL=/
FROM python:3.12-slim
# uv sync --locked, copy backend/, copy the dist output from the frontend stage
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]The locked installs mean the image contains exactly what you ran locally, which is most of what "works on my machine" insurance actually is.
Deploy with one command
az containerapp up compresses the whole first deployment. It creates a container registry, builds the image in the cloud (no local Docker needed), creates the Container Apps environment, and deploys with public ingress:
az containerapp up \
--name ca-invoice-review \
--resource-group rg-invoice-review \
--location westeurope \
--source . \
--ingress external \
--target-port 8000The command prints the app URL when it finishes. The app will start but fail on any Azure call, because the container has no credentials yet. That is the correct failure, secrets were never in the image.
Store the two keys as Container Apps secrets and expose all configuration as environment variables, exactly mirroring backend/.env:
az containerapp secret set \
--name ca-invoice-review \
--resource-group rg-invoice-review \
--secrets di-key=<document-intelligence-key> aoai-key=<azure-openai-key>
az containerapp update \
--name ca-invoice-review \
--resource-group rg-invoice-review \
--set-env-vars \
AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT=<your-di-endpoint> \
AZURE_DOCUMENT_INTELLIGENCE_KEY=secretref:di-key \
AZURE_OPENAI_ENDPOINT=<your-aoai-endpoint> \
AZURE_OPENAI_DEPLOYMENT=gpt-5.6-terra \
AZURE_OPENAI_API_KEY=secretref:aoai-key \
ALLOWED_ORIGIN=https://<your-app-url> \
DATABASE_URL=sqlite:////app/data/invoices.db \
UPLOAD_DIR=/app/data/uploadsOpen the URL. The full application works, upload a sample document and watch a review come back from real Azure services on a real public site.
Make the database survive
There is one honest problem left. The container filesystem is ephemeral, so every restart or new revision wipes SQLite and the uploads. Prove it to yourself, process a document, restart the app, and watch the history come back empty.
The fix is an Azure Files share mounted into the container. Create a storage account and share, then register the share with the Container Apps environment:
az storage account create \
--name stinvoicereview<suffix> \
--resource-group rg-invoice-review \
--location westeurope \
--sku Standard_LRS
az storage share-rm create \
--storage-account stinvoicereview<suffix> \
--name invoice-data
az containerapp env storage set \
--name <your-environment-name> \
--resource-group rg-invoice-review \
--storage-name invoice-data \
--azure-file-account-name stinvoicereview<suffix> \
--azure-file-account-key <account-key> \
--azure-file-share-name invoice-data \
--access-mode ReadWriteMounting the share into the app is a YAML update, with two settings that matter for SQLite. The mount options disable SMB byte-range locking, which SQLite trips over on network shares, and the replica count is pinned to one, because SQLite is a single-writer database:
properties:
template:
scale:
minReplicas: 1
maxReplicas: 1
volumes:
- name: data
storageType: AzureFile
storageName: invoice-data
mountOptions: nobrl
containers:
- name: ca-invoice-review
volumeMounts:
- volumeName: data
mountPath: /app/dataApply it with az containerapp update --yaml, repeat the restart test, and the history survives.
This is where production would diverge
SQLite on a file share, key-based authentication, and no login page are fine for a teaching deployment and wrong for a company one. The production versions of these decisions are a managed Postgres, managed identity instead of keys, and authentication in front of the app, and the next stage lists them honestly. Also remember that your deployed URL is public, including FastAPI's /docs page, so treat this as a demo you tear down, not a service you leave running.
Operate it
Two commands cover day-to-day operation. Stream the logs while you use the app:
az containerapp logs show \
--name ca-invoice-review \
--resource-group rg-invoice-review \
--followAnd redeploy after a code change by running the same az containerapp up --source . again, which rebuilds the image and rolls a new revision.
On cost, the consumption plan bills per second of vCPU and memory, and the monthly free grant (180,000 vCPU-seconds and 360,000 GiB-seconds) absorbs a demo app with a single small replica for a meaningful part of the month. The storage account adds cents. When you are done, the exit you prepared in the CLI stage removes everything, the app, the environment, the registry, the storage, and both AI resources:
az group delete --name rg-invoice-reviewCheckpoint
- The public URL serves the frontend, and
/healthreturns{"status":"ok"} - A document processes end to end against the deployed backend
- History survives a container restart
az containerapp logs showdisplays your requests, and you know the one command that tears everything down
Next: Where to go next
Recap the system you built and the honest list of what production work would add.