targetScope = 'resourceGroup' @description('Immutable image reference, normally ACR login server plus a Git SHA tag.') param containerImage string @description('Optional current web image used to keep Web unchanged while the migration job is upgraded.') param webContainerImage string = containerImage @description('Optional current worker image used to keep Worker unchanged while the migration job is upgraded.') param workerContainerImage string = containerImage @description('Migration image; normally the new immutable release image.') param migrationContainerImage string = containerImage @allowed(['dev', 'prod']) param environment string @allowed(['starter', 'production']) param deploymentTier string = 'starter' param location string = resourceGroup().location param acrName string param uniqueSuffix string = take(uniqueString(subscription().id, resourceGroup().id), 8) param postgresAdminLogin string = 'homixadmin' @secure() param postgresAdminPassword string = '' param entraTenantId string param entraClientId string @secure() param entraClientSecret string = '' @secure() param resendApiKey string = '' @secure() param resendWebhookSecret string = '' @secure() param unsubscribeSigningSecret string = '' @secure() param unsubscribePreviousSigningSecret string = '' param usePreviousUnsubscribeSigningSecret bool = false @secure() param bboMarketingApiKey string = '' param useBboMarketingApiKey bool = false @secure() param mlsGridAccessToken string = '' param useMlsGridAccessToken bool = false @secure() param openAiApiKey string = '' param useOpenAiApiKey bool = false param useResendSecrets bool = false param usePreviousResendWebhookSecret bool = false @secure() param resendWebhookPreviousSecretExpiresAt string = '' @secure() param unsubscribePreviousSigningSecretExpiresAt string = '' param useEntraClientSecret bool = false @description('Enable only the request-signed Homix Portal integration. Requires its Key Vault secret and company map.') param useHomixPortalIntegration bool = false param homixPortalCompaniesJson string = '{}' @allowed(['disabled', 'sandbox', 'live']) param emailDeliveryMode string = 'disabled' @description('Comma-separated normalized recipient addresses allowed while delivery is in sandbox mode.') param emailTestAllowlist string = '' param baseUrl string @description('Optional Container App custom domain. Leave empty until DNS validation records exist.') param customDomainName string = '' @description('Managed certificate resource name for customDomainName. Required when customDomainName is set.') param customDomainManagedCertificateName string = '' @allowed(['disabled', 'bbo', 'fake']) param oneKeyProvider string = 'disabled' param bboListingApiBaseUrl string = '' @description('Comma-separated exact origins approved for BBO-hosted listing media. No wildcards or paths.') param oneKeyMediaAllowedOrigins string = '' param oneKeySyncEnabled bool = false @allowed(['disabled', 'openai', 'azure-openai', 'fake']) param aiProvider string = 'disabled' param openAiModel string = 'gpt-5-mini' param openAiBaseUrl string = 'https://api.openai.com/v1' param bootstrapAdminEmails string param companyPostalAddress string = 'REQUIRED_BEFORE_LIVE_SEND' param alertEmail string = '' param postgresSkuName string = deploymentTier == 'production' ? 'Standard_D2ds_v5' : 'Standard_B1ms' param postgresTier string = deploymentTier == 'production' ? 'GeneralPurpose' : 'Burstable' param postgresStorageSizeGb int = deploymentTier == 'production' ? 128 : 32 param enableZoneRedundantHa bool = false @minValue(7) @maxValue(35) param postgresBackupRetentionDays int = deploymentTier == 'production' ? 14 : 7 param enableGeoRedundantBackup bool = false @allowed(['Standard_LRS', 'Standard_GRS', 'Standard_ZRS']) param storageSkuName string = 'Standard_LRS' param vnetAddressPrefix string = '10.42.0.0/16' param containerAppsSubnetPrefix string = '10.42.0.0/23' param postgresSubnetPrefix string = '10.42.2.0/24' param privateEndpointsSubnetPrefix string = '10.42.3.0/24' param webMinReplicas int = 1 param webMaxReplicas int = deploymentTier == 'production' ? 3 : 2 param workerMinReplicas int = 1 param workerMaxReplicas int = 1 var postgresHaMode = enableZoneRedundantHa ? 'ZoneRedundant' : 'Disabled' var namePrefix = 'homix-mkt-${environment}' var tags = { application: 'homix-marketing' environment: environment owner: 'homix-group' managedBy: 'bicep' dataClassification: 'internal' } var publicAssetTags = union(tags, { dataClassification: 'public-marketing-assets' }) var databaseName = 'homix_marketing' var postgresHost = '${postgres.name}.postgres.database.azure.com' var postgresRuntimeUrl = 'postgresql://${postgresAdminLogin}:${uriComponent(postgresAdminPassword)}@${postgresHost}:5432/${databaseName}?schema=public&sslmode=require&connection_limit=5&pool_timeout=20' var postgresDirectUrl = 'postgresql://${postgresAdminLogin}:${uriComponent(postgresAdminPassword)}@${postgresHost}:5432/${databaseName}?schema=public&sslmode=require&connection_limit=2&pool_timeout=30' var publicPaths = concat([ '/health/live' '/health/ready' '/api/public/webhooks/resend' '/api/public/unsubscribe/*' '/unsubscribe' '/public/assets/*' ], useHomixPortalIntegration ? ['/api/integrations/homix/v1/*'] : []) var commonSecrets = concat([ { name: 'database-url', keyVaultUrl: '${vault.properties.vaultUri}secrets/postgres-runtime-url', identity: identity.id } { name: 'direct-database-url', keyVaultUrl: '${vault.properties.vaultUri}secrets/postgres-direct-url', identity: identity.id } { name: 'session-secret', keyVaultUrl: '${vault.properties.vaultUri}secrets/session-secret', identity: identity.id } { name: 'unsubscribe-signing-secret', keyVaultUrl: '${vault.properties.vaultUri}secrets/unsubscribe-signing-secret', identity: identity.id } ], useResendSecrets ? [ { name: 'resend-api-key', keyVaultUrl: '${vault.properties.vaultUri}secrets/resend-api-key', identity: identity.id } { name: 'resend-webhook-secret', keyVaultUrl: '${vault.properties.vaultUri}secrets/resend-webhook-secret', identity: identity.id } ] : [], usePreviousResendWebhookSecret ? [ { name: 'resend-webhook-previous-secret', keyVaultUrl: '${vault.properties.vaultUri}secrets/resend-webhook-previous-secret', identity: identity.id } ] : [], usePreviousUnsubscribeSigningSecret ? [ { name: 'unsubscribe-previous-signing-secret', keyVaultUrl: '${vault.properties.vaultUri}secrets/unsubscribe-previous-signing-secret', identity: identity.id } ] : [], useBboMarketingApiKey ? [ { name: 'bbo-marketing-api-key', keyVaultUrl: '${vault.properties.vaultUri}secrets/bbo-marketing-api-key', identity: identity.id } ] : [], useMlsGridAccessToken ? [ { name: 'mls-grid-access-token', keyVaultUrl: '${vault.properties.vaultUri}secrets/mls-grid-access-token', identity: identity.id } ] : [], useOpenAiApiKey ? [ { name: 'openai-api-key', keyVaultUrl: '${vault.properties.vaultUri}secrets/openai-api-key', identity: identity.id } ] : [], useHomixPortalIntegration ? [{ name: 'homix-portal-signing', keyVaultUrl: '${vault.properties.vaultUri}secrets/homix-portal-integration-secret', identity: identity.id }] : [], useEntraClientSecret ? [{ name: 'entra-client-secret', keyVaultUrl: '${vault.properties.vaultUri}secrets/entra-client-secret', identity: identity.id }] : []) var commonEnv = concat([ { name: 'NODE_ENV', value: 'production' } { name: 'PORT', value: '3000' } { name: 'BASE_URL', value: baseUrl } { name: 'DEFAULT_TIMEZONE', value: 'America/New_York' } { name: 'DATABASE_URL', secretRef: 'database-url' } { name: 'DIRECT_DATABASE_URL', secretRef: 'direct-database-url' } { name: 'SESSION_SECRET', secretRef: 'session-secret' } { name: 'UNSUBSCRIBE_SIGNING_SECRET', secretRef: 'unsubscribe-signing-secret' } { name: 'AUTH_MODE', value: 'azure-easyauth' } { name: 'BOOTSTRAP_ADMIN_EMAILS', value: bootstrapAdminEmails } { name: 'AUTO_PROVISION_USERS', value: 'false' } { name: 'ALLOWED_EMAIL_DOMAINS', value: 'homixny.com' } { name: 'DEV_BYPASS_AUTH', value: 'false' } { name: 'EMAIL_PROVIDER', value: 'resend' } { name: 'EMAIL_DELIVERY_MODE', value: emailDeliveryMode } { name: 'EMAIL_TEST_ALLOWLIST', value: emailTestAllowlist } { name: 'STORAGE_PROVIDER', value: 'azure' } { name: 'AZURE_CLIENT_ID', value: identity.properties.clientId } { name: 'AZURE_STORAGE_ACCOUNT_URL', value: 'https://${storage.name}.blob.${az.environment().suffixes.storage}' } { name: 'AZURE_STORAGE_CONTAINER', value: 'marketing-assets' } { name: 'AZURE_PRIVATE_CONTAINER', value: 'private-exports' } { name: 'PUBLIC_ASSET_BASE_URL', value: 'https://${storage.name}.blob.${az.environment().suffixes.storage}/marketing-assets' } { name: 'COMPANY_NAME', value: 'Homix Realty' } { name: 'COMPANY_POSTAL_ADDRESS', value: companyPostalAddress } { name: 'COMPANY_WEBSITE', value: 'https://homixny.com' } { name: 'COMPANY_LISTINGS_URL', value: 'https://www.homixny.com/listings' } { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString } { name: 'WORKER_POLL_INTERVAL_MS', value: '2000' } { name: 'JOB_LOCK_SECONDS', value: '120' } { name: 'WEBHOOK_RETENTION_DAYS', value: '90' } { name: 'AUDIT_RETENTION_DAYS', value: '365' } { name: 'ONEKEY_PROVIDER', value: oneKeyProvider } { name: 'BBO_LISTING_API_BASE_URL', value: bboListingApiBaseUrl } { name: 'ONEKEY_MEDIA_ALLOWED_ORIGINS', value: oneKeyMediaAllowedOrigins } { name: 'ONEKEY_SYNC_ENABLED', value: oneKeySyncEnabled ? 'true' : 'false' } { name: 'MLS_GRID_BASE_URL', value: 'https://api.mlsgrid.com/v2' } { name: 'MLS_GRID_ORIGINATING_SYSTEM_NAME', value: 'onekey2' } { name: 'AI_PROVIDER', value: aiProvider } { name: 'OPENAI_MODEL', value: openAiModel } { name: 'OPENAI_BASE_URL', value: openAiBaseUrl } ], useResendSecrets ? [{ name: 'RESEND_API_KEY', secretRef: 'resend-api-key' }, { name: 'RESEND_WEBHOOK_SECRET', secretRef: 'resend-webhook-secret' }] : [], usePreviousResendWebhookSecret ? [{ name: 'RESEND_WEBHOOK_PREVIOUS_SECRET', secretRef: 'resend-webhook-previous-secret' }, { name: 'RESEND_WEBHOOK_PREVIOUS_SECRET_EXPIRES_AT', value: resendWebhookPreviousSecretExpiresAt }] : [], usePreviousUnsubscribeSigningSecret ? [{ name: 'UNSUBSCRIBE_PREVIOUS_SIGNING_SECRET', secretRef: 'unsubscribe-previous-signing-secret' }, { name: 'UNSUBSCRIBE_PREVIOUS_SIGNING_SECRET_EXPIRES_AT', value: unsubscribePreviousSigningSecretExpiresAt }] : [], useBboMarketingApiKey ? [{ name: 'BBO_MARKETING_API_KEY', secretRef: 'bbo-marketing-api-key' }] : [], useMlsGridAccessToken ? [{ name: 'MLS_GRID_ACCESS_TOKEN', secretRef: 'mls-grid-access-token' }] : [], useOpenAiApiKey ? [{ name: 'OPENAI_API_KEY', secretRef: 'openai-api-key' }] : [], useHomixPortalIntegration ? [{ name: 'HOMIX_PORTAL_INTEGRATION_SECRET', secretRef: 'homix-portal-signing' }, { name: 'HOMIX_PORTAL_COMPANIES_JSON', value: homixPortalCompaniesJson }] : []) resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { name: acrName } resource logs 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: 'log-${namePrefix}' location: location tags: tags properties: { retentionInDays: environment == 'prod' ? 90 : 30 } } resource appInsights 'Microsoft.Insights/components@2020-02-02' = { name: 'appi-${namePrefix}' location: location kind: 'web' tags: tags properties: { Application_Type: 'web', WorkspaceResourceId: logs.id } } resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: 'id-${namePrefix}' location: location tags: tags } resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' = { name: 'vnet-${namePrefix}' location: location tags: tags properties: { addressSpace: { addressPrefixes: [vnetAddressPrefix] } subnets: [ { name: 'snet-container-apps' properties: { addressPrefix: containerAppsSubnetPrefix delegations: [{ name: 'container-apps', properties: { serviceName: 'Microsoft.App/environments' } }] } } { name: 'snet-postgres' properties: { addressPrefix: postgresSubnetPrefix delegations: [{ name: 'postgres', properties: { serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' } }] privateEndpointNetworkPolicies: 'Disabled' } } { name: 'snet-private-endpoints' properties: { addressPrefix: privateEndpointsSubnetPrefix, privateEndpointNetworkPolicies: 'Disabled' } } ] } } resource postgresDns 'Microsoft.Network/privateDnsZones@2024-06-01' = { name: 'private.postgres.database.azure.com', location: 'global', tags: tags } resource postgresDnsLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { parent: postgresDns, name: 'link-${namePrefix}', location: 'global', properties: { virtualNetwork: { id: vnet.id }, registrationEnabled: false } } resource postgres 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { name: 'psql-${namePrefix}-${uniqueSuffix}' location: location tags: tags sku: { name: postgresSkuName, tier: postgresTier } properties: union({ administratorLogin: postgresAdminLogin version: '16' storage: { storageSizeGB: postgresStorageSizeGb } backup: { backupRetentionDays: postgresBackupRetentionDays, geoRedundantBackup: enableGeoRedundantBackup ? 'Enabled' : 'Disabled' } highAvailability: { mode: postgresHaMode } network: { delegatedSubnetResourceId: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, 'snet-postgres'), privateDnsZoneArmResourceId: postgresDns.id, publicNetworkAccess: 'Disabled' } }, !empty(postgresAdminPassword) ? { administratorLoginPassword: postgresAdminPassword } : {}) dependsOn: [postgresDnsLink] } resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { parent: postgres, name: databaseName, properties: { charset: 'UTF8', collation: 'en_US.utf8' } } resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { name: 'sthomixmkt${uniqueSuffix}' location: location tags: publicAssetTags sku: { name: storageSkuName } kind: 'StorageV2' // Email clients must be able to fetch the immutable marketing images without // authentication. The private container remains non-public and application // writes still use managed identity through the private endpoint. properties: { allowBlobPublicAccess: true, allowSharedKeyAccess: false, minimumTlsVersion: 'TLS1_2', supportsHttpsTrafficOnly: true, publicNetworkAccess: 'Enabled' } } resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { parent: storage, name: 'default', properties: { deleteRetentionPolicy: { enabled: true, days: environment == 'prod' ? 30 : 7 }, containerDeleteRetentionPolicy: { enabled: true, days: environment == 'prod' ? 30 : 7 } } } resource marketingAssets 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { parent: blobService, name: 'marketing-assets', properties: { publicAccess: 'Blob' } } resource privateExports 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { parent: blobService, name: 'private-exports', properties: { publicAccess: 'None' } } resource blobDns 'Microsoft.Network/privateDnsZones@2024-06-01' = { name: 'privatelink.blob.${az.environment().suffixes.storage}', location: 'global', tags: tags } resource blobDnsLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { parent: blobDns, name: 'link-${namePrefix}', location: 'global', properties: { virtualNetwork: { id: vnet.id }, registrationEnabled: false } } resource storagePrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = { name: 'pe-${storage.name}-blob', location: location, tags: tags, properties: { subnet: { id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, 'snet-private-endpoints') }, privateLinkServiceConnections: [{ name: 'blob', properties: { privateLinkServiceId: storage.id, groupIds: ['blob'] } }] } } resource storageDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-01-01' = { parent: storagePrivateEndpoint name: 'default' properties: { privateDnsZoneConfigs: [{ name: 'blob', properties: { privateDnsZoneId: blobDns.id } }] } dependsOn: [blobDnsLink] } resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { name: 'kv-homix-${environment}-${uniqueSuffix}' location: location tags: tags properties: union({ tenantId: subscription().tenantId, enableRbacAuthorization: true, enableSoftDelete: true, softDeleteRetentionInDays: 90, publicNetworkAccess: 'Disabled', sku: { family: 'A', name: 'standard' } }, environment == 'prod' ? { enablePurgeProtection: true } : {}) } resource vaultDns 'Microsoft.Network/privateDnsZones@2024-06-01' = { name: 'privatelink.vaultcore.azure.net', location: 'global', tags: tags } resource vaultDnsLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { parent: vaultDns, name: 'link-${namePrefix}', location: 'global', properties: { virtualNetwork: { id: vnet.id }, registrationEnabled: false } } resource vaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = { name: 'pe-${vault.name}', location: location, tags: tags, properties: { subnet: { id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, 'snet-private-endpoints') }, privateLinkServiceConnections: [{ name: 'vault', properties: { privateLinkServiceId: vault.id, groupIds: ['vault'] } }] } } resource vaultDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-01-01' = { parent: vaultPrivateEndpoint name: 'default' properties: { privateDnsZoneConfigs: [{ name: 'vault', properties: { privateDnsZoneId: vaultDns.id } }] } dependsOn: [vaultDnsLink] } resource postgresPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(postgresAdminPassword)) { parent: vault, name: 'postgres-admin-password', properties: { value: postgresAdminPassword } } resource databaseUrlSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(postgresAdminPassword)) { parent: vault, name: 'postgres-runtime-url', properties: { value: postgresRuntimeUrl } } resource directDatabaseUrlSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(postgresAdminPassword)) { parent: vault, name: 'postgres-direct-url', properties: { value: postgresDirectUrl } } resource sessionSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(postgresAdminPassword)) { parent: vault, name: 'session-secret', properties: { value: guid(postgresAdminPassword, resourceGroup().id, 'session') } } resource unsubscribeSigningSecretResource 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(unsubscribeSigningSecret) || !empty(postgresAdminPassword)) { parent: vault, name: 'unsubscribe-signing-secret', properties: { value: !empty(unsubscribeSigningSecret) ? unsubscribeSigningSecret : guid(postgresAdminPassword, resourceGroup().id, 'unsubscribe') } } resource unsubscribePreviousSigningSecretResource 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (usePreviousUnsubscribeSigningSecret && !empty(unsubscribePreviousSigningSecret)) { parent: vault, name: 'unsubscribe-previous-signing-secret', properties: { value: unsubscribePreviousSigningSecret } } resource bboMarketingApiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (useBboMarketingApiKey && !empty(bboMarketingApiKey)) { parent: vault, name: 'bbo-marketing-api-key', properties: { value: bboMarketingApiKey } } resource mlsGridAccessTokenSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (useMlsGridAccessToken && !empty(mlsGridAccessToken)) { parent: vault, name: 'mls-grid-access-token', properties: { value: mlsGridAccessToken } } resource openAiApiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (useOpenAiApiKey && !empty(openAiApiKey)) { parent: vault, name: 'openai-api-key', properties: { value: openAiApiKey } } resource resendApiKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(resendApiKey)) { parent: vault, name: 'resend-api-key', properties: { value: resendApiKey } } resource resendWebhookSecretSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(resendWebhookSecret)) { parent: vault, name: 'resend-webhook-secret', properties: { value: resendWebhookSecret } } resource entraClientSecretResource 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(entraClientSecret)) { parent: vault, name: 'entra-client-secret', properties: { value: entraClientSecret } } resource vaultDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { name: 'send-audit-to-log-analytics' scope: vault properties: { workspaceId: logs.id logs: [{ categoryGroup: 'audit', enabled: true }] metrics: [{ category: 'AllMetrics', enabled: true }] } } resource keyVaultRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(vault.id, identity.id, 'kv-secrets-user'), scope: vault, properties: { principalId: identity.properties.principalId, principalType: 'ServicePrincipal', roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') } } resource blobRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(storage.id, identity.id, 'blob-contributor'), scope: storage, properties: { principalId: identity.properties.principalId, principalType: 'ServicePrincipal', roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') } } resource acrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(registry.id, identity.id, 'acr-pull'), scope: registry, properties: { principalId: identity.properties.principalId, principalType: 'ServicePrincipal', roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') } } resource containerEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { name: 'cae-${namePrefix}' location: location tags: tags properties: { appLogsConfiguration: { destination: 'log-analytics', logAnalyticsConfiguration: { customerId: logs.properties.customerId, sharedKey: logs.listKeys().primarySharedKey } } vnetConfiguration: { infrastructureSubnetId: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, 'snet-container-apps'), internal: false } } } resource customDomainManagedCertificate 'Microsoft.App/managedEnvironments/managedCertificates@2024-03-01' = if (!empty(customDomainName) && !empty(customDomainManagedCertificateName)) { parent: containerEnvironment name: customDomainManagedCertificateName location: location tags: tags properties: { subjectName: customDomainName domainControlValidation: 'CNAME' } } resource web 'Microsoft.App/containerApps@2024-03-01' = { name: 'ca-${namePrefix}-web' location: location tags: tags identity: { type: 'UserAssigned', userAssignedIdentities: { '${identity.id}': {} } } properties: { managedEnvironmentId: containerEnvironment.id configuration: { activeRevisionsMode: 'Single' ingress: { external: true targetPort: 3000 transport: 'auto' allowInsecure: false customDomains: !empty(customDomainName) && !empty(customDomainManagedCertificateName) ? [{ name: customDomainName bindingType: 'SniEnabled' certificateId: resourceId('Microsoft.App/managedEnvironments/managedCertificates', containerEnvironment.name, customDomainManagedCertificateName) }] : [] } registries: [{ server: registry.properties.loginServer, identity: identity.id }] secrets: commonSecrets } template: { containers: [{ name: 'web' image: webContainerImage env: concat(commonEnv, [{ name: 'APP_ROLE', value: 'web' }]) resources: { cpu: json('0.5'), memory: '1Gi' } probes: [ { type: 'Liveness', httpGet: { path: '/health/live', port: 3000, scheme: 'HTTP' }, initialDelaySeconds: 10, periodSeconds: 30 } { type: 'Readiness', httpGet: { path: '/health/ready', port: 3000, scheme: 'HTTP' }, initialDelaySeconds: 10, periodSeconds: 15 } ] }] scale: { minReplicas: webMinReplicas, maxReplicas: webMaxReplicas, rules: [{ name: 'http', http: { metadata: { concurrentRequests: '50' } } }] } } } dependsOn: [keyVaultRole, blobRole, acrPullRole, storageDnsGroup, vaultDnsGroup, database, databaseUrlSecret, directDatabaseUrlSecret, sessionSecret, unsubscribeSigningSecretResource, unsubscribePreviousSigningSecretResource, bboMarketingApiKeySecret, mlsGridAccessTokenSecret, openAiApiKeySecret, customDomainManagedCertificate] } resource worker 'Microsoft.App/containerApps@2024-03-01' = { name: 'ca-${namePrefix}-worker' location: location tags: tags identity: { type: 'UserAssigned', userAssignedIdentities: { '${identity.id}': {} } } properties: { managedEnvironmentId: containerEnvironment.id configuration: { activeRevisionsMode: 'Single', registries: [{ server: registry.properties.loginServer, identity: identity.id }], secrets: commonSecrets } template: { containers: [{ name: 'worker', image: workerContainerImage, env: concat(commonEnv, [{ name: 'APP_ROLE', value: 'worker' }]), resources: { cpu: json('0.5'), memory: '1Gi' } }], scale: { minReplicas: workerMinReplicas, maxReplicas: workerMaxReplicas } } } dependsOn: [web] } resource migrationJob 'Microsoft.App/jobs@2024-03-01' = { name: 'caj-${namePrefix}-migrate' location: location tags: tags identity: { type: 'UserAssigned', userAssignedIdentities: { '${identity.id}': {} } } properties: { environmentId: containerEnvironment.id configuration: { triggerType: 'Manual', replicaTimeout: 1800, replicaRetryLimit: 0, manualTriggerConfig: { parallelism: 1, replicaCompletionCount: 1 }, registries: [{ server: registry.properties.loginServer, identity: identity.id }], secrets: commonSecrets } template: { containers: [{ name: 'migrate', image: migrationContainerImage, env: concat(commonEnv, [{ name: 'APP_ROLE', value: 'migrate' }]), resources: { cpu: json('0.5'), memory: '1Gi' } }] } } dependsOn: [keyVaultRole, acrPullRole, database, databaseUrlSecret, directDatabaseUrlSecret, sessionSecret, unsubscribeSigningSecretResource, unsubscribePreviousSigningSecretResource, bboMarketingApiKeySecret, mlsGridAccessTokenSecret, openAiApiKeySecret] } resource webAuth 'Microsoft.App/containerApps/authConfigs@2024-03-01' = { parent: web name: 'current' properties: { platform: { enabled: true } globalValidation: { unauthenticatedClientAction: 'RedirectToLoginPage', redirectToProvider: 'azureactivedirectory', excludedPaths: publicPaths } identityProviders: { azureActiveDirectory: { enabled: true, registration: union({ clientId: entraClientId, openIdIssuer: '${az.environment().authentication.loginEndpoint}${entraTenantId}/v2.0' }, useEntraClientSecret ? { clientSecretSettingName: 'entra-client-secret' } : {}) } } login: { allowedExternalRedirectUrls: [baseUrl], preserveUrlFragmentsForLogins: false } httpSettings: { requireHttps: true, routes: { apiPrefix: '/.auth' }, forwardProxy: { convention: 'Standard' } } } } resource actionGroup 'Microsoft.Insights/actionGroups@2023-01-01' = if (!empty(alertEmail)) { name: 'ag-${namePrefix}', location: 'global', tags: tags, properties: { groupShortName: 'HomixMkt', enabled: true, emailReceivers: [{ name: 'operations', emailAddress: alertEmail, useCommonAlertSchema: true }] } } resource web5xxAlert 'Microsoft.Insights/metricAlerts@2018-03-01' = if (!empty(alertEmail)) { name: 'alert-${namePrefix}-web-5xx', location: 'global', tags: tags, properties: { description: 'Homix Marketing web HTTP 5xx responses exceeded threshold.', severity: 2, enabled: true, scopes: [web.id], evaluationFrequency: 'PT5M', windowSize: 'PT15M', criteria: { 'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria', allOf: [{ name: 'Http5xx', metricName: 'Requests', metricNamespace: 'Microsoft.App/containerapps', operator: 'GreaterThan', threshold: 5, timeAggregation: 'Total', dimensions: [{ name: 'StatusCodeCategory', operator: 'Include', values: ['5xx'] }], criterionType: 'StaticThresholdCriterion' }] }, actions: [{ actionGroupId: actionGroup.id }] } } @secure() output applicationInsightsConnectionString string = appInsights.properties.ConnectionString output acrLoginServer string = registry.properties.loginServer output webFqdn string = web.properties.configuration.ingress.fqdn output webAppName string = web.name output workerAppName string = worker.name output migrationJobName string = migrationJob.name output containerAppsEnvironmentName string = containerEnvironment.name output keyVaultName string = vault.name output storageAccountUrl string = 'https://${storage.name}.blob.${az.environment().suffixes.storage}' output postgresServerFqdn string = postgres.properties.fullyQualifiedDomainName output managedIdentityClientId string = identity.properties.clientId output managedIdentityResourceId string = identity.id