Datalumina

Deployment

Deploy to Azure

Provision ACR, storage, and a Container Apps environment with the CLI, then create the app with secrets and a public URL.

By the end of this stage the app runs on a public HTTPS URL. Every resource is created from the CLI, a container registry holding your image, a storage account for the data, a Container Apps environment, and the app itself with its secrets. In the video the coding agent drives these commands; this page walks them so you understand each one, and the same runbook lives in the repo at docs/azure-deploy.md.

Dave's advice for watching the provisioning fly by applies to running it too. It is fine that this feels like a lot the first time. "Just see someone do it end to end, follow along, and then start to swap out the components."

daveebbelaar/invoice-review2:20:52

What gets created

Four hosting resources land in the existing rg-invoice-review resource group, next to the Document Intelligence and Foundry resources you already have:

ResourceExample nameTierPurpose
Container RegistryacrinvreviewweuBasicStores the Docker image
Storage accountstinvreviewweuStandard LRSAzure Files for SQLite and uploads
Container Apps Environmentcae-invoice-reviewConsumptionHosting environment, brings Log Analytics with it
Container Appca-invoice-reviewConsumptionThe running app

Costs stay small but are not zero. ACR Basic carries a standing monthly fee while it exists, and Container Apps bills while a replica runs. The storage account is close to free, which is exactly why it plays database here. Dave compares it to a managed PostgreSQL server at 20 to 40 dollars a month, while "a storage account is pretty much free on Azure."

Reuse, do not recreate

This deploy goes into the resource group you already have. Do not recreate Document Intelligence or Foundry, and when you clean up later, do not delete the resource group. The teardown commands in the next stage remove hosting only.

Step 0: names and secrets

Everything is parameterized up front. Run this from the repo root:

RG=rg-invoice-review
LOCATION=westeurope
ACR_NAME=acrinvreviewweu          # change if taken
STORAGE_NAME=stinvreviewweu       # change if taken
SHARE_NAME=invoice-review-data
ENV_NAME=cae-invoice-review
APP_NAME=ca-invoice-review
STORAGE_DEF=invoice-review-files

az account show
az group show --name "$RG"

ACR and storage account names are globally unique across all of Azure, so append your own suffix if creation fails on the name. The two show commands are the preflight, right subscription, resource group exists.

Next, load the provider credentials from your local .env and generate the two auth secrets:

set -a
source backend/.env
set +a

APP_ACCESS_PASSWORD="$(openssl rand -base64 18)"
APP_SESSION_SECRET="$(openssl rand -hex 32)"
# Save APP_ACCESS_PASSWORD somewhere safe; you need it to sign in.

set -a exports everything source reads, so the .env values become shell variables the later commands can pass along. The password and session secret are freshly generated random values, never reused from anywhere.

One normalization guards against the endpoint gotcha from the LLM service stage. The OpenAI base URL must end in /openai/v1/, and this snippet fixes it whichever form your .env has:

OPENAI_EP="$AZURE_OPENAI_ENDPOINT"
case "$OPENAI_EP" in
  */openai/v1|*/openai/v1/) ;;
  */) OPENAI_EP="${OPENAI_EP}openai/v1/" ;;
  *) OPENAI_EP="${OPENAI_EP}/openai/v1/" ;;
esac

Step 1: registry and image

az acr create \
  --resource-group "$RG" \
  --name "$ACR_NAME" \
  --sku Basic \
  --location "$LOCATION"

az acr update --name "$ACR_NAME" --admin-enabled true

ACR_USER="$(az acr credential show -n "$ACR_NAME" --query username -o tsv)"
ACR_PASS="$(az acr credential show -n "$ACR_NAME" --query 'passwords[0].value' -o tsv)"

az acr build \
  --registry "$ACR_NAME" \
  --resource-group "$RG" \
  --image invoice-review:latest \
  .

The last command is the quiet star of the deployment. az acr build uploads the repo and builds the Dockerfile on Azure's build servers. You do not need Docker installed locally at all. A few minutes of streamed build logs later, invoice-review:latest sits in the registry. Admin access is enabled so the Container App can pull with a username and password, the simple auth model that fits a demo.

Step 2: storage for SQLite

az storage account create \
  --resource-group "$RG" \
  --name "$STORAGE_NAME" \
  --location "$LOCATION" \
  --sku Standard_LRS \
  --kind StorageV2

STORAGE_KEY="$(az storage account keys list \
  --resource-group "$RG" \
  --account-name "$STORAGE_NAME" \
  --query '[0].value' -o tsv)"

az storage share create \
  --account-name "$STORAGE_NAME" \
  --account-key "$STORAGE_KEY" \
  --name "$SHARE_NAME"

Container filesystems are ephemeral; every restart starts blank. The app keeps its state in /app/data, the SQLite file plus uploads, so that directory needs to live outside the container. An Azure Files share is the cheapest thing that survives restarts, and in the next stage it gets mounted at exactly that path.

Step 3: the environment

az containerapp env create \
  --resource-group "$RG" \
  --name "$ENV_NAME" \
  --location "$LOCATION"

A Container Apps environment is the boundary your apps run inside, and creating one is the slowest step of the deploy. It can sit in Waiting for several minutes; poll until it reports Succeeded before moving on:

az containerapp env show -g "$RG" -n "$ENV_NAME" \
  --query properties.provisioningState -o tsv
# Succeeded

Then register the file share with the environment, under the name the volume mount will reference:

az containerapp env storage set \
  --resource-group "$RG" \
  --name "$ENV_NAME" \
  --storage-name "$STORAGE_DEF" \
  --azure-file-account-name "$STORAGE_NAME" \
  --azure-file-account-key "$STORAGE_KEY" \
  --azure-file-share-name "$SHARE_NAME" \
  --access-mode ReadWrite

Step 4: create the app

The big one. It pulls the image from ACR, opens external HTTPS ingress to port 8000, and wires every environment variable the backend's Settings class expects:

az containerapp create \
  --resource-group "$RG" \
  --name "$APP_NAME" \
  --environment "$ENV_NAME" \
  --image "$ACR_NAME.azurecr.io/invoice-review:latest" \
  --registry-server "$ACR_NAME.azurecr.io" \
  --registry-username "$ACR_USER" \
  --registry-password "$ACR_PASS" \
  --target-port 8000 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 1 \
  --cpu 0.5 \
  --memory 1.0Gi \
  --secrets \
    "di-key=$AZURE_DOCUMENT_INTELLIGENCE_KEY" \
    "openai-key=$AZURE_OPENAI_API_KEY" \
    "access-password=$APP_ACCESS_PASSWORD" \
    "session-secret=$APP_SESSION_SECRET" \
  --env-vars \
    "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT=$AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT" \
    "AZURE_DOCUMENT_INTELLIGENCE_KEY=secretref:di-key" \
    "AZURE_OPENAI_ENDPOINT=$OPENAI_EP" \
    "AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-gpt-5.6-terra}" \
    "AZURE_OPENAI_API_KEY=secretref:openai-key" \
    "APP_ACCESS_PASSWORD=secretref:access-password" \
    "APP_SESSION_SECRET=secretref:session-secret" \
    "FRONTEND_DIST_DIR=/app/frontend/dist"

Two patterns in there deserve a close look.

First, secrets versus plain variables. Sensitive values, the two API keys, the password, the session secret, go in --secrets, where the portal stores them write-only. The --env-vars list then references them with secretref: instead of repeating the value. Non-sensitive config, the endpoints and the deployment name, rides as plain environment variables you can read in the portal. This split is the answer to the puzzle Dave hits in the video when he finds keys under Secrets but no endpoints there. The endpoints were never secret to begin with.

Second, the replica count. --min-replicas 1 --max-replicas 1 is not a cost optimization. Multiple replicas would mean multiple containers writing one SQLite file over a network share, and that fails, with sqlite3.OperationalError: database is locked in the logs. The single-writer ceiling is the price of the SQLite-on-Azure-Files shortcut, and it is the first thing the enterprise page revisits.

Finally, grab the URL and pin CORS to it:

FQDN="$(az containerapp show -g "$RG" -n "$APP_NAME" --query properties.configuration.ingress.fqdn -o tsv)"
echo "App URL: https://$FQDN"

az containerapp update \
  --resource-group "$RG" \
  --name "$APP_NAME" \
  --set-env-vars "ALLOWED_ORIGIN=https://$FQDN"

Azure hands you a generated subdomain on azurecontainerapps.io with TLS included. A custom domain is possible but unnecessary here. The app is live, but its data directory is still inside the container. Making it survive a restart is the next stage.

Checkpoint

  • invoice-review:latest shows in the registry after az acr build
  • The environment reports Succeeded and lists the file share under its storage
  • The app is created with min and max replicas of 1, and https://$FQDN/health returns {"status":"ok"}
  • You saved APP_ACCESS_PASSWORD, and you can explain which values became secrets and why

On this page