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 adotmarcdatabase and a firewall rule allowing Azure services. -
A Key Vault (RBAC-authorized), with the Container App's managed identity granted the
Key Vault Secrets Userrole. -
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:Role Scope Actions Container App role the Container App itself containerApps/read,containerApps/writeManaged Environment role the Container Apps environment managedEnvironments/read,managedEnvironments/join/action,managedEnvironments/managedCertificates/read,managedEnvironments/managedCertificates/writemanagedEnvironments/join/actionis 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).
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
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.
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
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.
Update to a specific release (recommended)
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>"
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.