BeyondTrust/POV

← Back to dashboard

Cloud Hosting: Azure Container Apps

Audience: operator · Profile: both · Read this when: you are running the dashboard on Azure Container Apps: Postgres, the app with its gateway sidecar, the worker, custom domains, outbound addresses, and workload identity instead of a PAT.

Part of Cloud Hosting.

This is what the reference install runs, so everything below is verified against a live deployment rather than derived.

Postgres

az postgres flexible-server create \
  --resource-group RG-DASH --name pg-dash --location eastus2 \
  --tier Burstable --sku-name Standard_B1ms --version 16 \
  --storage-size 32 --public-access Disabled \
  --vnet vnet-dash --subnet snet-pg

Public access disabled means the Container Apps environment must be VNet-joined to reach it. B1ms allows only 50 connections, which is why the shipped DB_POOL_SIZE/DB_MAX_OVERFLOW defaults are a conservative 5 + 5 — the app is two gunicorn processes and the worker one, so a deployment holds 3 × (size + overflow) = 30. B2s and every General Purpose tier allow 429 or more.

Environment

az containerapp env create \
  --name cae-dash --resource-group RG-DASH --location eastus2 \
  --infrastructure-subnet-resource-id "$(az network vnet subnet show \
      -g RG-DASH --vnet-name vnet-dash -n snet-aca --query id -o tsv)"

The subnet must be delegated to Microsoft.App/environments. Leave the environment external (the default) — VNet-joining is about reaching the private database on the way out, not about hiding ingress.

The app, with its gateway sidecar

Create the app first, then add the sidecar — az containerapp create takes one container, and a second is a template edit.

Create it pointing ingress at 8000, the app's own port, so you have a working dashboard to check before adding anything. Moving ingress to the gateway's port 80 is part of the same update that adds the gateway; do it earlier and ingress points at a port nothing is listening on.

az containerapp create \
  --name dash --resource-group RG-DASH --environment cae-dash \
  --image chrweav/infra-dashboard:latest \
  --target-port 8000 --ingress external \
  --cpu 1 --memory 2Gi --min-replicas 1 --max-replicas 1 \
  --secrets database-url="postgresql://..." jwt-secret-key="$(openssl rand -hex 32)" \
  --env-vars \
      DATABASE_URL=secretref:database-url \
      JWT_SECRET_KEY=secretref:jwt-secret-key \
      PUBLIC_BASE_URL=https://dash.example.com \
      WEBAUTHN_RP_ID=dash.example.com \
      WEBAUTHN_ORIGIN=https://dash.example.com \
      CORS_ORIGINS=https://dash.example.com \
      APP_ENV=production

Then add the sidecar. There is no CLI flag for a second container, so this is a template edit: export the current YAML, add an entry under properties.template.containers, and apply it back.

- name: gateway
  image: <registry>/dash-gateway:1
  resources: { cpu: 0.25, memory: 0.5Gi }
  env:
    - name: AGENT_HOSTNAME
      value: agents.example.com
    - name: UI_HOSTNAME
      value: dash.example.com
az containerapp show   -n dash -g RG-DASH -o yaml > dash.yaml
# …add the gateway container to properties.template.containers…
az containerapp update -n dash -g RG-DASH --yaml dash.yaml
az containerapp ingress update -n dash -g RG-DASH --target-port 80

After the port move, the app is reachable only through the gateway — which means only on the two hostnames it knows about. The environment's default *.azurecontainerapps.io name will start returning 404, and that is the design working, not a fault.

PUBLIC_BASE_URL with two hostnames. It is the remote-agent signing audience, and enrolment codes embed it — so once agents enrol on agents.example.com, set it to that host. It used to double as the SSO callback origin, which then pointed the IdP at a hostname the gateway 404s for everything but /api/agent/*. The SSO callbacks now come from UI_BASE_URL, else WEBAUTHN_ORIGIN (already https://dash.example.com above), so nothing more is needed as long as WEBAUTHN_ORIGIN is the UI host. Set UI_BASE_URL=https://dash.example.com explicitly if it is not. Register https://dash.example.com/api/auth/oauth/{azure,oidc}/callback with the IdP — see OIDC Step 1.

Custom domains

az containerapp hostname add    -n dash -g RG-DASH --hostname dash.example.com
az containerapp hostname bind   -n dash -g RG-DASH --hostname dash.example.com \
                                --validation-method CNAME

Repeat for the agent hostname. Two things worth knowing: bind both names before enrolling any agent, and a managed certificate often reports a bind error and then succeeds anyway — wait and re-check before you start troubleshooting a problem you do not have.

The worker

az containerapp create \
  --name dash-worker --resource-group RG-DASH --environment cae-dash \
  --image chrweav/infra-dashboard:latest \
  --command python --args "-m" "web_dashboard.jobs_worker" \
  --cpu 0.5 --memory 1Gi --min-replicas 1 --max-replicas 1 \
  --secrets database-url="postgresql://..." jwt-secret-key="<same key>" \
  --env-vars DATABASE_URL=secretref:database-url \
             JWT_SECRET_KEY=secretref:jwt-secret-key \
             PUBLIC_BASE_URL=https://dash.example.com APP_ENV=production

Full detail, including why the worker is its own Container App rather than another sidecar: job-worker.md.

SPIRE sidecar

Only if agents are to attest through SPIRE, or the dashboard is to have its own SPIFFE identity. The SPIRE server becomes a third container in dash, beside gateway and app. The worker changes in no way except its image: it reaches the server through the database.

Before you start:

1. Secrets. secret set adds or updates only the names it is given:

az containerapp secret set -n dash -g RG-DASH --secrets "spire-datastore-url=postgresql://<user>:<password>@<server>.postgres.database.azure.com:5432/spire?sslmode=require" "spire-ca-cert=$(cat spire-ca.crt)" "spire-ca-key=$(cat spire-ca.key)"

2. The template. Export it, change it, apply it. The same trap as Probes: az containerapp show returns the secrets block as names with no values, so delete properties.configuration.secrets from the file before you apply it, or the update blanks database-url and jwt-secret-key. Keep the full container list: it is a list, so a partial one drops the containers you left out.

az containerapp show -n dash -g RG-DASH -o yaml > dash.yaml

Add the volume, the sidecar, and two lines on app:

properties:
  configuration:
    ingress:
      # Agents' mTLS gRPC and the federation endpoint: TCP passthrough, never through
      # the gateway. TCP ports need a VNet-integrated environment, and are per
      # environment: each exposed port is usable by one app only.
      additionalPortMappings:
        - { external: true, targetPort: 8081, exposedPort: 8081 }
        - { external: true, targetPort: 8082, exposedPort: 8082 }
  template:
    volumes:
      - name: spire-admin
        storageType: EmptyDir
    containers:
      - name: app
        # …everything it already has, plus:
        env:
          - name: SPIRE_SERVER_SOCKET
            value: /run/spire-server/api.sock
        volumeMounts:
          - volumeName: spire-admin
            mountPath: /run/spire-server
      - name: spire
        image: <acr>.azurecr.io/dash-spire:1
        resources: { cpu: 0.25, memory: 0.5Gi }
        env:
          - { name: SPIRE_TRUST_DOMAIN, value: dashboard.example.com }
          - { name: SPIRE_DATASTORE_URL, secretRef: spire-datastore-url }
          - { name: SPIRE_UPSTREAM_CA_CERT, secretRef: spire-ca-cert }
          - { name: SPIRE_UPSTREAM_CA_KEY, secretRef: spire-ca-key }
        volumeMounts:
          - volumeName: spire-admin
            mountPath: /run/spire-server
        probes:
          - { type: Startup, httpGet: { path: /ready, port: 8088 }, periodSeconds: 10, failureThreshold: 10 }
          - { type: Liveness, httpGet: { path: /live, port: 8088 }, periodSeconds: 30 }
          - { type: Readiness, httpGet: { path: /ready, port: 8088 }, periodSeconds: 10 }
az containerapp update -n dash -g RG-DASH --yaml dash.yaml
az containerapp secret list -n dash -g RG-DASH --query "[].name"

The second command checks that every secret name is still there. An authenticated page in the UI is what proves the JWT key survived. The sidecar's probes are HTTP, unlike the gateway's, because 8088 does no host matching. Add SPIRE_JWT_ISSUER to the sidecar's env too if you will use the dashboard's own identity.

3. Check it. The sidecar's log should end with the server serving, and the app's log should show SPIRE relay: serving relayed calls over socket once a SPIRE setting is on:

az containerapp logs show -n dash -g RG-DASH --container spire --tail 50

4. The dashboard. Under Settings → Integrations → Remote Agents, tick Let agents attest through SPIRE. Leave SPIRE server address for agents blank: the agent hostname's custom domain resolves to the environment's address, and that address carries the TCP ports too. From an agent host, nc -vz agents.example.com 8081 should connect. Then migrate agents as on the guide.

UI_ALLOWED_CIDR does not cover these ports: it is the gateway's, and only for the UI hostname. Restrict 8081 and 8082 at the network level if agents come from known addresses.

Outbound addresses and the managed-node firewalls

A Container Apps environment with no NAT gateway has no single outbound address. It SNATs traffic out of a shared pool of several hundred addresses, and it picks an address per destination. That breaks anything that admits the dashboard by source address, the managed Portainer and Rancher node firewalls above all. The dashboard learns its address from an echo service, but the echo service and the node see different addresses, so the node drops the bootstrap. The job reads "serving", then ConnectTimeout, then "re-detecting … produced no change".

There are three ways to fix it. Pick one:

Cost Setup What the node admits
Let the dashboard read its pool free one Reader grant the environment's published pool (~400 shared Azure addresses)
NAT gateway ~$37/month + $0.045/GB three az commands one static address you own
Paste the pool by hand free a copy-paste, repeated when it goes stale the same pool as option 1, frozen

The pool in options 1 and 3 is shared Azure address space: other tenants' container apps in the same region can egress from it too, so they could reach the node's management ports. The node's own login still stands between them and the node. A NAT gateway is the only option that admits nothing but the dashboard.

Option 1: let the dashboard read its own pool

The platform publishes the pool as properties.outboundIpAddresses on the Container App, so the dashboard reads it with the app's own managed identity. Whenever a node firewall is applied (deploy, Re-apply, token mint), the whole pool is admitted on tcp 9443/8000 (Portainer) or 80/443 (Rancher) and re-read. There's nothing to paste and nothing to go stale. It needs one read grant, and one grant on the resource group covers both apps:

for app in dash dash-worker; do
  az containerapp identity assign -n $app -g RG-DASH --system-assigned
  az role assignment create --role Reader \
    --scope "$(az group show -n RG-DASH --query id -o tsv)" \
    --assignee-principal-type ServicePrincipal \
    --assignee-object-id "$(az containerapp show -n $app -g RG-DASH --query identity.principalId -o tsv)"
done

Settings → Containers (and → Kubernetes) shows Hosting platform outbound pool: N addresses once it works. When it doesn't, the readout shows why. That's nearly always the missing grant, and the message includes the exact command with the identity's object id already filled in.

Option 2: a NAT gateway

A NAT gateway on the environment's subnet gives every outbound connection one static address. The echo detection then sees the same address the node does, so the dashboard's normal /32 detection just works, on every cloud including AWS. This needs a workload profiles environment (the default since 2024; a Consumption-only environment can't take one). Costs are about $32.85/month for the gateway, $3.65/month for the Standard public IP, and $0.045 per GB processed.

az network public-ip create -g RG-DASH -n pip-dash-egress --sku Standard --allocation-method Static
az network nat gateway create -g RG-DASH -n nat-dash --public-ip-addresses pip-dash-egress
az network vnet subnet update -g RG-DASH --vnet-name vnet-dash -n snet-aca --nat-gateway nat-dash

Then turn option 1 off so the pool stops being admitted, and re-apply the node firewall (Settings → Containers → Re-apply) or redeploy:

for app in dash dash-worker; do
  az containerapp update -n $app -g RG-DASH --set-env-vars DASHBOARD_EGRESS_POOL=off
done

The address is az network public-ip show -g RG-DASH -n pip-dash-egress --query ipAddress -o tsv, which is also the one to give any other allow-list that needs to admit the dashboard. To remove the gateway, detach it first (az network vnet subnet update ... --remove natGateway): a NAT gateway can't be deleted while a subnet still references it.

Option 3: paste the pool by hand

When you can't grant roles (Contributor alone can't write role assignments) and don't want the cost of a NAT gateway, copy the pool into portainer_allowed_source_cidrs (or rancher_allowed_source_cidrs) in Settings, then Re-apply:

az containerapp show -n dash-worker -g RG-DASH --query "join(',', properties.outboundIpAddresses)" -o tsv

That copy can go stale when Azure grows the pool. The symptom is the same ConnectTimeout coming back, and the fix is to paste it again.

No PAT: authenticate to Pathfinder with a workload identity

Applies to: an install using Workload Credentials. Skip this if you do not. Status: run end to end on the Azure Container Apps install on 2026-09-15, so the walkthrough below is a transcript rather than a sketch. Two legs are still unproven: the worker minting under a real job, which is a different process from the one Test connection exercises — and the GCP and AWS platforms, whose client paths are unit-tested but which nobody has yet pointed at a live Pathfinder registration.

Workload Credentials replaced three standing cloud keys with one standing platform credential: a Personal Access Token, stored encrypted in app_config. That token is the last secret this feature holds, and it is the one that has actually caused an outage here — a dead PAT produces 401 Personal access token not found on every mint, from a dashboard that looks entirely healthy.

That token can go away. The platform can hand the container an identity of its own; Pathfinder can be told, once, to trust it. The container then presents a short-lived OIDC token it fetched from the platform a moment earlier, and there is no BeyondTrust credential anywhere in the deployment — nothing in app_config, nothing in the Container App's secrets, nothing to rotate.

This is not Azure-only, and the walkthrough below is Azure only because that is the install it was run on. The three hosting options at the top of this page each hand a container an identity, with one exception worth knowing before you start:

Identity platform Where the token comes from Registers in Pathfinder as
Azure — Container Apps, App Service, a VM IDENTITY_ENDPOINT/IDENTITY_HEADER, else IMDS Azure Entra ID, or Custom IDP
GCP — Cloud Run, GCE, GKE the metadata server's instance identity endpoint Custom IDP, issuer https://accounts.google.com
AWS — EKS only a projected token file (IRSA, or Pod Identity) Custom IDP, issuer = the cluster's OIDC provider URL
Other any projected token on disk, e.g. a Kubernetes ServiceAccount token Custom IDP
The dashboard's own SPIFFE identity — any host, ECS and on-prem included wlc.jwt, minted by the dashboard's SPIRE server Custom IDP, issuer = the dashboard's /spiffe URL (steps)

ECS is the exception. An ECS task — and a plain EC2 instance — gets SigV4 credentials and a signed instance identity document from the task metadata endpoint. Neither is an OIDC token, and there is no endpoint that will issue one. A dashboard on ECS uses its own SPIFFE identity or stays on a stored Personal Access Token; the panel says so, and so does the error if you select AWS anyway.

Which one you pick is Settings → Integrations → Workload Credentials → Identity platform. Everything after that is the same shape: register the trust once, name the audience, and hold nothing.

Registration is a GUI action and there is no API for it. Pathfinder → Administration → Workload Identities → Register Workload Identity. That is deliberate on both sides: a trust that can be created by the thing being trusted is not a trust. It takes a few minutes, once.

Pathfinder registers three kinds of issuer, and the third is the general case:

Identity Provider Type What it is for
GitHub Actions a workflow pulling secrets in CI — pinned to owner/repo and, optionally, immutable org/repo IDs so a renamed or recreated repo stops matching
Azure Entra ID an Entra identity whose tokens carry the v2 issuer — convenient when it fits, and step 2 covers when it does not
Custom IDP any OIDC issuer, scoped by explicit claim conditions: Okta, Ping, Keycloak, an EKS or GKE cluster's own issuer — or Entra itself

Custom IDP is the general mechanism rather than a fallback. It takes an issuer URL and a set of claim conditions, so anything that mints a signed OIDC token carrying a stable claim to pin can be trusted. The walkthrough below uses it against Entra because that is the host being documented; the same form with https://<your-org>.okta.com/oauth2/default or a GKE cluster's issuer URL works identically.

The token source is the dashboard's side of this. The trust is IdP-agnostic; the dashboard reads its token from the platform you pick in the table above. It cannot sign in to Okta or Ping on its own behalf. Where no platform hands the container a token (ECS, plain EC2, an on-prem VM), the dashboard's own SPIFFE identity can: its SPIRE server mints the token, and a Custom IDP registration for the dashboard's issuer trusts it.

1. One user-assigned identity, on both container apps

Use a user-assigned identity rather than a system-assigned one. The app and the worker are two separate Container Apps, so system-assigned means two principals, two object ids, and two registrations in Pathfinder to keep in step. And the worker is where credentials are actually minted — an identity on dash alone gives you a panel that tests green and jobs that still fail.

az identity create -g RG-DASH -n id-dash-wlc
ID=$(az identity show -g RG-DASH -n id-dash-wlc --query id -o tsv)
az containerapp identity assign -n dash        -g RG-DASH --user-assigned "$ID"
az containerapp identity assign -n dash-worker -g RG-DASH --user-assigned "$ID"

Then read back the two values Pathfinder and the settings panel each need:

az identity show -g RG-DASH -n id-dash-wlc \
  --query '{principalId:principalId, clientId:clientId, tenantId:tenantId}' -o json

principalId is the service principal object ID — the value Pathfinder asks for, and the one that ends up as the sub claim it checks. clientId is what the dashboard sends when asking the platform for a token; without it the request resolves to the system-assigned identity, which here is not assigned at all.

2. An audience for the token — usually without registering anything

A managed identity does not mint a token in the abstract — it mints one for a resource, and that resource becomes the token's aud. You need a value to put there. You do not necessarily need to create one.

Start here, because it needs no directory permissions at all: borrow a first-party resource that already exists in every tenant, and read the two values step 3 wants straight off a real token.

az account get-access-token --resource https://management.azure.com/ --query accessToken -o tsv \
  | python3 -c "import base64,json,sys;p=sys.stdin.read().strip().split('.')[1];p+='='*(-len(p)%4);d=json.loads(base64.urlsafe_b64decode(p));print('iss:',d['iss']);print('aud:',d['aud'])"
iss: https://sts.windows.net/<tenant-id>/
aud: https://management.azure.com/

Use Azure Resource Manager rather than Microsoft Graph as the borrowed audience. Graph tokens can carry a nonce in the JWT header that makes the signature unverifiable by anything except Graph — and Pathfinder has to verify it — whereas ARM tokens are ordinary JWTs and ARM's service principal exists in every tenant. What makes borrowing safe is not the audience but the identity: the one from step 1 holds no role assignments anywhere, so a token minted for it authorises nothing.

The trade-off is the issuer, and it settles step 3 for you. A first-party resource issues a v1 token — iss = https://sts.windows.net/<tenant-id>/ — so the registration has to be Custom IDP. The Azure Entra ID type derives the issuer as https://login.microsoftonline.com/<tenant-id>/v2.0 and will never match a v1 token, with nothing in either UI to say why.

If you would rather own the audience — this needs directory permissions — register an application purely to be that resource, and you can then use the Azure Entra ID type:

APP_ID=$(az ad app create --display-name vm-dashboard-wlc \
           --identifier-uris api://vm-dashboard-wlc --query appId -o tsv)
az ad app update --id "$APP_ID" --set api.requestedAccessTokenVersion=2
az ad sp create --id "$APP_ID"      # so the tenant can resolve api://…

requestedAccessTokenVersion: 2 is the entire reason to bother: it is what moves the issuer to the v2 form. In the portal's manifest the same property reads accessTokenAcceptedVersion — one knob, two names. Leave it at the default and the identity produces a perfectly valid token the Azure Entra ID type refuses. If the tenant rejects api://vm-dashboard-wlc, api://<the new appId> always resolves.

Many tenants do not permit this. If az ad app create returns "Directory permission is needed for the current user to register the application … Insufficient privileges to complete the operation", nothing is misconfigured — app registration is restricted to directory roles, and the borrowed-audience route above is the way through. The minimum role that unblocks it is Application Developer, if you want this path specifically.

3. Register the trust in Pathfinder

Administration → Workload Identities → Register Workload Identity:

Field Value
Identity Provider Type Custom IDP — or Azure Entra ID only if you own the audience app and set it to v2
Service Name any name; the dashboard sends it back as X-BT-Service-Name
Issuer URL the iss from step 2, verbatim, trailing slash included
Claim Conditions sub = the identity's principalId GUID from step 1
aud = the aud from step 2
Site the site whose Workload Credentials you use
Product Scopes Secrets

Four of those rows carry a trap.

sub takes the GUID, not the word. principalId is the name of the Azure property you read the value from. Typing the literal string gets you Claim not present in token, because the form then goes looking for a claim named principalId. And it is principalId, not clientId: both are GUIDs on the same identity, and the mix-up is silent in both directions — clientId here yields a valid token that Pathfinder declines, while principalId in the panel field of step 4 fails at the Azure token fetch instead.

Add the aud condition yourself. The Custom IDP form has no audience field — the Token Resource (App ID URI) input belongs to the Azure Entra ID type — so without an explicit condition, any token that identity obtains for any resource satisfies this trust. It costs one row.

Product Scopes is a grant separate from Site. The site says whose data; the scopes say which product APIs. Missing or wrong, it is another flat 401.

The wrong site fails every call with 401 Access denied for this site, which reads like the site lacking the application rather than like a scoping mistake.

Before saving, use the Validate a token tab. It is free, and it is the only place in this feature that names which claim failed. Paste the token from the step 2 command; the correct result is a single failure:

✗ sub — present, but the values do not match

That is the all-clear. It means the issuer string, the signature check against the provider's JWKS, the aud condition and every claim path passed, and the only mismatch is the one a laptop cannot fix — that token's subject is you, not the workload. Anything else failing is a real finding, and far easier to read here than at runtime, where all of it collapses into one HTTP 401.

A live token is a credential. The token from step 2 is a working Azure Resource Manager bearer token for your own user, valid about an hour, and pasting it here sends it to the platform. It is the quickest pre-flight, and the field exists to accept tokens — but if your account carries real Azure privileges, consider going straight to Test connection in step 4 instead. That uses the workload's own inert identity and proves strictly more.

Settings → Integrations → Workload Credentials:

Save, then press Test connection — it tests the saved values. The test is an unmetered GET /session, so it is free and safe to repeat, and it is the only thing that separates the two failures cleanly:

What you see What it means
no managed identity endpoint reachable no identity is assigned to this container app
a hang, then a connection error naming 169.254.169.254 the same cause wearing a disguise: Azure injects IDENTITY_ENDPOINT only into replicas that have an identity, and the IMDS fallback is unreachable from a Container App, so the call times out instead of saying so. Assign the identity, then restart the revision
could not get a managed identity token (HTTP 400/404) the identity exists but the tenant will not issue for that resource — check the token resource, that az ad sp create was run if it is your own api://… app, and that the client ID field holds clientId and not principalId
Workload Credentials error (HTTP 401) the token was fine and Pathfinder declined it — wrong site, missing Product Scopes, a Service Name that does not match (including a trailing space, which is not trimmed), a sub that is not this identity's principalId, an aud condition that does not match the token resource, or the v1/v2 issuer mismatch from step 2. The Validate a token tab in step 3 separates these; this message does not

Once it passes, clear wlc_pat: switching modes does not delete it, and a stored token nobody uses is still a credential somebody has to answer for. The Workload Lab's Cloud tab mints through the same client, so it stops depending on a stored token at the same moment.

What this does not remove. DATABASE_URL and JWT_SECRET_KEY are still platform secrets on both container apps, and they still have to match between dash and dash-worker. This removes the BeyondTrust credential, not every credential.