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:
- Both apps on an image from this release or later. The app needs the
spire-serverCLI it carries, and the worker needs the relay. - The sidecar image in your registry:
az acr build -r <acr> -t dash-spire:1 examples/cloud-spire. - A
spiredatabase on the Postgres server:CREATE DATABASE spire;as the admin user. - The upstream CA, generated as in The upstream CA.
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.
- Two apps with the same name visible to the identity, or a user-assigned
identity: set
DASHBOARD_HOST_RESOURCE_ID(the app's full resource id) and/orDASHBOARD_HOST_IDENTITY_CLIENT_IDon the app. - A node on AWS can't hold the pool: a security group allows 60 inbound rules by default, and each address × port is one. The pool is left out there, with a warning, and only the detected address is admitted. Host the node on Azure or GCP, which hold thousands of addresses per rule, or use option 2.
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:
- Authentication → Workload identity (nothing stored)
- Identity platform → Azure — Container Apps, App Service, VM
- Service name → the Service Name from step 3
- Token audience → the
audfrom step 2 —https://management.azure.com/, or your ownapi://…if you registered one - User-assigned identity client ID →
clientIdfrom step 1
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.