This guide walks through deploying PilotSwarm workers to AKS for production multi-node operation.
Two deployment paths. The repo ships two side-by-side ways to deploy PilotSwarm to AKS:
- Legacy
scripts/deploy-aks.sh(the rest of this document). Imperative bash + rawkubectl applyagainstdeploy/k8s/*.yaml. Stable, well-trodden, and not going away.- GitOps IaC pipeline under
deploy/(described below in GitOps IaC Path). Bicep-managed Azure infra + Flux-driven cluster manifests, pulled from versioned blob containers. Modeled on a known-good internal reference implementation, simplified for PilotSwarm's single-service Node.js shape. This path adds Edge Mode (AFD vs Private AppGw) and TLS Source (AKV vs Let's Encrypt) topology choices.Choose one. They share the same Kubernetes cluster shape but stamp it out via different mechanisms; running both against the same cluster will fight over the same resources.
The IaC pipeline under deploy/ provisions Azure infra via Bicep and
keeps cluster state in sync via Flux Kustomizations sourced from blob
containers. Public entry points:
deploy/scripts/new-env.mjs— generate a per-env.env+ bicep parameter files. Picks defaults for the topology axes below.deploy/scripts/deploy.mjs— orchestrates the per-service bicep + manifest stage ininfraOrderthenservicesorder fromdeploy/services/deploy-manifest.json.deploy/scripts/test/*.test.mjs—npm run test:deploy-scripts.
The IaC path supports an (EDGE_MODE × TLS_SOURCE) matrix plus the
optional VPN_GATEWAY_ENABLED axis. Five combinations are supported;
the rest are blocked at preflight (see Unsupported Combinations
and the named diagnostic codes in Optional: VPN Gateway P2S):
EDGE_MODE |
TLS_SOURCE |
Edge ingress | Cert source | Notes |
|---|---|---|---|---|
afd |
letsencrypt |
AFD → AppGw (Private Link) + AGIC | cert-manager + Let's Encrypt prod (HTTP-01) | OSS default. Zero CA setup. |
afd |
akv |
AFD → AppGw (Private Link) + AGIC | OneCertV2-PublicCA via AKV (registered automatically) | enterprise default. BYO public CA. |
afd + VPN |
akv |
AFD → AppGw (Private Link) and Azure VPN Gateway P2S → same AppGw private FE | OneCertV2-PublicCA via AKV (shared with AFD path) | Hybrid trusted-bypass; opt-in via VPN_GATEWAY_ENABLED=true. AKV-only. See Optional: VPN Gateway P2S. |
private |
akv |
AKS web-app-routing addon (NGINX) + ILB | OneCertV2-PrivateCA via AKV (registered automatically) | Enterprise / AME. No AFD, no AppGw, no AGIC. |
private |
akv-selfsigned |
AKS web-app-routing addon (NGINX) + ILB | AKV Self issuer (auto-generated, in-place) |
No CA; private-VNet smoke tests. |
Default for OSS = afd + letsencrypt. Default for the enterprise path =
afd + akv.
EDGE_MODE=afd— Azure Front Door fronts a regional Application Gateway over Private Link. Public TLS terminates at AFD; AppGw is reachable only via the AFD private endpoint. Use for any Internet- facing deployment.EDGE_MODE=private— No AFD, no AppGw, no AGIC, no GlobalInfra resource group. AKS uses the web-app-routing addon (managed NGINX) with an internal-only Azure Load Balancer. Reachable from peered VNets / Bastion / VPN / ExpressRoute. Theglobalinfraandafdservices are skipped bydeploy.mjs. Bicep also provisions a Private DNS Zone (PRIVATE_DNS_ZONE) and links it to the AKS VNet;deploy.mjswrites an A record${HOST}.${PRIVATE_DNS_ZONE}→ internal LB IP after the Portal rolls out.TLS_SOURCE=akv— Portal cert is issued by an AKV cert issuer. The bicep auto-registersOneCertV2-PublicCA(afd mode) orOneCertV2-PrivateCA(private mode) on the Key Vault using the sharedakv-certificate-issuer.bicepmodule — no manual issuer setup. Override withPORTAL_TLS_ISSUER_NAMEif you have a different registered CA. Cert is projected into the cluster via Secret Store CSI; afd mode binds it to AppGw via theappgw-ssl-certificateAGIC annotation, private mode mounts it directly into the NGINX-fronted Portal pod's TLS secret.TLS_SOURCE=akv-selfsigned(private only) — uses the AKV built-inSelfissuer to mint a self-signed cert. Browsers will warn; only suitable for private-VNet smoke tests where you control the trust store.TLS_SOURCE=letsencrypt(afd only) —cert-manageris installed in-cluster via Flux (HelmRelease pinned to v1.20.2 exact). Theletsencrypt-prodClusterIssuer (HTTP-01 solver) issues a real CA cert and writes it into the K8s Secret named in the Ingresstls.secretName. AGIC imports it from there. No AKV cert, no manual upload step. RequiresACME_EMAILin the env.
deploy/gitops/portal/overlays/ ships three flavors, one per supported
combo (akv and akv-selfsigned share an overlay because the only
difference is the AKV issuer name, set by Portal bicep, not by
kustomize):
afd-letsencrypt/— AFD + AppGw + AGIC + cert-manager-managed Secret.afd-akv/— AFD + AppGw + AGIC + Secret Store CSI (AKV cert).private-akv/— web-app-routing NGINX + ILB + Secret Store CSI (AKV cert, OneCertV2-PrivateCA orSelf).
Portal bicep selects the overlay automatically:
kustomizationPath: 'overlays/${edgeMode}-${
tlsSource == 'akv-selfsigned' ? 'akv' : tlsSource
}'deploy.mjs skips entire services based on env flags:
| Service | Skip when |
|---|---|
globalinfra, afd |
EDGE_MODE != afd |
cert-manager |
TLS_SOURCE != letsencrypt |
cert-manager-issuers |
TLS_SOURCE != letsencrypt |
Both single-service runs (deploy.mjs <svc>) and deploy.mjs all honor
these gates.
deploy/gitops/cert-manager/base/helm-release.yaml pins
version: 1.20.2 exact (no semver range). To upgrade, edit that field
in a PR — Flux will not auto-roll. The OCI HelmRepository points at
oci://quay.io/jetstack/charts (official Jetstack registry) for OSS;
the enterprise path stays on the AKV path so this chart source is OSS-only.
ClusterIssuers live in a separate Kustomization
(cert-manager-issuers) so the issuer install retries cleanly while
cert-manager CRDs are landing — Flux retry handles the ordering, no
explicit dependsOn between the two fluxConfigurations resources.
In EDGE_MODE=private the cluster uses the AKS web-app-routing addon
(addonProfiles.webAppRouting) instead of AGIC. The addon installs a
managed NGINX ingress controller in the app-routing-system
namespace; the default ingress class is
webapprouting.kubernetes.azure.com.
Two pieces are wired by deploy.mjs after Flux reconciles the Portal
manifests, because they depend on runtime state Bicep can't observe:
- Internal LB.
deploy.mjspatches the cluster-scopednginxingresscontroller/defaultCR withspec.loadBalancerAnnotations.service.beta.kubernetes.io/azure-load-balancer-internal=true. The addon controller propagates the annotation onto the underlyingapp-routing-system/nginxService, and Azure recreates the LB as internal-only. - Private DNS A record.
deploy.mjspolls the Service for its internal IP, then idempotently upserts an A record${HOST}→ internal-LB IP on the Bicep-provisioned Private DNS Zone (PRIVATE_DNS_ZONE). Re-running the Portal deploy refreshes the record if the LB IP changes.
Callers reach the Portal at https://${HOST}.${PRIVATE_DNS_ZONE} from
inside the AKS VNet (or any VNet linked to the same Private DNS Zone:
peered VNets, Bastion-attached jump boxes, VPN, ExpressRoute). The
zone is not publicly resolvable.
EDGE_MODE=privatewithTLS_SOURCE=letsencrypt— Let's Encrypt HTTP-01 needs a public IP for ACME validation; private mode has none. DNS-01 against an Azure Public DNS zone is not in scope (we don't provision public zones). UseTLS_SOURCE=akv(OneCertV2-PrivateCA / AME) orTLS_SOURCE=akv-selfsignedfor private deployments.EDGE_MODE=afdwithTLS_SOURCE=akv-selfsigned— Azure Front Door rejects self-signed origin certs at the TLS validation step. UseTLS_SOURCE=letsencrypt(free, public CA) orTLS_SOURCE=akvwith a public CA (e.g. OneCertV2-PublicCA).
new-env.mjs and deploy.mjs both refuse these combos at preflight.
The IaC path supports an optional, additive Azure VPN Gateway
(Point-to-Site, OpenVPN protocol, Microsoft Entra ID authentication) that
coexists with EDGE_MODE=afd. The VPN tunnel terminates inside the stamp
VNet and reaches the same AppGw private listener as the AFD path, with
the same AKV cert — so an allow-listed user (through AFD) and an
off-allow-list authenticated user (through VPN) both reach
https://<PORTAL_RESOURCE_NAME>.<SSL_CERT_DOMAIN_SUFFIX> and observe an identical
cert chain. The Private DNS A record is keyed on PORTAL_RESOURCE_NAME so
it matches the AppGw listener hostname and AKV cert subject;
RESOURCE_PREFIX alone is only a backwards-compat fallback when
PORTAL_RESOURCE_NAME is empty. This is the "trusted-bypass" pattern for tenant users with a
valid Entra ID token who would otherwise be blocked at the public edge by
operator-defined AFD WAF allow-lists (typically service-tag, IP-range, or
header-based rules that gate the public ingress to a known managed-network
population). VPN is not a replacement for any existing edge mode.
(allow-listed public user)
────────────► AFD Premium
│ (Private Link)
▼
AppGw v2 (private FE, WAF_v2)
│ (single AKV cert)
▼
AKS portal pod
▲
│
(off-allow-list authenticated user)
────────────► Azure VPN Gateway P2S
Entra ID + MFA (GatewaySubnet, OpenVPN)
Both paths share one AppGw, one WAF policy, one cert. The AppGw WAF custom-rules pipeline disambiguates them at L7 (see "WAF guard rules" below).
EDGE_MODE=afd+TLS_SOURCE=akv—validateVpnGatewayCombo()indeploy/scripts/lib/overlay-contracts.mjsrequires AFD (code:vpn-requires-afd) and an AKV-familyTLS_SOURCE—akvorakv-selfsigned(code:vpn-requires-akv);letsencryptis rejected because ACME HTTP-01 cannot reach a VPN-only client. Theakv-selfsignedvariant is also rejected end-to-end on AFD stamps byUNSUPPORTED_COMBOSindeploy/scripts/deploy.mjs(AFD rejects self-signed origin certs at TLS validation), so the only effective combo for the AFD+VPN trusted-bypass isEDGE_MODE=afd+TLS_SOURCE=akv.privatemode is rejected because the auto-seeded WAF guards assume AFD as the public ingress.SSL_CERT_DOMAIN_SUFFIXmust be set — the managed Private DNS zone uses it (code:vpn-requires-domain-suffix).VPN_CLIENT_ADDRESS_POOLmust not overlap the VNetVNET_CIDR(default10.20.0.0/16); validated against both endpoints (code:vpn-pool-overlap). Default pool is172.16.200.0/24(~250 concurrent clients).AZURE_TENANT_IDmust be set — VPN runs in the same Entra ID tenant as the rest of the stamp (code:vpn-requires-tenant-id).- Tenant admin access to author the Conditional Access policy below.
| Var | Default | Notes |
|---|---|---|
VPN_GATEWAY_ENABLED |
false |
Master switch. true provisions the gateway + GatewaySubnet + managed Private DNS zone + Azure Private DNS Resolver inbound endpoint (with its own /28 subnet), and seeds the WAF guard rules. The Resolver IP is advertised to P2S clients via the parent VNet's dhcpOptions.dnsServers (the supported P2S DNS-push path) so the portal hostname resolves through the tunnel without hosts-file edits. |
VPN_GATEWAY_SKU |
VpnGw2AZ |
VpnGw2AZ, VpnGw3AZ, VpnGw4AZ, or VpnGw5AZ. Generation2 AZ SKUs only. VpnGw1AZ (Generation1) is excluded — silently drops OpenVPN+AAD HardResetClientV2 packets ~5s after TCP accept with no diagnostic event. Non-AZ SKUs (VpnGw1/2/3) and Basic are also rejected. |
VPN_CLIENT_ADDRESS_POOL |
172.16.200.0/24 |
CIDR assigned to connected clients. MUST NOT overlap the VNet. |
VPN_AAD_AUDIENCE |
c632b3df-fb67-4d84-bdcf-b95ad541b5c8 |
Microsoft-registered Azure VPN Client app. Set to 41b23e61-6c1e-4545-b367-cd054e0ed4b4 only on tenants that must interop with older Azure VPN client builds. |
APPGW_WAF_CUSTOM_RULES_FILE |
unset | See AppGw WAF custom rules below — a general-purpose facility, not VPN-specific. |
Create one CA policy targeting the Azure VPN Client app:
- Target app:
c632b3df-fb67-4d84-bdcf-b95ad541b5c8(default), or41b23e61-6c1e-4545-b367-cd054e0ed4b4if you setVPN_AAD_AUDIENCEto the legacy override. - Assignment: a NAMED users group. Do not target "all users" — every Entra principal in your tenant would otherwise inherit VPN reachability to the AppGw private FE.
- Grant: require MFA.
- Grant: do NOT require device compliance — the OpenVPN client cannot satisfy that grant and connect attempts will fail with an opaque AAD error.
The post-scaffold reminder block in new-env.mjs re-prints these
requirements when VPN_GATEWAY_ENABLED=true. The full skill-side
reference lives in pilotswarm-new-env-deploy (Step 4 → Optional: VPN Gateway P2S).
After the first deploy completes, hand operators the OpenVPN client profile via one of these paths (in order of preference):
- Helper script (recommended):
pwsh -File deploy/scripts/auth/Get-VpnClientProfile.ps1 -EnvName <stamp>. Wraps theaz network vnet-gateway vpn-client generatecall, downloads the signed zip, and extracts it under the gitignoreddeploy/envs/local/<stamp>/vpn-client/folder. See thepilotswarm-vpn-client-profileskill for full usage. - Azure portal:
Resource group → <gateway-name> → Point-to-site configuration → Download VPN client. - CLI:
az network vnet-gateway vpn-client generate --resource-group <rg> --name <gateway-name> --authentication-method EAPTLS.
All three emit the same .zip that imports directly into the Azure VPN
Client app (Windows / macOS / iOS / Android). The profile embeds the
AAD audience and the gateway public IP — re-issue it (-Force on the
helper script) if you rotate VPN_AAD_AUDIENCE. The XML carries no
per-user credentials; end users authenticate with their own Entra ID
at connect time.
When VPN_GATEWAY_ENABLED=true, base-infra bicep
(deploy/services/base-infra/bicep/application-gateway.bicep) prepends
three custom rules to the AppGw WAF policy's customRules.rules array:
| Priority | Name | Action | Match |
|---|---|---|---|
| 90 | AllowAfd |
Allow |
RequestHeaders[X-Azure-FDID] == <frontDoorId> (threaded from global-infra) |
| 91 | AllowVpn |
Allow |
RemoteAddr ∈ VPN_CLIENT_ADDRESS_POOL (IPMatch) |
| 92 | BlockOther |
Block |
RemoteAddr ∈ 0.0.0.0/0 (catch-all) |
Together: AFD-origin-authenticity check (90) + VPN-pool allow-list (91) + catch-all block (92). The catch-all is belt-and-braces against future NSG/route changes accidentally exposing the AppGw private FE outside both paths.
The 90–92 priority band is reserved. Operator-supplied rules from
APPGW_WAF_CUSTOM_RULES_FILE (next section) MUST start at priority ≥ 100;
the bicep concats them after the auto-seeded set.
When VPN_GATEWAY_ENABLED=false, no auto-seeded rules are emitted — a
VPN-off stamp's AppGw WAF policy diff vs a no-VPN baseline is empty.
APPGW_WAF_CUSTOM_RULES_FILE is a general-purpose facility, parallel
to the AFD-side WAF_CUSTOM_RULES_FILE, and works on any
EDGE_MODE=afd stamp regardless of whether VPN is enabled. Point it at a
JSON array file containing AppGw WAF custom rule objects; the orchestrator
resolves the path (relative-to-repo-root or absolute), parses the JSON,
and threads it into the bicep deploy as appgwWafCustomRules.
# Recommended location (gitignored under deploy/envs/local/<stamp>/):
APPGW_WAF_CUSTOM_RULES_FILE=deploy/envs/local/<stamp>/appgw-waf-custom-rules.jsonThe merge logic mirrors the bicep exactly in
deploy/scripts/lib/appgw-waf-rules.mjs so test cases can assert merged
rule shape without shelling out to az. Missing-file or non-JSON-array
inputs fail loudly at preflight with a single named diagnostic.
- Cost: ~$450/month total for a VPN-enabled stamp:
- ~$280/month for
VpnGw2AZ(Public IP + gateway hours). Higher AZ SKUs (VpnGw3AZ/VpnGw4AZ/VpnGw5AZ) scale linearly — see Azure VPN Gateway pricing for current rates.VpnGw1AZ(Generation1) and non-AZ SKUs (VpnGw1/2/3) are excluded — see the SKU notes above. - ~$170/month for the Azure Private DNS Resolver inbound endpoint, which
is co-provisioned with the VPN gateway so P2S clients can resolve
Private DNS Zone records (e.g. the portal hostname) through the tunnel
without hosts-file edits. P2S clients cannot reach the Azure-magic
168.63.129.16resolver — that IP only works from inside Azure VMs — so the Resolver inbound endpoint sits on a regular VNet IP. P2S clients inherit this IP via the parent VNet'sdhcpOptions.dnsServersblock, which the gateway pushes at connect time (the classic VPN gateway resource has no dedicated DNS-push property — VNet DHCP options are the supported path).
- ~$280/month for
- First-deploy time: 45+ minutes. Gateway provisioning is the
long pole; the rest of the stamp finishes well before the gateway
reports
Succeeded. Subsequent param-change deploys are minutes, not 45+.
pilotswarm-new-env-deployskill — full step-by-step including theEDGE_MODE × TLS_SOURCE × VPN_GATEWAY_ENABLEDmatrix, scaffolder prompts, and the post-scaffold reminder.pilotswarm-aks-deployskill — legacy bash deploy path; surfaces VPN cost / time as operator context but does not orchestrate VPN itself.
The IaC path mounts the worker's model catalog as a kustomize-generated
ConfigMap (copilot-worker-model-providers) sourced from
deploy/gitops/worker/base/model_providers.json,
exposed to the runtime via PS_MODEL_PROVIDERS_PATH=/app/config/model_providers.json.
This is separate from the legacy scripts/deploy-aks.sh flow,
which bakes deploy/config/model_providers.ghcp.json into the image.
Built-in providers in the base catalog:
| Provider | Auth secret (KV → SPC) | Endpoint | When it loads |
|---|---|---|---|
github-copilot |
GITHUB_TOKEN |
https://api.githubcopilot.com |
Always |
anthropic (direct) |
ANTHROPIC_API_KEY |
https://api.anthropic.com |
When the key is set (sentinel-tolerant) |
azure-foundry, azure-kimi |
AZURE_OAI_KEY |
__FOUNDRY_ENDPOINT__/openai/v1 |
Only when FOUNDRY_ENABLED=true |
__FOUNDRY_ENDPOINT__ is rewritten to the live Foundry account URL
during --steps manifests (from the FOUNDRY_ENDPOINT Bicep output).
When Foundry is disabled, the placeholder stays in the file and the
worker's catalog loader silently drops the Foundry providers
(apiKey: env:AZURE_OAI_KEY resolves to the stripped __PS_UNSET__
sentinel, i.e. undefined).
npm run deploy:new-env -- <name> --foundry-enabled y
# scaffolds deploy/envs/local/<name>/foundry-deployments.json
# (a JSON array; the stdout banner lists common entries to copy in)
# edit the JSON to your desired model deployments, then:
npm run deploy -- --env <name>foundry.bicep provisions one Microsoft.CognitiveServices/accounts
(kind=AIServices) per stamp, each entry as a child
accounts/deployments, and writes azure-oai-key directly to KV via
co-located listKeys(). When FOUNDRY_ENABLED=false,
auto-secrets-sentinel.bicep writes the __PS_UNSET__ sentinel into
the same KV secret so the SPC mount still succeeds.
Phase 1 only. Foundry uses key auth; Claude is direct-Anthropic. Phase 2 (SDK Entra-mode for Foundry) and Phase 3 (Foundry-hosted Claude) are tracked in
docs/proposals/foundry-entra-mode-auth.mdanddocs/proposals/foundry-hosted-claude.md.
Drop a kustomize overlay patch on the
copilot-worker-model-providers ConfigMap in
deploy/gitops/worker/overlays/<overlay>/ to diverge from the base
catalog for one stamp. Keep __FOUNDRY_ENDPOINT__ in the patched JSON
to keep endpoint substitution; hard-code the URL to opt out.
Local dev outside Azure (e.g. kind, k3d, plain Docker) is not part
of the IaC path. Use the legacy local scripts (./run.sh,
scripts/deploy-aks.sh) for those scenarios. The IaC path assumes an
Azure target.
The base-infra Bicep provisions a per-stamp Log Analytics workspace
(<RESOURCE_PREFIX>-log) and an AKS Container Insights Data Collection
Rule that ships pod stdout/stderr to the workspace using the modern
ContainerLogV2 schema. This gives you historical, queryable logs
that survive pod restarts — a step up from kubectl logs, which only
shows the current and previous container instance.
Find the workspace:
# From base-infra deployment outputs (set after `--steps bicep`):
az deployment group show \
-g "$RESOURCE_GROUP" \
-n base-infra \
--query "properties.outputs.logAnalyticsWorkspaceName.value" -o tsv
# → <RESOURCE_PREFIX>-logOpen the workspace in the Azure portal → Logs, and run KQL like:
// Last 200 portal log lines
ContainerLogV2
| where PodNamespace == "pilotswarm"
| where PodName startswith "pilotswarm-portal"
| order by TimeGenerated desc
| take 200
| project TimeGenerated, PodName, ContainerName, LogMessage
// Worker errors in the last 1h
ContainerLogV2
| where PodNamespace == "pilotswarm"
| where PodName startswith "copilot-runtime-worker"
| where TimeGenerated > ago(1h)
| where LogLevel in ("error", "warn") or LogMessage contains_cs "ERROR"
| order by TimeGenerated desc
// Pod restarts / OOMKills
KubeEvents
| where Namespace == "pilotswarm"
| where Reason in ("BackOff", "Failed", "OOMKilling", "Killing")
| order by TimeGenerated desc
| project TimeGenerated, Name, Reason, MessageNotes:
- Ingestion lag is ~3–10 minutes. Tail follow-ups should still use
kubectl logs -ffor live debugging; KQL is the historical view. - Retention defaults to 30 days (free tier). Tune via
LOG_ANALYTICS_RETENTION_DAYSindeploy/envs/local/<env>/env. Up to 730 days is supported; 30+ is billed at the workspace's PerGB2018 rate. - ContainerLogV2 is the only schema enabled. The legacy
ContainerLogtable is deprecated (retiring 2026-09-30) and is not populated. - Cost. A small dev stamp typically lands at 1–3 GB/day; ingestion
is ~$2.30/GB after the free 5 GB/month per workspace. Use the
Usage | summarize sum(Quantity) by DataTypequery to monitor.
┌──────────────────────────────────────────────────────────────────────┐
│ Your App (Client) │
│ PilotSwarmClient({ apiUrl: "https://<portal-url>" }) │
│ → createSession, sendAndWait, on() │
└────────────────────┬─────────────────────────────────────────────────┘
│ HTTP /api/v1 + WebSocket /api/v1/ws
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Portal Pod (hosts the versioned Web API, port 3001) │
└────────────────────┬─────────────────────────────────────────────────┘
│ PostgreSQL
▼
┌──────────────────────────────────────────────────────────────────────┐
│ PostgreSQL (Azure Database for PostgreSQL) │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ duroxide schema │ │ copilot_sessions │ │
│ │ (orchestrations) │ │ (session catalog)│ │
│ └─────────────────┘ └──────────────────┘ │
└────────────────────┬─────────────────────────────────────────────────┘
│ PostgreSQL
▼
┌──────────────────────────────────────────────────────────────────────┐
│ AKS Worker Pods (N replicas) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ worker-1 │ │ worker-2 │ │ worker-3 │ │ worker-N │ │
│ │ polls PG │ │ polls PG │ │ polls PG │ │ polls PG │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Each pod: node packages/sdk/examples/worker.js │
│ → Picks up orchestrations from the queue │
│ → Runs LLM turns via Copilot SDK │
│ → Dehydrates/hydrates sessions via Azure Blob Storage │
└──────────────────────────────────────────────────────────────────────┘
Client apps talk to the versioned Web API hosted by the portal — see
docs/api/reference.md. Constructing a client with
{ store: DATABASE_URL } still exists but is internal (workers,
portal-host embedding, and internal testing only). Workers always use
{ store } — they are trusted backend components with direct database
access.
- Azure CLI (
az) installed and logged in kubectlconfigured for your AKS cluster- An Azure Container Registry (ACR) for Docker images
- An Azure Database for PostgreSQL (Flexible Server)
- An Azure Storage Account (for session blob storage)
Before deploying, always check for old worker pods in other namespaces that may still be connected to the same database:
kubectl get pods --all-namespaces -l app.kubernetes.io/component=worker --no-headersOld workers from a previous namespace (e.g. copilot-sdk vs copilot-runtime) will process orchestrations with stale orchestration code, causing nondeterminism errors. Delete any old deployments before deploying:
kubectl delete deployment copilot-runtime-worker -n <old-namespace>For this repository, prefer the canonical deploy/reset path:
./scripts/deploy-aks.shThat script refreshes the Kubernetes secret, optionally wipes remote state, builds the SDK, pushes the worker image, and waits for rollout completion.
kubectl apply -f deploy/k8s/namespace.yamlThis creates the copilot-runtime namespace.
PilotSwarm's checked-in model-catalog template is .model_providers.example.json. Workers actually load the local .model_providers.json, which is gitignored so teams can keep personal endpoint URLs and similar local details out of source control. The Kubernetes secret only needs the env vars referenced by that real runtime catalog and the worker runtime.
Store your credentials as a Kubernetes secret:
kubectl create secret generic copilot-runtime-secrets \
-n copilot-runtime \
--from-literal=DATABASE_URL="postgresql://user:pass@myserver.postgres.database.azure.com:5432/postgres?options=-csearch_path%3Dcopilot_runtime&sslmode=require" \
--from-literal=GITHUB_TOKEN="ghp_xxxxxxxxxxxx" \
--from-literal=DUROXIDE_PG_POOL_MAX="10" \
--from-literal=PILOTSWARM_CMS_PG_POOL_MAX="3" \
--from-literal=PILOTSWARM_FACTS_PG_POOL_MAX="3" \
--from-literal=PILOTSWARM_ORCHESTRATION_CONCURRENCY="2" \
--from-literal=PILOTSWARM_WORKER_CONCURRENCY="2" \
--from-literal=PILOTSWARM_TURN_TIMEOUT_MS="1200000" \
--from-literal=AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=..." \
--from-literal=AZURE_STORAGE_CONTAINER="copilot-sessions"Worker sizing is env-driven:
DUROXIDE_PG_POOL_MAX—duroxide-pgprovider pool size. Default:10.PILOTSWARM_CMS_PG_POOL_MAX— CMSpg.Poolmax size. Default:3.PILOTSWARM_FACTS_PG_POOL_MAX— factspg.Poolmax size. Default:3.PILOTSWARM_ORCHESTRATION_CONCURRENCY— Duroxide orchestration concurrency. Default:2.PILOTSWARM_WORKER_CONCURRENCY— Duroxide activity/worker concurrency. Default:2.PILOTSWARM_TURN_TIMEOUT_MS— wall-clock cap for one Copilot turn. Default:1200000(20 minutes);0disables it.
Provider availability in selectors is env-driven at worker startup. If you add or remove a provider key, refresh the secret and restart the workers; changing the checked-in template alone is not enough, and changing the real .model_providers.json only takes effect after the updated file is present in the runtime environment.
If you also deploy the shipped browser portal, treat it as a separate runtime surface from the worker pods:
- package the same app plugin into the portal image
- set
PLUGIN_DIRSin the portal deployment so the web process can readplugin.json.portal,plugin.json.tui, creatable agent metadata, and session policy - keep portal branding in
plugin.json.portal, usingplugin.json.tuias a fallback or shared source only when that is intentional
If the portal pod cannot see the app plugin, the browser UI falls back to generic PilotSwarm branding and generic-session creation even when the worker supports named agents.
The portal process also hosts the versioned Web API — HTTP /api/v1 and
WebSocket /api/v1/ws — used by the SDK's web mode, the
pilotswarm-sdk/api package, and pilotswarm remote --api-url (see
docs/api/reference.md). This requires no new
ingress rules: the existing portal ingress (path / prefix, 3600s
WebSocket timeouts) already covers /api/v1 and /api/v1/ws, and the
readiness probe stays /api/health. TUI and SDK users of the deployment
need exactly one value: the portal URL.
Portal auth is provider-based. For the shipped Entra add-on, add these env vars
to copilot-runtime-secrets (or the portal deployment env) before restarting
the portal:
PORTAL_AUTH_PROVIDER=entra
PORTAL_AUTH_ENTRA_TENANT_ID=<tenant-id>
PORTAL_AUTH_ENTRA_CLIENT_ID=<client-id>
PORTAL_AUTHZ_ADMIN_GROUPS=admin1@contoso.com,admin2@contoso.com
PORTAL_AUTHZ_USER_GROUPS=user1@contoso.com,user2@contoso.comRegister the portal ingress URL as the SPA redirect URI in Entra. The portal core does not require Entra specifically, so alternate providers can use the same deployment slot without changing the portal shell contract.
Automating the app registration. Rather than clicking through the Azure Portal, use
deploy/scripts/auth/Setup-PortalAuth.ps1to create the Entra application, register the SPA redirect URI, and (optionally) define theadmin/userapp roles consumed by the portal authz engine. The script requires-ServiceTreeId(operator-supplied) and exposes-CreateAppRoles(recommended for production stamps) and-AssignmentRequired(advanced opt-in — see caveat in the README) switches for role-driven and Entra-level lockdown postures. The recommended production lockdown is-CreateAppRolesplus role assignments in Entra (the role assignment is the allowlist); the portal engine is deny-by-default for any signed-in principal without a role claim. Full operator docs:deploy/scripts/auth/README.md. Agent-driven invocation:pilotswarm-portal-app-regskill.
Use the canonical PORTAL_AUTH_* / PORTAL_AUTHZ_* keys only. The portal no
longer reads legacy ENTRA_* aliases.
For tenants driving admission from Entra app roles instead of an email
allowlist, see docs/portal-entra-app-roles.md
for the recommended end-state setup (define roles → assign principals
→ rely on engine deny-by-default → optionally enable
appRoleAssignmentRequired=true if your tenant allows it without
tripping admin-consent → align CA). In the Roles posture, the role
assignment in Entra is the allowlist — do not also populate
PORTAL_AUTHZ_ADMIN_GROUPS, since the engine bypasses it whenever the
JWT carries a roles[] claim.
Portal authz supports both email allowlists and Entra app-role claims:
- authenticated users whose email appears in the configured admin/user allowlists are allowed in
- authenticated users whose token carries an Entra app-role claim (
admin/user) are decided from that claim; seeportal-entra-app-roles.md adminanduserhave the same portal permissions today- per-user session visibility is a later phase
The GitHub token expires periodically. To update:
kubectl create secret generic copilot-runtime-secrets \
-n copilot-runtime \
--from-literal=DATABASE_URL="..." \
--from-literal=GITHUB_TOKEN="$(gh auth token)" \
--from-literal=DUROXIDE_PG_POOL_MAX="10" \
--from-literal=PILOTSWARM_CMS_PG_POOL_MAX="3" \
--from-literal=PILOTSWARM_FACTS_PG_POOL_MAX="3" \
--from-literal=PILOTSWARM_ORCHESTRATION_CONCURRENCY="2" \
--from-literal=PILOTSWARM_WORKER_CONCURRENCY="2" \
--from-literal=PILOTSWARM_TURN_TIMEOUT_MS="1200000" \
--from-literal=AZURE_STORAGE_CONNECTION_STRING="..." \
--from-literal=AZURE_STORAGE_CONTAINER="copilot-sessions" \
--dry-run=client -o yaml | kubectl apply -f -The same pattern applies to Azure/OpenAI or Anthropic BYOK keys. If a provider should disappear from selectors, make sure its env var is absent when the secret is reapplied, then restart the deployment and verify the live model surface.
az acr login --name <your-acr-name># Build TypeScript first
npm run build
# Build and push Docker image
docker buildx build \
--platform linux/amd64 \
-f deploy/Dockerfile.worker \
-t <your-acr-name>.azurecr.io/copilot-runtime-worker:latest \
--push .The Dockerfile (deploy/Dockerfile.worker) builds a minimal image:
node:24-trixie-slimbase- Production dependencies only (
npm ci --omit=dev --force) - Copies
packages/sdk/dist/andpackages/sdk/examples/worker.js - Runs as non-root
nodeuser
Update deploy/k8s/worker-deployment.yaml with your ACR URL:
containers:
- name: worker
image: <your-acr-name>.azurecr.io/copilot-runtime-worker:latestkubectl apply -f deploy/k8s/worker-deployment.yamlkubectl get pods -n copilot-runtime -l app.kubernetes.io/component=workerExpected output:
NAME READY STATUS RESTARTS AGE
copilot-runtime-worker-xxxxx-aaaaa 1/1 Running 0 30s
copilot-runtime-worker-xxxxx-bbbbb 1/1 Running 0 30s
copilot-runtime-worker-xxxxx-ccccc 1/1 Running 0 30s
copilot-runtime-worker-xxxxx-ddddd 1/1 Running 0 30s
kubectl logs -n copilot-runtime -l app.kubernetes.io/component=worker --prefix --tail=20You should see:
[pod/copilot-runtime-worker-xxxxx/worker] [worker] Pod: copilot-runtime-worker-xxxxx
[pod/copilot-runtime-worker-xxxxx/worker] [worker] Started ✓ Polling for orchestrations...
After a cold start or destructive reset, the workers will automatically recreate the built-in system sessions (PilotSwarm Agent, Sweeper Agent, Resource Manager Agent, Facts Manager). A truly empty session list is therefore temporary.
Deploys never reset data. A reset is a separate, deliberate operation — run it only when a wipe was explicitly requested:
./scripts/reset-db-aks.sh --i-understand-this-deletes-all-dataThis scales workers to 0, wipes the database, and restores the previous
replica count. (Equivalent manual sequence: scale to 0 →
NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=.env.remote scripts/db-reset.js --yes
→ scale back up.)
This drops:
duroxidecopilot_sessionspilotswarm_facts- all blobs in
copilot-sessionswhen blob storage is configured
After the workers come back, expect the built-in system sessions to be recreated immediately. For replay-sensitive testing, verify that the recreated root PilotSwarm Agent is healthy before starting new user sessions.
From your application (anywhere that can reach the portal URL over HTTPS):
import { PilotSwarmClient } from "pilotswarm-sdk";
const client = new PilotSwarmClient({
apiUrl: "https://<portal-url>",
// getAccessToken: async () => "...", // when the portal runs Entra auth
});
await client.start();
// Sessions are processed by AKS worker pods
const session = await client.createSession();
await session.send("Monitor this service every 5 minutes for the next 24 hours");
console.log(`Session ${session.sessionId} is running on AKS`);The client talks to the portal's Web API (/api/v1 + /api/v1/ws) — see
docs/api/reference.md. Direct
{ store: DATABASE_URL } construction is internal (workers, portal-host
embedding, and internal testing only); don't use it for client apps.
Or use the TUI in remote mode — the only value you need is the portal URL:
npx pilotswarm remote --api-url https://<portal-url>Auth is discovered from the deployment: a no-auth portal starts
immediately; an Entra-protected portal opens your browser for an interactive sign-in in the
terminal, with tokens cached at ~/.config/pilotswarm/auth/. You can also
sign in ahead of time (or check/clear the cache) with:
npx pilotswarm auth login --api-url https://<portal-url>
npx pilotswarm auth status --api-url https://<portal-url>
npx pilotswarm auth logout --api-url https://<portal-url>No DATABASE_URL and no kubectl needed — logs stream over the API.
(PILOTSWARM_API_URL, e.g. in .env.remote, works instead of the flag.)
Operator note.
pilotswarm remote --store "$DATABASE_URL"still works for direct database access, but it is for operators/internal use.--api-urland--storetogether are an error.
Adjust the replica count:
kubectl scale deployment copilot-runtime-worker -n copilot-runtime --replicas=8Workers are stateless — each polls the PostgreSQL queue for available work. duroxide ensures exactly-once execution.
The default resource requests/limits in the deployment:
resources:
requests:
cpu: "250m"
memory: "1Gi"
limits:
cpu: "1000m"
memory: "4Gi"Each worker runs one LLM turn at a time. Increase CPU limits if tool execution is compute-heavy.
The deployment includes a toleration for Azure spot instances:
tolerations:
- key: "kubernetes.azure.com/scalesetpriority"
operator: "Equal"
value: "spot"
effect: "NoSchedule"Spot instances are safe because sessions are durable — if a spot node is evicted, the orchestration retries automatically on another node.
# Rebuild and push
npm run build
docker buildx build --platform linux/amd64 -f deploy/Dockerfile.worker \
-t <your-acr-name>.azurecr.io/copilot-runtime-worker:latest --push .
# Restart pods (pulls latest image)
kubectl rollout restart deployment/copilot-runtime-worker -n copilot-runtime
# Wait for rollout to complete
kubectl rollout status deployment/copilot-runtime-worker -n copilot-runtimeIn-flight orchestrations are safe during rollouts. If a worker is killed mid-turn, duroxide will retry the activity on another worker after the lock timeout.
To wipe all orchestration and session state (only when a wipe was explicitly requested — deploys never do this):
./scripts/reset-db-aks.sh --i-understand-this-deletes-all-dataThis drops the ps_duroxide, copilot_sessions, and pilotswarm_facts schemas (plus the legacy duroxide schema if present). Use with caution — all in-flight sessions will be lost.
# Check pods are running
kubectl get pods -n copilot-runtime -l app.kubernetes.io/component=worker
# Check logs for errors
kubectl logs -n copilot-runtime -l app.kubernetes.io/component=worker --tail=50
# Verify database connectivity
kubectl exec -n copilot-runtime deploy/copilot-runtime-worker -- \
node -e "console.log('DB OK')" --env-file=/dev/nullA session may be stuck if the activity timed out. Check the orchestration status:
# From your machine, via the deployment's Web API
# (.env.remote carries PILOTSWARM_API_URL)
node --env-file=.env.remote -e "
import { PilotSwarmClient } from './dist/index.js';
const c = new PilotSwarmClient({ apiUrl: process.env.PILOTSWARM_API_URL });
await c.start();
const s = await c.resumeSession('SESSION_ID');
console.log(await s.getInfo());
await c.stop();
"(Operators with direct database access can construct the client with
{ store: process.env.DATABASE_URL } instead — internal use only.)
If workers log authentication errors, refresh the secret:
kubectl create secret generic copilot-runtime-secrets -n copilot-runtime \
--from-literal=GITHUB_TOKEN="$(gh auth token)" \
--dry-run=client -o yaml | kubectl apply -f -
# Restart workers to pick up new secret
kubectl rollout restart deployment/copilot-runtime-worker -n copilot-runtimeMultiple teams or projects can share one AKS cluster. Each deployment gets its own Kubernetes namespace, secrets, and optionally its own database schemas.
Each deployment uses a different PostgreSQL database on the same server. No code
changes needed — just different DATABASE_URLs.
Team Alpha: postgresql://user:pass@pg-server:5432/alpha_pilotswarm
Team Beta: postgresql://user:pass@pg-server:5432/beta_pilotswarm
Use custom schema names to isolate deployments within a single database. Set
duroxideSchema and cmsSchema on both worker and client (see
Getting Started → Custom Schema Names).
Each team creates their own namespace and secrets:
TEAM_NS=copilot-alpha
kubectl create namespace $TEAM_NS
kubectl create secret generic copilot-runtime-secrets \
-n $TEAM_NS \
--from-literal=DATABASE_URL="postgresql://..." \
--from-literal=GITHUB_TOKEN="$(gh auth token)" \
--from-literal=AZURE_STORAGE_CONNECTION_STRING="..." \
--from-literal=AZURE_STORAGE_CONTAINER="alpha-sessions"Copy and customize the deployment manifests:
cp deploy/k8s/worker-deployment.yaml deploy/k8s/worker-deployment-alpha.yamlEdit the copy to update:
metadata.namespace→ your team namespacespec.template.spec.containers[0].image→ your ACR image
Then deploy:
kubectl apply -f deploy/k8s/worker-deployment-alpha.yamlPoint the TUI at that deployment's portal URL — logs stream over the API,
so no kubectl access or namespace/label flags are needed:
npx pilotswarm remote --api-url https://<alpha-portal-url>If the deployment runs Entra auth, the TUI walks you through a
browser sign-in (or run npx pilotswarm auth login --api-url https://<alpha-portal-url> first).
Operator note. Direct-store mode still works and is where the K8s log-tail flags (
-c/--namespace/--label) apply:node packages/app/tui/bin/tui.js remote \ --env .env.alpha \ --namespace copilot-alpha \ --label app.kubernetes.io/component=worker
For tighter isolation, use Kubernetes resource quotas:
apiVersion: v1
kind: ResourceQuota
metadata:
name: copilot-quota
namespace: copilot-alpha
spec:
hard:
requests.cpu: "4"
requests.memory: 4Gi
limits.cpu: "8"
limits.memory: 8Gi
pods: "10"