Skip to main content

Deploy to Azure

infra/main.bicep provisions everything needed to run dotMARC on Azure:

  • A Container Apps environment (backed by a Log Analytics workspace) and a Container App running the published dotMARC image, with a system-assigned managed identity, sticky sessions (required for Blazor Server), and a single replica.

  • An Azure Database for PostgreSQL Flexible Server (Standard_B1ms, PostgreSQL 18) with a dotmarc database and a firewall rule allowing Azure services.

  • A Key Vault (RBAC-authorized), with the Container App's managed identity granted the Key Vault Secrets User role.

  • Optionally (enableMtaStsHosting), two narrowly-scoped custom RBAC roles letting the Container App manage its own custom domains and managed certificates, for MTA-STS policy hosting. Exact permissions:

    RoleScopeActions
    Container App rolethe Container App itselfcontainerApps/read, containerApps/write
    Managed Environment rolethe Container Apps environmentmanagedEnvironments/read, managedEnvironments/join/action, managedEnvironments/managedCertificates/read, managedEnvironments/managedCertificates/write

    managedEnvironments/join/action is easy to leave out and easy to miss when you do: Azure requires it on any update to the Container App's ingress/custom-domain config, not just when first attaching the app to the environment, and its own error (LinkedAuthorizationFailed/403) reads like a Container-App-scoped permission problem rather than pointing at the environment-scoped role that's actually missing it.

Before deploying, complete the two Entra app registrations described in Getting Started, the Bicep template takes the same non-secret client IDs/tenant IDs as deployment parameters, and the client secrets are set into Key Vault after deployment (see below).

0 of 5 complete

1. Fill in the parameters​

Copy infra/main.parameters.json and replace the REPLACE_ME placeholders, as checked in, the file contains placeholder values only and is not meant to be deployed as-is. At minimum, set postgresAdminPassword, graphClientId, graphTenantId, graphMailboxAddress, entraIdTenantId, and entraIdClientId. For containerImage, use the GHCR image published by CI/CD, ghcr.io/homotechsual/dotmarc:latest, or a specific version tag from a release, rather than building your own.

Alternatively, leave the file untouched and pass overrides inline with --parameters key=value on the command below.

2. Deploy​

$RG = 'your-dotmarc-resourcegroup'
az group create --name $RG --location uksouth
az deployment group create `
--resource-group $RG `
--template-file infra/main.bicep `
--parameters infra/main.parameters.json

3. Register the deployed hostname as an OIDC redirect URI​

Custom Domain

If you're configuring a custom domain in the Azure Portal for your container app your OIDC redirect URI should be https://<yourcustomURL>/signin-oidc

The Container App's hostname isn't known until after deployment (Container Apps assigns its FQDN from the environment's own DNS suffix), so the redirect URI registered in the dashboard sign-in app registration during one-time setup can't be filled in ahead of time. Read the deployed URL from the template's containerAppUrl output:

az deployment group show --resource-group $RG --name main --query properties.outputs.containerAppUrl.value -o tsv

Then, in the dotmarc-dashboard app registration's Authentication blade, add a Web platform redirect URI of <that URL>/signin-oidc (alongside or replacing the placeholder one added during one-time setup). Sign-in will fail with AADSTS50011 until this is done.

4. Populate the Key Vault secrets​

The template deliberately provisions three Key Vault secrets: Graph-ClientSecret, EntraId-ClientSecret, and ConnectionStrings-DotMarc with empty values, rather than accepting secret material as deployment parameters (which would put it on the command line or in a parameters file). Until these are set, the app can't sign in or reach Postgres. Populate them directly:

$RG = 'dotmarc-rg'
$KV = az deployment group show --resource-group $RG --name main --query properties.outputs.keyVaultName.value -o tsv
$PG_FQDN = az deployment group show --resource-group $RG --name main --query properties.outputs.postgresServerFqdn.value -o tsv

az keyvault secret set --vault-name $KV --name Graph-ClientSecret --value "<graph app client secret>"
az keyvault secret set --vault-name $KV --name EntraId-ClientSecret --value "<entra id app client secret>"
az keyvault secret set --vault-name $KV --name ConnectionStrings-DotMarc `
--value "Host=$PG_FQDN;Database=dotmarc;Username=<postgresAdminUsername>;Password=<postgresAdminPassword>;Ssl Mode=Require"

$APP = az deployment group show --resource-group $RG --name main --query properties.outputs.containerAppName.value -o tsv
$REVISION = az containerapp show --resource-group $RG --name $APP --query properties.latestRevisionName -o tsv
az containerapp revision restart --resource-group $RG --name $APP --revision $REVISION

Substitute the two Entra app registration client secrets created in the one-time setup steps above, and the postgresAdminUsername/postgresAdminPassword values used in step 1. The container app's secrets reference these by versionless Key Vault URL, so az containerapp revision restart forces it to re-fetch them immediately rather than waiting for their normal refresh cycle.

Don't forget InitialAdmins__Emails

Set InitialAdmins__Emails (see Getting Started) before this deployment's first startup, without it, the tightened authorization policy locks out every user, including you.

Optional: Key Vault-backed secret storage​

By default, runtime-editable secrets (the HaloPSA API client secret, Cloudflare/Azure DNS push OAuth client secrets - anything configured through an in-app settings page rather than a deployment parameter) are encrypted and stored in Postgres. To store them in this deployment's Key Vault instead, redeploy with enableKeyVaultWrite set to true - this grants the container app's managed identity a narrowly-scoped write role on the vault (see infra/main.bicep's keyVaultWriteRole, it adds only secrets/setSecret, read is already covered by the existing Key Vault Secrets User assignment). No manual az keyvault secret set needed here, the app writes each secret itself the first time you save it from the relevant settings page.

5. Update or redeploy dotMARC​

This is the Azure part of Update or redeploy dotMARC, which also covers what to check before and after an update.

Container Apps stores the image reference in a revision. Check the image configured on the currently active app, then list its revision history:

$RG = 'dotmarc-rg'
$APP = az deployment group show --resource-group $RG --name main --query properties.outputs.containerAppName.value -o tsv

az containerapp show `
--resource-group $RG `
--name $APP `
--query "properties.template.containers[0].image" `
--output tsv

az containerapp revision list `
--resource-group $RG `
--name $APP `
--query "[].{Revision:name,Active:properties.active,Created:properties.createdTime,Image:properties.template.containers[0].image}" `
--output table
What version is actually running?

For an immutable release reference such as ghcr.io/homotechsual/dotmarc:0.9.2, the tag identifies the version. If the configured image is :latest, Azure only reports :latest - that mutable tag does not reveal which build was pulled. Check the version shown in dotMARC's footer, or compare the deployment time with the image published by the relevant release. Prefer release tags for production so the deployed version and rollback target are unambiguous.

Use an immutable release tag. az containerapp update creates a new revision and, because this template uses single-revision mode, moves traffic to it after it becomes ready:

$VERSION = '<release-version>'
$IMAGE = "ghcr.io/homotechsual/dotmarc:$VERSION"
$REVISION_SUFFIX = "release-$(Get-Date -Format yyyyMMddHHmmss)"

az containerapp update `
--resource-group $RG `
--name $APP `
--image $IMAGE `
--revision-suffix $REVISION_SUFFIX

The app runs EF Core migrations during startup. Do not run a separate migration command; wait for the new revision to become ready before considering the update complete.

Redeploy when the app is configured with :latest​

Publishing a new image under :latest does not automatically update a running Container App. Running az containerapp revision restart only restarts the existing revision; use it for refreshed Key Vault references or a stuck replica, not as the image-update mechanism.

If the configured image string remains exactly the same, force a new revision by supplying a unique revision suffix:

$IMAGE = 'ghcr.io/homotechsual/dotmarc:latest'
$REVISION_SUFFIX = "latest-$(Get-Date -Format yyyyMMddHHmmss)"

az containerapp update `
--resource-group $RG `
--name $APP `
--image $IMAGE `
--revision-suffix $REVISION_SUFFIX

The unique suffix makes this a new revision even though the image reference is still :latest, so Container Apps resolves and pulls the tag again. This is inherently less auditable than deploying a release tag: the revision records :latest, not the release version it represented at that time.

Update through Bicep​

If the deployment is intentionally managed through infra/main.bicep, set containerImage to the new immutable release tag, run the --what-if check in the next section, then apply the template. A Bicep redeploy with containerImage still set to the same :latest string may show no template change and therefore may not create a new revision. In that case, either change to a release tag or use the forced-revision command above.

Verify the update​

$REVISION = az containerapp show `
--resource-group $RG `
--name $APP `
--query properties.latestReadyRevisionName `
--output tsv

az containerapp revision show `
--resource-group $RG `
--name $APP `
--revision $REVISION `
--query "{Revision:name,Active:properties.active,Image:properties.template.containers[0].image,Replicas:properties.replicas}" `
--output table

az containerapp logs show `
--resource-group $RG `
--name $APP `
--revision $REVISION `
--tail 100

Then load the dashboard, confirm the expected version in its footer, sign in, and check Poll status for a successful post-deployment cycle.

Roll back​

Create another revision using the previous immutable release tag:

$PREVIOUS_VERSION = '<previous-release-version>'
$IMAGE = "ghcr.io/homotechsual/dotmarc:$PREVIOUS_VERSION"
$REVISION_SUFFIX = "rollback-$(Get-Date -Format yyyyMMddHHmmss)"

az containerapp update `
--resource-group $RG `
--name $APP `
--image $IMAGE `
--revision-suffix $REVISION_SUFFIX

Do not roll back blindly after a release that applied database migrations: application rollback is only safe when the older release remains compatible with the current schema. A deployment that only ever recorded :latest also has no reliable historical image target; choose the previous release tag explicitly.

6. Re-running the template later (e.g. to turn on MTA-STS hosting)​

enableMtaStsHosting is off by default, so enabling it (or making any other change) usually means re-running az deployment group create against a resource group that's no longer in exactly the state the template describes: you've likely since bound a custom domain to the Container App (through the Portal, or az containerapp hostname add), and Azure may have changed Postgres storage/tier/Entra-auth settings that aren't declared in main.bicep at all. ARM deployments only touch what's declared, but for a property Bicep explicitly declares as a whole object (this template's ingress block, or flexibleServers' storage/authConfig), a redeploy replaces that whole object with what the template says, silently dropping anything set outside it.

Always run --what-if before the real deployment once a resource group has any post-deploy customization:

az deployment group what-if `
--resource-group $RG `
--template-file infra/main.bicep `
--parameters infra/main.parameters.json

Read the diff carefully. A ~ Microsoft.App/containerApps/<name> entry removing properties.configuration.ingress.customDomains, or a ~ Microsoft.DBforPostgreSQL/flexibleServers/<name> entry removing storage/authConfig properties, means applying it would break your custom domain or reset database settings you configured after the original deployment. Don't proceed if you see that; either update the template's parameters/resources to match current state first, or apply just the specific change you actually need by hand instead of the whole template:

# The two custom roles enableMtaStsHosting would have created, applied directly instead of via
# a full template redeploy. See the roleDefinition resources in main.bicep for the exact
# actions/description each one needs.
az role definition create --role-definition <role JSON, one per role>
az role assignment create --assignee-object-id <container app's principalId> `
--assignee-principal-type ServicePrincipal --role <role id> --scope <container app or environment resource id>

# The env vars enableMtaStsHosting's parameters would have set, merged onto the existing
# container without touching anything else (existing env vars are left alone):
az containerapp update --name $APP --resource-group $RG --set-env-vars `
"MtaSts__HostingHostname=<container app FQDN>" "MtaSts__Provisioner=Azure" `
"MtaSts__AzureSubscriptionId=<subscription id>" "MtaSts__AzureResourceGroupName=$RG" `
"MtaSts__AzureContainerAppName=$APP" "MtaSts__AzureManagedEnvironmentName=<environment name>"
Git Bash

If you're running these az commands from Git Bash on Windows rather than PowerShell, any argument starting with / (every Azure resource ID) gets silently mangled into a Windows path by MSYS's path conversion, MissingSubscription errors on az role assignment create are the usual symptom. Set export MSYS_NO_PATHCONV=1 first.

Want to host MTA-STS policies for customer domains? See MTA-STS: hosting infrastructure for the one-time enableMtaStsHosting setup - each domain you later enable also needs an extra domain-ownership TXT record Container Apps requires that a self-hosted deployment doesn't, covered in MTA-STS: enabling a domain.