This project uses Aspire's built-in publish/deploy pipeline to deploy to Azure. Deployments are automated via GitHub Actions:
- Every push to
mainruns tests and auto-deploys to production. - Pushing a version tag (
v*) additionally creates a GitHub Release with changelog notes.
| Resource | Azure Service | Notes |
|---|---|---|
| API + React SPA | Azure Container Apps | React build is bundled into the API container as static files |
| PostgreSQL | Azure Database for PostgreSQL Flexible Server | Burstable B1ms, 32 GB, Entra ID auth (no passwords) |
| Identity | User-Assigned Managed Identity | RBAC roles for PostgreSQL |
| Container Registry | Azure Container Registry | Auto-created for container images |
| Networking | Container Apps Environment | Shared environment with Log Analytics |
The React SPA is not deployed as a separate service. At publish time, Aspire runs npm run build and copies the output into the API container's wwwroot/ folder. The API serves the SPA as static files in production.
Before the automated pipeline can deploy, you need to configure Azure OIDC credentials and GitHub secrets. This is a one-time setup.
- Azure CLI — Install
- Azure subscription with permissions to create resource groups and app registrations
- GitHub repo admin access — to configure environments and secrets
az login
az group create --name aspire-academy --location eastusaz ad app create --display-name "AspireAcademy-Deploy"
APP_ID=$(az ad app list --display-name "AspireAcademy-Deploy" --query "[0].appId" -o tsv)
# Create a service principal
az ad sp create --id "$APP_ID"This lets GitHub Actions authenticate to Azure without stored passwords:
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-production",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:adamint/AspireAcademy:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'SUBSCRIPTION_ID=$(az account show --query id -o tsv)
SP_ID=$(az ad sp list --filter "appId eq '$APP_ID'" --query "[0].id" -o tsv)
# Contributor on the resource group (creates/updates resources)
az role assignment create --assignee "$SP_ID" \
--role Contributor \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/aspire-academy"
# User Access Administrator (for managed identity role assignments)
az role assignment create --assignee "$SP_ID" \
--role "User Access Administrator" \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/aspire-academy"You'll need two values for the first deploy:
| Secret | Description |
|---|---|
| JWT signing key | A random string, at least 32 characters. Used to sign auth tokens. |
| AI connection string | Endpoint=https://your-resource.services.ai.azure.com/;Key=your-key (Azure AI Foundry) or Key=sk-your-key (OpenAI) |
Generate a JWT key:
openssl rand -base64 48- Go to your repo's Settings → Environments and create an environment named
production - Go to Settings → Secrets and variables → Actions and add:
| Type | Name | Value |
|---|---|---|
| Secret | AZURE_CLIENT_ID |
The app registration's Application (client) ID |
| Secret | AZURE_TENANT_ID |
Your Entra ID tenant ID |
| Secret | AZURE_SUBSCRIPTION_ID |
Target Azure subscription ID |
| Variable | AZURE_RESOURCE_GROUP |
Resource group name (e.g., aspire-academy) |
| Variable | AZURE_LOCATION |
Azure region (e.g., eastus) |
The first deploy must be run manually to provide the JWT key and OpenAI connection string (these get stored as ACA secrets and persist for future deploys):
aspire deployThe Aspire CLI will interactively prompt you for:
- Azure tenant — Select your Entra ID tenant
- Azure subscription — Select the subscription to deploy to
- Resource group — Enter the name from Step 1
- Location — Azure region
- jwt-key — Paste your JWT signing key
- openai connection string — Paste your AI connection string (Azure AI Foundry endpoint or OpenAI API key)
After this first deploy, all subsequent deploys (automated or manual) reuse these stored secrets.
After the one-time setup, every release is a single command:
./scripts/release.sh 1.5.0This script:
- Validates changelog entry exists in
AspireAcademy.Web/src/data/changelog.ts - Checks for uncommitted changes
- Runs backend tests and frontend checks locally
- Creates git tag
v1.5.0and pushes it
Pushing the tag triggers the Release & Deploy GitHub Actions workflow, which:
- Validates — confirms changelog entry
- Tests — runs unit tests, integration tests, and frontend checks in parallel
- Creates GitHub Release — with structured release notes parsed from changelog.ts
- Deploys to Azure — runs
aspire deploy --non-interactive - Smoke tests — verifies
/healthreturns 200 - Updates Release — appends deployed URL to the GitHub Release
To redeploy without a new release (e.g., config change):
- From GitHub: Actions → Release & Deploy → Run workflow → check "Skip tests"
- From CLI:
aspire deploy
For local development or debugging deployments:
aspire deploySubsequent deploys are faster — only the container image is rebuilt and redeployed. Infrastructure changes are applied incrementally via Bicep.
After deployment completes, the CLI prints the Container App URL. You can also find it:
az containerapp show --name api --resource-group <your-rg> \
--query "properties.configuration.ingress.fqdn" -o tsvYour app is live at https://api.<environment-domain>.azurecontainerapps.io.
When the API container starts in production for the first time:
- EF Core migrations run — Creates all database tables in the Azure PostgreSQL instance
- Curriculum is loaded — Reads
worlds.yaml, lesson markdown, quizzes, and challenges from the container filesystem and seeds the database - Health check passes —
/healthreturns 200, Container Apps marks the app as healthy
This is automatic. No manual database setup is needed.
To generate the Bicep templates without deploying:
aspire publish -o ./aspire-outputThis creates:
aspire-output/
├── main.bicep # Top-level orchestrator
├── aca-env/aca-env.bicep # Container Apps Environment + Log Analytics
├── aca-env-acr/aca-env-acr.bicep # Azure Container Registry
├── postgres/postgres.bicep # PostgreSQL Flexible Server
├── api/api.bicep # API Container App
├── api-identity/ # Managed Identity
└── api-roles-postgres/ # PostgreSQL RBAC
You can inspect, customize, or deploy these manually with az deployment sub create.
These are injected by Aspire into the container at deploy time:
| Variable | Source |
|---|---|
ConnectionStrings__academydb |
Azure PostgreSQL connection string (with Entra ID auth) |
ConnectionStrings__openai |
AI connection string — Azure AI Foundry endpoint or OpenAI key (stored as ACA secret) |
Jwt__Key |
From your input (stored as ACA secret) |
AZURE_CLIENT_ID |
Managed identity client ID |
HTTP_PORTS |
Container port (auto-assigned) |
- PostgreSQL: Entra ID authentication via managed identity (no password)
- AI (Azure AI Foundry / OpenAI): Connection string with endpoint+key or API key (stored as ACA secret)
- JWT: Symmetric key (stored as ACA secret)
Default: 1 replica minimum. To adjust:
az containerapp update --name api --resource-group <your-rg> \
--min-replicas 1 --max-replicas 5For a minimal deployment (Burstable tier, single replica):
| Resource | ~Monthly Cost |
|---|---|
| Container Apps (1 replica, 0.5 vCPU / 1 GB) | ~$15 |
| PostgreSQL Flexible Server (B1ms) | ~$13 |
| Container Registry (Basic) | ~$5 |
| Log Analytics | ~$2-5 |
| Total | ~$35-40/month |
You need to provide values for jwt-key and openai connection string (which accepts Azure AI Foundry or OpenAI). Run aspire deploy (not --non-interactive) to get the interactive prompts. This happens on first deploy before secrets are stored.
Check container logs:
az containerapp logs show --name api --resource-group <your-rg> --type consoleCommon causes:
- Database migration failed (check PostgreSQL firewall rules)
- Missing or invalid AI connection string (check
ConnectionStrings__openai) - JWT key too short (must be at least 32 characters)
The EF Core migration history table and initial migration must exist. If deploying from scratch, the API uses MigrateAsync() which applies the migration in Migrations/20260326042829_InitialCreate.cs. Ensure that migration file is up to date with your model.
The federated credential subject doesn't match. Verify the credential was created with repo:adamint/AspireAcademy:environment:production and that the deploy job has environment: production set.