Deploying to Azure
A Bicep template that runs OrbisID on Azure Container Apps against an Azure Database for PostgreSQL Flexible Server, pulling the official orbisid/orbisid-backend and orbisid/orbisid-frontend images directly from Docker Hub — no Azure Container Registry, no image build step.
Architecture
Front Door's path-based routing mirrors what nginx.conf does for a Docker Compose install (/api/ and /actuator/ to the backend, everything else to the frontend), and it terminates TLS with a certificate it manages itself — no certificate request step needed before deploying, unlike AWS.
Prerequisites
- Azure CLI installed and logged in (
az login) - A subscription with quota for Container Apps, PostgreSQL Flexible Server, and Front Door Standard
Unlike AWS, no domain or certificate is required up front — Front Door issues its endpoint a *.azurefd.net hostname with a managed certificate automatically. Add your own custom domain afterwards in the Portal (Front Door > Domains) if you want one; it's optional.
Deploy
Copy the template below and save it locally as main.bicep:
// =============================================================================
// OrbisID on Azure — Container Apps (backend + frontend, pulling the official
// Docker Hub images), Azure Database for PostgreSQL Flexible Server, and Azure
// Front Door Standard for path-based routing (/api/*, /actuator/* -> backend,
// everything else -> frontend — the same split docs-site/nginx.conf does for a
// Docker Compose install) plus managed TLS.
// See docs-site/docs/installation/cloud-azure.md for the full walkthrough.
// =============================================================================
@description('Prefix used to name every resource this deployment creates.')
param environmentName string = 'orbisid'
param location string = resourceGroup().location
@description('Tag to pull from orbisid/orbisid-backend and orbisid/orbisid-frontend on Docker Hub. Pin to a specific released version for production — "latest" will change under you on the next image push.')
param orbisIdVersion string = 'latest'
param dbAdminUsername string = 'orbisid'
@secure()
@minLength(8)
@description('Administrator password for the PostgreSQL Flexible Server.')
param dbAdminPassword string
param dbName string = 'orbisid'
@description('Burstable SKU name. If you change this to a non-Burstable tier, also update dbSkuTier below.')
param dbSkuName string = 'Standard_B1ms'
param dbSkuTier string = 'Burstable'
param dbStorageSizeGB int = 32
@secure()
@description('AES-256-GCM key OrbisID uses to encrypt stored credentials. Generate with `openssl rand -base64 32`. Keep this safe outside of Azure too — if it is lost, encrypted data cannot be recovered.')
param encryptionKey string
@description('Container Apps CPU cores for the backend, e.g. 1.0 = 1 vCPU.')
param backendCpu string = '1.0'
param backendMemory string = '2Gi'
param frontendCpu string = '0.5'
param frontendMemory string = '1Gi'
@description('Minimum replicas. Kept at 1 by default — scale-to-zero would cold-start every login.')
param minReplicas int = 1
param maxReplicas int = 3
// ── Logging ──────────────────────────────────────────────────────────────
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
name: '${environmentName}-logs'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
// ── Container Apps environment ──────────────────────────────────────────
resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = {
name: '${environmentName}-env'
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalytics.properties.customerId
sharedKey: logAnalytics.listKeys().primarySharedKey
}
}
}
}
// ── Database ─────────────────────────────────────────────────────────────
resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = {
name: '${environmentName}-postgres'
location: location
sku: {
name: dbSkuName
tier: dbSkuTier
}
properties: {
version: '16'
administratorLogin: dbAdminUsername
administratorLoginPassword: dbAdminPassword
storage: {
storageSizeGB: dbStorageSizeGB
}
backup: {
backupRetentionDays: 7
geoRedundantBackup: 'Disabled'
}
highAvailability: {
mode: 'Disabled'
}
}
}
// Public access + "allow Azure services" firewall rule, for a straightforward
// getting-started setup. For production, prefer VNet-integrating both this
// server and the Container Apps environment instead — see cloud-azure.md.
resource postgresFirewallAllowAzure 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2022-12-01' = {
parent: postgresServer
name: 'AllowAzureServices'
properties: {
startIpAddress: '0.0.0.0'
endIpAddress: '0.0.0.0'
}
}
resource postgresDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2022-12-01' = {
parent: postgresServer
name: dbName
properties: {
charset: 'UTF8'
collation: 'en_US.utf8'
}
}
// ── Backend Container App ───────────────────────────────────────────────
resource backendApp 'Microsoft.App/containerApps@2023-05-01' = {
name: '${environmentName}-backend'
location: location
properties: {
managedEnvironmentId: containerAppsEnvironment.id
configuration: {
ingress: {
external: true
targetPort: 8080
transport: 'auto'
allowInsecure: false
}
secrets: [
{
name: 'db-password'
value: dbAdminPassword
}
{
name: 'encryption-key'
value: encryptionKey
}
]
}
template: {
containers: [
{
name: 'backend'
image: 'docker.io/orbisid/orbisid-backend:${orbisIdVersion}'
resources: {
cpu: json(backendCpu)
memory: backendMemory
}
env: [
{
name: 'SPRING_PROFILES_ACTIVE'
value: 'docker'
}
{
name: 'SPRING_DATASOURCE_URL'
value: 'jdbc:postgresql://${postgresServer.properties.fullyQualifiedDomainName}:5432/${dbName}?sslmode=require'
}
{
name: 'SPRING_DATASOURCE_USERNAME'
value: dbAdminUsername
}
{
name: 'SPRING_DATASOURCE_PASSWORD'
secretRef: 'db-password'
}
{
name: 'ORBISID_ENCRYPTION_KEY'
secretRef: 'encryption-key'
}
]
probes: [
{
type: 'Liveness'
httpGet: {
path: '/actuator/health'
port: 8080
}
initialDelaySeconds: 60
periodSeconds: 30
}
]
}
]
scale: {
minReplicas: minReplicas
maxReplicas: maxReplicas
}
}
}
dependsOn: [
postgresDatabase
]
}
// ── Frontend Container App ──────────────────────────────────────────────
resource frontendApp 'Microsoft.App/containerApps@2023-05-01' = {
name: '${environmentName}-frontend'
location: location
properties: {
managedEnvironmentId: containerAppsEnvironment.id
configuration: {
ingress: {
external: true
targetPort: 3000
transport: 'auto'
allowInsecure: false
}
}
template: {
containers: [
{
name: 'frontend'
image: 'docker.io/orbisid/orbisid-frontend:${orbisIdVersion}'
resources: {
cpu: json(frontendCpu)
memory: frontendMemory
}
env: [
{
// Frontend calls the API via a relative path — it and the backend
// are served from the same Front Door domain, routed by path
// (see the routes below), so no absolute URL is needed here.
name: 'NEXT_PUBLIC_API_URL'
value: ''
}
]
}
]
scale: {
minReplicas: minReplicas
maxReplicas: maxReplicas
}
}
}
}
// ── Front Door (path-based routing + managed TLS) ───────────────────────
resource frontDoorProfile 'Microsoft.Cdn/profiles@2024-02-01' = {
name: '${environmentName}-fd'
location: 'global'
sku: {
name: 'Standard_AzureFrontDoor'
}
}
resource frontDoorEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2024-02-01' = {
parent: frontDoorProfile
name: '${environmentName}-endpoint'
location: 'global'
properties: {
enabledState: 'Enabled'
}
}
resource backendOriginGroup 'Microsoft.Cdn/profiles/originGroups@2024-02-01' = {
parent: frontDoorProfile
name: 'backend-origin-group'
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
}
healthProbeSettings: {
probePath: '/actuator/health'
probeRequestType: 'GET'
probeProtocol: 'Https'
probeIntervalInSeconds: 30
}
}
}
resource backendOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2024-02-01' = {
parent: backendOriginGroup
name: 'backend-origin'
properties: {
hostName: backendApp.properties.configuration.ingress.fqdn
httpPort: 80
httpsPort: 443
originHostHeader: backendApp.properties.configuration.ingress.fqdn
priority: 1
weight: 1000
}
}
resource frontendOriginGroup 'Microsoft.Cdn/profiles/originGroups@2024-02-01' = {
parent: frontDoorProfile
name: 'frontend-origin-group'
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
}
healthProbeSettings: {
probePath: '/'
probeRequestType: 'GET'
probeProtocol: 'Https'
probeIntervalInSeconds: 30
}
}
}
resource frontendOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2024-02-01' = {
parent: frontendOriginGroup
name: 'frontend-origin'
properties: {
hostName: frontendApp.properties.configuration.ingress.fqdn
httpPort: 80
httpsPort: 443
originHostHeader: frontendApp.properties.configuration.ingress.fqdn
priority: 1
weight: 1000
}
}
// Front Door matches the most specific pattern automatically, so this route
// (registered ahead of the frontend's catch-all below) wins for /api/* and
// /actuator/* without needing an explicit priority field.
resource backendRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2024-02-01' = {
parent: frontDoorEndpoint
name: 'api-route'
dependsOn: [
backendOrigin
]
properties: {
originGroup: {
id: backendOriginGroup.id
}
supportedProtocols: [
'Https'
]
patternsToMatch: [
'/api/*'
'/actuator/*'
]
forwardingProtocol: 'HttpsOnly'
linkToDefaultDomain: 'Enabled'
httpsRedirect: 'Enabled'
}
}
resource frontendRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2024-02-01' = {
parent: frontDoorEndpoint
name: 'default-route'
dependsOn: [
frontendOrigin
]
properties: {
originGroup: {
id: frontendOriginGroup.id
}
supportedProtocols: [
'Https'
]
patternsToMatch: [
'/*'
]
forwardingProtocol: 'HttpsOnly'
linkToDefaultDomain: 'Enabled'
httpsRedirect: 'Enabled'
}
}
output applicationUrl string = 'https://${frontDoorEndpoint.properties.hostName}'
output postgresFqdn string = postgresServer.properties.fullyQualifiedDomainName
output backendFqdn string = backendApp.properties.configuration.ingress.fqdn
output frontendFqdn string = frontendApp.properties.configuration.ingress.fqdn
Create a resource group, generate a database password and an encryption key, then deploy:
- Linux
- macOS
- Windows
az group create --name orbisid-rg --location eastus
DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')
ENCRYPTION_KEY=$(openssl rand -base64 32)
echo "Save this — it cannot be recovered if lost: $ENCRYPTION_KEY"
az deployment group create \
--resource-group orbisid-rg \
--template-file main.bicep \
--parameters \
dbAdminPassword="$DB_PASSWORD" \
encryptionKey="$ENCRYPTION_KEY"
az group create --name orbisid-rg --location eastus
DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')
ENCRYPTION_KEY=$(openssl rand -base64 32)
echo "Save this — it cannot be recovered if lost: $ENCRYPTION_KEY"
az deployment group create \
--resource-group orbisid-rg \
--template-file main.bicep \
--parameters \
dbAdminPassword="$DB_PASSWORD" \
encryptionKey="$ENCRYPTION_KEY"
az group create --name orbisid-rg --location eastus
$bytes = New-Object byte[] 24
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$DB_PASSWORD = [Convert]::ToBase64String($bytes) -replace '[^A-Za-z0-9]', ''
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$ENCRYPTION_KEY = [Convert]::ToBase64String($bytes)
Write-Host "Save this - it cannot be recovered if lost: $ENCRYPTION_KEY"
az deployment group create `
--resource-group orbisid-rg `
--template-file main.bicep `
--parameters `
dbAdminPassword="$DB_PASSWORD" `
encryptionKey="$ENCRYPTION_KEY"
Or deploy through the Portal instead: create the resource group, then Deploy a custom template > Build your own template in the editor, paste in main.bicep (the Portal accepts Bicep directly — no need to convert to ARM JSON), and fill in dbAdminPassword/encryptionKey in the parameters form.
It takes about 10 minutes.
Once it finishes, fetch the outputs:
- Linux
- macOS
- Windows
az deployment group show --resource-group orbisid-rg --name main --query 'properties.outputs' --output json
az deployment group show --resource-group orbisid-rg --name main --query 'properties.outputs' --output json
az deployment group show --resource-group orbisid-rg --name main --query 'properties.outputs' --output json
applicationUrl is the one you need next.
Other parameters worth knowing about (all have defaults):
| Parameter | Default | Purpose |
|---|---|---|
environmentName | orbisid | Prefix for every resource name |
orbisIdVersion | latest | Docker Hub image tag to deploy — pin this to a specific release (e.g. 2.12.2) for production, since latest moves under you |
dbSkuName / dbSkuTier | Standard_B1ms / Burstable | PostgreSQL Flexible Server size |
backendCpu / backendMemory | 1.0 / 2Gi | Container Apps sizing for the backend |
maxReplicas | 3 | Upper bound for Container Apps autoscaling |
Post-deployment
- Browse to the
applicationUrloutput (https://<environment-name>-endpoint-xxxxxxxx.z01.azurefd.netby default). Log in with the default credentials (admin/ChangeMe123!) and change the password immediately — Administration > Users. - Optional: add a custom domain in Front Door (Portal > your Front Door profile > Domains) and point a CNAME at the endpoint hostname.
- Add target systems and configure scanning as usual — see Systems and Scanning.
Cost (rough estimate)
With the template's defaults (Standard_B1ms PostgreSQL, one replica each for backend/frontend, Front Door Standard): roughly $100–150/month. This is a rough order of magnitude, not a quote — check the Azure Pricing Calculator for your region and traffic. Front Door Standard has a fixed monthly base cost regardless of traffic; Container Apps and PostgreSQL scale with usage/SKU. The main levers: dbSkuName/dbSkuTier, backendCpu/backendMemory, and maxReplicas.
Updating
Redeploy with the same parameters plus a new orbisIdVersion — dbAdminPassword and encryptionKey have no default, so az deployment group create needs them again on every call (it doesn't remember them from the previous deployment); keep those two somewhere you can reuse them (a password manager, or a local, git-ignored parameters file):
- Linux
- macOS
- Windows
az deployment group create \
--resource-group orbisid-rg \
--template-file main.bicep \
--parameters \
dbAdminPassword="$DB_PASSWORD" \
encryptionKey="$ENCRYPTION_KEY" \
orbisIdVersion=2.13.0
az deployment group create \
--resource-group orbisid-rg \
--template-file main.bicep \
--parameters \
dbAdminPassword="$DB_PASSWORD" \
encryptionKey="$ENCRYPTION_KEY" \
orbisIdVersion=2.13.0
az deployment group create `
--resource-group orbisid-rg `
--template-file main.bicep `
--parameters `
dbAdminPassword="$DB_PASSWORD" `
encryptionKey="$ENCRYPTION_KEY" `
orbisIdVersion=2.13.0
az deployment group create only touches what changed — the database and its data are untouched by an image version bump. Database migrations run automatically on backend startup (Flyway), same as the Docker Compose install.
Known limitations
- No Kafka. Endpoint Sensor / Threat Detection event ingestion (
KAFKA_BOOTSTRAP_SERVERS) needs a reachable Kafka broker, which this template doesn't provision — same as the released Docker Compose packages, which also ship without one. If you need that feature in this deployment, add Event Hubs' Kafka-compatible endpoint and setKAFKA_BOOTSTRAP_SERVERSon the backend Container App. - No bundled AI runtime. This template doesn't run Ollama — AI features stay off. To enable them, either point Administration > Settings > OrbisAI's base URL at a separately-hosted Ollama instance reachable from the Container Apps environment, or add another Container App running
ollama/ollamawith a persistent Azure Files-backed volume for model storage (CPU inference is memory-hungry — see Requirements). - Public database access. For a straightforward getting-started setup, the PostgreSQL Flexible Server uses public access with an "allow Azure services" firewall rule rather than VNet integration. For production, VNet-integrate both the Container Apps environment and the Flexible Server instead and drop the firewall rule.
- Both Container Apps have public ingress. Front Door reaches them over the internet (Standard tier doesn't support Private Link origins — that needs Premium), so the
*.azurecontainerapps.ioFQDNs are technically reachable directly, bypassing Front Door, unless you add IP restrictions scoped to Front Door's service tag.
Tearing down
- Linux
- macOS
- Windows
az group delete --name orbisid-rg --yes
az group delete --name orbisid-rg --yes
az group delete --name orbisid-rg --yes
This deletes everything in the resource group, including the database and its data, with no recovery window — there is no equivalent of AWS's retained-secret or snapshot-on-delete behaviour in this template. Take a manual backup first if you need one:
- Linux
- macOS
- Windows
az postgres flexible-server db show --resource-group orbisid-rg --server-name orbisid-postgres --database-name orbisid
# then pg_dump against the server's fullyQualifiedDomainName output
az postgres flexible-server db show --resource-group orbisid-rg --server-name orbisid-postgres --database-name orbisid
# then pg_dump against the server's fullyQualifiedDomainName output
az postgres flexible-server db show --resource-group orbisid-rg --server-name orbisid-postgres --database-name orbisid
# then pg_dump against the server's fullyQualifiedDomainName output
az group delete removes the database with no snapshot and no retention. Back up first if the data matters.
Troubleshooting
- Container App stuck provisioning / crash-looping: check logs via
az containerapp logs show --name orbisid-backend --resource-group orbisid-rg --follow— usually a database connectivity issue or a baddbAdminPassword/encryptionKeyvalue. - Front Door route returns 502: the backend takes time to run Flyway migrations on first boot; Front Door's health probe (
/actuator/health) should hold traffic back until it's ready, but check the origin health in the Portal (Front Door > your profile > Metrics) if it persists. /api/*requests hit the frontend instead of the backend: Front Door selects the most specific matching route pattern automatically — if you added a custom route, make sure its pattern doesn't unintentionally out-specify/api/*//actuator/*.