Developing against Azure#
The development stack has two stack targets. You choose one in run-dev-enhanced.ps1 (main
menu 7).
| Local (offline) | Azure | |
|---|---|---|
| The apps (gateway, main API, WebApi, SPA, OPA, Keycloak) | Docker, with dotnet watch |
the same |
| Shared configuration | App Configuration emulator | your own App Configuration store |
| Key Vault | none | your own vault |
| Blob storage (host status, customer data, packages) | Azurite | your own storage account |
| SQL | the local mssql container |
your own Azure SQL database |
| Service Bus (agents) | off | the shared namespace |
| Log Analytics, App Insights | none | shared workspace, your own App Insights |
| Needs | Docker | Docker, network, az login once a day for the firewall rule |
Local is the stack as it always was, and it stays the fallback when you are offline. Azure takes SQL Server and the emulators off your machine, and gives you what cannot be emulated: - Key Vault references; - Log Analytics for the Exceptions page; - Service Bus request/reply with agents.
The data in the two targets is separate, and nothing is copied between them.
flowchart LR
subgraph Laptop["Your machine (Docker)"]
BFF["Gateway"] --- API["Main API"] --- WebApi
OPA["OPA"] --- Keycloak
end
subgraph Own["rg-cc-dev-{alias} (yours)"]
AC["App Configuration<br/>Developer tier"]
KV["Key Vault"]
ST["Storage<br/>config-status, customer-data, packages"]
AI["App Insights"]
end
subgraph Shared["rg-cc-dev-shared"]
SQL[("Azure SQL<br/>cc-dev-{alias}")]
SB["Service Bus<br/>agent topics"]
LA["Log Analytics"]
end
Laptop -- "signed in as cc-dev-{alias}<br/>(certificate)" --> Own
Laptop -- "token, no password" --> SQL
WebApi --> SB
WebApi --> LA
AI --> LA
Who runs what#
| Script | Who | When |
|---|---|---|
scripts/dev-azure/provision-shared.ps1 |
an admin | once, and after a change to infra/dev/shared.bicep |
scripts/dev-azure/onboard-developer.ps1 |
an admin, who must be a member and an owner of the SQL administrators group | once per developer |
scripts/dev-azure/connect.ps1 |
the developer, with their own az login |
once on each machine, and to refresh |
scripts/dev-azure/offboard-developer.ps1 |
an admin | when a developer leaves |
All four need PowerShell 7 and the Azure CLI. Onboarding also needs go-sqlcmd (winget install
sqlcmd), because the older ODBC sqlcmd cannot sign in with an Azure CLI login.
The order is always the same: the shared services exist first, then each developer is onboarded, then each developer connects their own machine.
flowchart LR
P["provision-shared.ps1<br/>admin, once"] --> O["onboard-developer.ps1<br/>admin, per developer"]
O --> C["connect.ps1<br/>developer, per machine"]
C --> R["run-dev-enhanced.ps1<br/>7 → Azure, 1 → 1"]
O -. "when a developer leaves" .-> F["offboard-developer.ps1"]
Provisioning the shared services#
An admin does this once per subscription, before the first developer is onboarded. It creates
rg-cc-dev-shared with everything that has a base charge and is shared by all developers.
Before you start#
- A subscription for development. The SQL free offer counts per subscription (10 databases), so a subscription of its own keeps those for the developers.
- Your permissions.
- To provision: Contributor on the subscription, to create the resource group and its resources, plus User Access Administrator (or Owner), because the template defines a custom role.
- To onboard later: the same, plus permission in Entra ID to create app registrations. That is either the Application Developer role, or the tenant setting that lets users register applications.
- An Entra group for the SQL administrators. It becomes the SQL server's administrator (the
server accepts Entra sign-in only, so there is no SQL login or password). You don't need to make
it yourself: the script suggests
cc-dev-sql-adminsand creates it when it does not exist, with you as its owner and member, after asking (-CreateGroupskips the question). Creating it needs the Entra setting that lets users create security groups, or Groups Administrator.- Every developer becomes a SQL administrator, as on the other database servers where
developers are sysadmin: onboarding adds them to the group.
-NoSqlAdminonboards a developer with their own database only. - The containers never are: they sign in as the developer's service principal, which only has its own database. So a mistake in code running on the stack cannot reach another developer's database.
- Whoever runs onboarding must be a member (it creates database users) and an owner of the group (it adds the developer). Provisioning makes its runner both.
- Every developer becomes a SQL administrator, as on the other database servers where
developers are sysadmin: onboarding adds them to the group.
-
The resource providers registered on the subscription. A new subscription may not have them yet; registering is harmless when they are already there:
-
Signed in with
az login, to the tenant that holds the subscription.
Running it#
| Parameter | |
|---|---|
-Subscription |
The subscription, by name or id. The script switches the Azure CLI to it. |
-SqlAdminGroup |
The Entra group that administers the SQL server, by name or object id. cc-dev-sql-admins by default; created when missing, after asking. |
-CreateGroup |
Create a missing group without asking. |
-Location |
The Azure region, westeurope by default. Onboarding has its own -Location: keep it the same, because a developer's database must be in the SQL server's region. |
-WhatIf |
Shows what would change (az deployment group what-if) and changes nothing. It needs rg-cc-dev-shared to exist, so it is for later runs, not the first one. |
It deploys infra/dev/shared.bicep as the deployment cc-dev-shared. Onboarding and connect.ps1
read that deployment's outputs to find the shared resources. What it creates:
| Resource | Name | Notes |
|---|---|---|
| Resource group | rg-cc-dev-shared |
tagged purpose=commandcenter-dev |
| Service Bus namespace (Standard) | sb-cc-dev-{token} |
local auth off (Entra only), TLS 1.2; the topics agent.state.v1, agent.commands.v1, agent.replies.v1, deploy.replies.v1, bmo.deploy.commands.v1, bmo.package.events.v1, messages kept for a day. Subscriptions are per developer (onboarding) or made by WebApi itself |
| Entra group | cc-dev-sql-admins (or -SqlAdminGroup) |
only when missing; you as owner and member |
| Azure SQL logical server | sql-cc-dev-{token} |
Entra-only authentication, administered by the group, TLS 1.2, public endpoint with no firewall rules: each developer's connect.ps1 adds a rule for their own IP, and onboarding adds a temporary one for the admin |
| Log Analytics workspace | log-cc-dev-{token} |
30 days' retention, capped at 1 GB a day. Each developer's App Insights writes here |
| Custom role | CC Dev SQL Firewall (rg-cc-dev-shared) |
reads the SQL server and manages its firewall rules, nothing else. Onboarding gives it to each developer on the server |
{token} is a short hash of the resource group's id, so the names are the same every time the
script runs in that subscription. When it finishes, it prints the Service Bus and SQL server
addresses and the Log Analytics workspace id.
No database is created here: each developer's database is created at onboarding.
Changing it later#
Edit infra/dev/shared.bicep, check with -WhatIf, then run the script again. It is idempotent:
it updates the resources in place and keeps every developer's database, subscriptions and roles,
which live in their own deployments (cc-dev-developer-{alias}).
Removing everything#
There is no script for this, on purpose:
- Offboard every developer first (below). That removes their resource groups, their databases in the shared server, and their app registrations.
-
Then delete the shared resource group:
This deletes the Service Bus namespace, the SQL server and the Log Analytics workspace with everything in them. It cannot be undone.
Deleting rg-cc-dev-shared before offboarding leaves each developer's resource group and app
registration behind, still billed where they have a charge.
Onboarding a developer#
sequenceDiagram
actor Admin
actor Dev as Developer
participant Entra
participant Azure
participant SQL as Azure SQL
Admin->>Entra: app registration + service principal cc-dev-{alias}, Dev as owner
Admin->>Azure: rg-cc-dev-{alias}: store, vault, storage, App Insights, roles (developer.bicep)
Admin->>Azure: rg-cc-dev-shared: database, heartbeat subscription, roles (developer-shared.bicep)
Admin->>SQL: CREATE USER … FROM EXTERNAL PROVIDER WITH OBJECT_ID (service principal, Dev), db_owner
Dev->>Azure: firewall rule dev-{alias} = this machine's IP
Dev->>Dev: certificate + private key in %APPDATA%\CommandCenter\azure-dev
Dev->>Entra: add the certificate's public part (as owner)
Dev->>Azure: settings into the store (label dev), App Insights string into the vault
Dev->>Dev: .env.azure
-
The admin runs:
The alias is 2–12 lowercase letters and digits. Pass the same
-Locationas for provisioning when it is notwesteurope. The developer becomes a SQL administrator of the development server (a member of the group); add-NoSqlAdminto give them their own database only. 2. The developer runsrun-dev-enhanced.ps1, then 7 → 3, which callsconnect.ps1, and enters the subscription and their alias. After that, 7 → 2 chooses Azure. 3. The developer starts the stack (1 → 1). The first time, they apply the migrations with 3 → 1, which runsGET /api/maintenance/migratein both running APIs.
journey
title A day in Azure mode
section Start
az login (once a day): 3: Developer
run-dev-enhanced 1 → 1: 5: Developer
Firewall rule follows your IP: 5: run-dev-enhanced
section Work
Edit code, dotnet watch reloads: 5: Developer
Change a setting on /config (applies within 2 min): 4: Developer
section Offline
Azure not reachable, local stack offered: 4: run-dev-enhanced
How the containers sign in#
Each developer has a service principal, cc-dev-{alias}. connect.ps1 makes its certificate
on your machine:
- The private key never leaves the machine. It stays in
%APPDATA%\CommandCenter\azure-dev\cc-dev-{alias}.pem, readable by you only, and is mounted
read-only into the containers as /run/secrets/cc-dev-cert.
- Only the public part goes to the app registration, which you own. Adding it with --append keeps
the certificate of another machine of yours.
- The certificate is valid for a year. run-dev-enhanced.ps1 warns two weeks before it expires;
connect.ps1 -RenewCertificate makes a new one.
Your own az login does not reach into the containers: on Windows the Azure CLI token cache is
encrypted for your Windows account, so a Linux container cannot use it.
The hosts' own databases sign in with the same identity, through a token callback
(AzureSqlSignIn). The connection strings have no user, no password and no Authentication=. See
backend/CLAUDE.md → Per-stage connections.
What is where#
compose.azure.yaml holds only the bootstrap values, read from .env.azure:
- which store (Configuration:AppConfig:Endpoint);
- which vault (Configuration:KeyVault:Uri);
- where host status goes (Configuration:Status:BlobServiceUri);
- which identity signs in (Configuration:Identity:* and AZURE_*);
- a snapshot file of its own (config-snapshot.azure.json), so a snapshot of the emulator's settings
never starts a host in Azure mode.
It also blanks the emulator connection strings that compose.dev.yaml sets.
Everything else is in your store at label dev, where /config shows it and can change it. An
environment variable would shadow the store (docs/configuration.md). connect.ps1 writes:
| Key | Value |
|---|---|
ConnectionStrings:DefaultConnection, ConnectionStrings:CommandCenter |
your database, no credentials |
CustomerData:Storage:ServiceUri, BmoBlob:ServiceUri, BmoBlob:Container |
your storage account, container packages |
Agents:ServiceBus:Namespace, Agents:ServiceBus:StateSubscription |
the shared namespace, commandcenter-webapi-{alias} |
LogAnalytics:WorkspaceId |
the shared workspace |
APPLICATIONINSIGHTS_CONNECTION_STRING |
a Key Vault reference to appinsights-connection-string in your vault |
CustomerData:Storage:ConnectionString, BmoBlob:ConnectionString, Agents:ServiceBus:ConnectionString |
empty, so a local value in your user secrets cannot win |
User secrets sit below the store, so a key the store sets wins over them. connect.ps1 lists the
connection-string keys in your user secrets that the store does not set, since those would still
point at local resources. It prints their names only.
The store refreshes every 2 minutes in Azure mode, against 5 seconds with the emulator. Three
hosts each check two sentinels, which comes to about 180 requests an hour, well inside the 3,000 a
day the Developer tier includes. A change on /config therefore applies within 2 minutes.
What does not work in Azure mode#
- Stage clone and database drop. They need disk BACKUP/RESTORE and
master, which Azure SQL Database lacks. They are refused before anything changes: the clone at its first step, the drop task at its only step.DELETE /api/deploylogs/{id}/databaseanswers 503not-supported-on-azure-sql. Use the local stack for them. - Database sizes on the customers page and the metrics pages are unknown, because there is no
sys.master_files. - Customer stages. Your Azure database starts empty, and stage rows that say
localhostpoint at it. Stage databases in Azure are a later step. - BMO (profile
bmo) stays on its local emulators. Its Service Bus subscription names are fixed, so two developers' BMOs would take each other's messages. - The first request after the database paused (an hour without the stack running) can fail while the database resumes, which takes up to a minute. Retry.
Costs#
| Where | Charge | |
|---|---|---|
| Service Bus Standard | shared | a monthly base charge |
| Azure SQL | shared server, a database per developer | the free offer: 10 databases per subscription, 100,000 vCore-seconds a month each. The stack's pollers keep a database online while it runs, so it pauses only when the stack is down. Onboarding with -NoFreeOffer makes a billed serverless database instead |
| Log Analytics | shared | per GB ingested, capped at 1 GB a day |
| App Configuration Developer | per developer | a daily charge, with 3,000 requests a day included |
| Key Vault, storage, App Insights | per developer | pay per use, close to nothing |
Offboarding#
./scripts/dev-azure/offboard-developer.ps1 -Subscription "<subscription>" -Alias <alias> -User <upn>
It lists everything onboarding created for that alias, and deletes exactly that after you type the alias: - the developer's resource group, then it purges their Key Vault so the name is free again; - their membership of the SQL administrators group; - their database, subscription and firewall rule; - only the role assignments onboarding granted; - the app registration.
It never deletes anything else. The developer removes .env.azure, .dev-stack.json and the
certificate folder on their own machine.
Troubleshooting#
| Symptom | Cause, fix |
|---|---|
Deployment 'cc-dev-shared' was not found in 'rg-cc-dev-shared' |
The shared services have not been provisioned in this subscription yet (provision-shared.ps1), or the Azure CLI is on another subscription |
provision-shared.ps1 fails on the role definition |
Your account lacks User Access Administrator or Owner on the subscription |
A deployment fails with MissingSubscriptionRegistration |
A resource provider is not registered: see Before you start |
run-dev-enhanced offers the local stack |
Its checks listed what is wrong: no .env.azure, the certificate missing or expired, or the store not answering (offline) |
| A host stops at start with "cannot reach the store" | Configuration:AppConfig:Optional is false in Azure mode, deliberately. Check the network, or start the local stack |
Login failed on the database |
The firewall rule is not at your current IP: connect.ps1 -FirewallOnly, or restart through run-dev-enhanced |
AADSTS700027 or a certificate error |
The certificate on the app registration is not the one on disk: connect.ps1 -RenewCertificate |
| A setting from your user secrets still applies | connect.ps1 listed it: remove it there, or set it in your store |