Azure Kubernetes Service (AKS) Helm Deployment
Deploy Phase on AKS with standard Kubernetes networking, keep it private to your Tailscale network, or publish the same instance through Tailscale Funnel.
$ npx skills add phasehq/ai -s aksThen ask your agent to deploy Phase on Azure AKS
The Phase Helm chart remains independent of Tailscale. Tailscale ingress and egress are separate Kubernetes resources managed by the Tailscale Kubernetes operator; the chart needs no Tailscale dependency or modification.
Prerequisites
- An Azure subscription with permission to use AKS and create or update resources in the target resource group.
- An existing AKS cluster, or permission to create one.
- Azure Cloud Shell in Bash mode, or a workstation with the Azure CLI,
kubectl, Helm,jq,curl, and OpenSSL. Microsoft Entra-enabled AKS clusters also use thekubelogincredential plugin. Cloud Shell and current Azure CLI installations normally manage it; verify it is onPATHwithkubelogin --version. - At least 2.35 vCPUs and 3.8 GiB of allocatable memory for Phase, in addition to AKS system overhead, plus a storage class that can provision a 50 GiB ReadWriteOnce volume for the default in-cluster deployment.
- Enough total regional vCPU quota and quota in the selected VM family for the
intended AKS node count. The optional preflight below uses
Standard_D4as_v5as an example, not a requirement. - For either Tailscale path: a tailnet with MagicDNS and HTTPS certificates enabled.
Keep these example Azure and release values near the top for reference. The optional preflight uses the Azure values as variables; the main deployment steps show values directly so each command remains easy to read. Replace the marked Azure values for your cluster:
AZURE_RESOURCE_GROUP="phase-aks-rg" # 👈 replace with your resource group
AKS_CLUSTER="phase-aks" # 👈 replace with your AKS cluster name
AZURE_REGION="centralindia" # 👈 replace with your AKS region
PHASE_NAMESPACE="phase"
PHASE_RELEASE="phase-console"
PHASE_CHART_VERSION="1.0.2"
TAILSCALE_OPERATOR_VERSION="1.98.9"
Optional preflight: select a subscription, check SKU quota and
capacity, and create or connect AKS
Expand this section when choosing a region or node SKU, creating a cluster, or
refreshing local AKS credentials. If kubectl already targets a suitable
cluster with sufficient quota and capacity, continue with the check after this
section.
Select the Azure subscription
The Azure CLI already has an active subscription. Detect and display it instead of hard-coding a subscription ID:
az account show \
--query '{name:name,id:id,tenantId:tenantId}' \
--output table
Check regional quota and VM availability
AKS node creation is constrained by both total regional vCPU quota and the quota for the selected VM family. Check the target region before creating or scaling a cluster:
az vm list-usage \
--location "$AZURE_REGION" \
--query "[?name.value=='cores'].{
Quota:name.localizedValue,
Used:currentValue,
Limit:limit
}" \
--output table
Create or connect to AKS
If you need a new cluster, create it using the AKS Automatic quickstart or the Azure portal. Use the quota-checked region. The validated development cluster used Azure CNI Overlay, a Standard load balancer, node auto-provisioning, a managed identity, OIDC, and workload identity.
For a production cluster, use Microsoft Entra authentication with Kubernetes
or Azure RBAC, disable local accounts, and avoid the non-auditable --admin
credential path. Prefer a private API server, or restrict a public API server
to approved source ranges. Inspect the current posture before deploying:
az aks show \
--resource-group "$AZURE_RESOURCE_GROUP" \
--name "$AKS_CLUSTER" \
--query '{
rbac:enableRbac,
entraManaged:aadProfile.managed,
azureRbac:aadProfile.enableAzureRbac,
localAccountsDisabled:disableLocalAccounts,
privateApi:apiServerAccessProfile.enablePrivateCluster,
authorizedIpRanges:apiServerAccessProfile.authorizedIpRanges
}' \
--output yaml
See the Azure guidance for disabling local accounts and securing API server access before changing an existing cluster's access model.
Tailscale's standalone proxy pods perform network configuration in an init container. Confirm that your AKS admission and pod-security policy allows the operator proxy workload before standardizing a locked-down cluster configuration.
Connect kubectl to the cluster:
# Entra-enabled AKS clusters use kubelogin for Azure CLI authentication.
# If this command is missing on a workstation, run: az aks install-cli
kubelogin --version
az aks get-credentials \
--resource-group "$AZURE_RESOURCE_GROUP" \
--name "$AKS_CLUSTER" \
--overwrite-existing
Whether you used the optional preflight or already had AKS configured, verify the active context before deploying:
kubectl config current-context
kubectl get nodes -o wide
The current context must be the intended cluster and every existing node
should report Ready before continuing.
Deployment
1. Prepare ingress before installing Phase
Choose one tab and complete it. For Funnel, use the Tailscale tab now and enable public access only after the private Phase health checks pass.
The application-routing NGINX mode is cluster-wide. Before running an
approuting update command on a shared cluster, inventory every Ingress that
uses webapprouting.kubernetes.azure.com and confirm its owner accepts the
load-balancer exposure change.
For a cluster without application routing, enable the add-on with an external NGINX controller:
# 👇 Replace these two Azure values with your cluster details.
az aks approuting enable \
--resource-group "phase-aks-rg" \
--name "phase-aks" \
--nginx External
kubectl -n app-routing-system get pods,services -o wide
kubectl get ingressclass webapprouting.kubernetes.azure.com
If application routing is already enabled, run update instead of enable:
# 👇 Replace these two Azure values with your cluster details.
az aks approuting update \
--resource-group "phase-aks-rg" \
--name "phase-aks" \
--nginx External
Set your final hostname and point its A/AAAA or CNAME record at the NGINX Service address:
# Point your Phase hostname at the address shown by this Service.
kubectl -n app-routing-system get service nginx -o wide
Install cert-manager using step 1 of the
Kubernetes Helm guide.
Do not reuse that guide's generic NGINX issuer. Create
aks-letsencrypt-prod.yaml with the AKS application-routing class and replace
the email address:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: "[email protected]"
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
ingressClassName: webapprouting.kubernetes.azure.com
kubectl apply --dry-run=server -f aks-letsencrypt-prod.yaml
kubectl apply -f aks-letsencrypt-prod.yaml
kubectl get clusterissuer letsencrypt-prod
The standard values in Create the Phase values file ask this issuer for the
Phase certificate.
Replace <trusted-bootstrap-cidr> with the administrator's stable public IP
/32, or the routed CIDR for a trusted VPN, VNet, or corporate network. From
the workstation that will perform onboarding, curl -4 https://ifconfig.me
prints its public egress IP; append /32 (for example,
198.51.100.24/32). For private access, ask the network administrator for the
routed range (for example, 10.20.0.0/16). Do not use 0.0.0.0/0. The
allowlist protects Phase routes while cert-manager's separate HTTP-01 solver
remains public. If you already operate another ingress controller, skip the
AKS add-on and issuer instructions, then follow the custom-controller path in
Create the Phase values file.
2. Create the Phase namespace and Secret
Create the namespace:
kubectl create namespace phase --dry-run=client -o yaml | kubectl apply -f -
Generate the four required values and create the Secret in one command. Run this once for a new deployment; if the Secret already exists, leave it unchanged unless you are deliberately rotating it:
kubectl -n phase create secret generic phase-console-secret \
--from-literal=SECRET_KEY="$(openssl rand -hex 32)" \
--from-literal=SERVER_SECRET="$(openssl rand -hex 32)" \
--from-literal=DATABASE_PASSWORD="$(openssl rand -hex 32)" \
--from-literal=REDIS_PASSWORD="$(openssl rand -hex 32)"
Back up this Secret in your approved secret-management system. Losing
SERVER_SECRET can make encrypted Phase data unreadable. Never put the Secret
values in a Helm values file, Git repository, ticket, or chat.
3. Create the Phase values file
Use the final PHASE_HOST from Prepare ingress before installing Phase in
global.host and, for chart-managed ingress, ingress.host. Do not install
with a placeholder and plan to repair it later.
Create phase-values.yaml and replace phase.example.com if you chose a
different hostname:
global:
host: "phase.example.com"
httpProtocol: "https://"
version: "v2.71.0"
phaseSecrets: phase-console-secret
passwordAuth:
enabled: true
database:
external: false
name: phase
user: phase
persistence:
enabled: true
size: 50Gi
storageClass: ""
redis:
external: false
ingress:
enabled: true
className: "webapprouting.kubernetes.azure.com"
host: "phase.example.com"
annotations:
# 👇 Replace with a trusted public /32 or private range such as 10.20.0.0/16.
nginx.ingress.kubernetes.io/whitelist-source-range: "<trusted-bootstrap-cidr>"
certManager:
enabled: true
issuerName: "letsencrypt-prod"
issuerKind: "ClusterIssuer"
The values above use the minimal in-cluster database path. For managed
PostgreSQL or Redis, start with the
Helm chart values
and configure database.external and redis.external before installation.
4. Install Phase
Add the Phase Helm repository:
helm repo add phase https://helm.phase.dev
helm repo update phase
Optionally render the pinned chart and ask the Kubernetes API server to validate the resources without persisting them:
helm template phase-console phase/phase \
--version 1.0.2 \
--namespace phase \
--values phase-values.yaml |
kubectl apply --dry-run=server -f - >/dev/null
Install Phase:
helm upgrade --install phase-console phase/phase \
--version 1.0.2 \
--namespace phase \
--values phase-values.yaml \
--timeout 15m
Do not add --wait or --atomic while using this chart version with the
bundled database. Application init containers wait for the migration hook,
and waiting for application readiness before running the hook can create a
circular wait.
Watch the workloads become ready:
kubectl -n phase get pods,pvc,services,ingress -o wide --watch
The successful migration hook deletes itself. kubectl get jobs returning no
migration job after installation is normal.
5. Verify Phase
Check that all three application deployments use the intended host:
kubectl -n phase exec \
deployment/phase-console-frontend -- printenv HOST
kubectl -n phase exec \
deployment/phase-console-backend -- \
printenv HOST ALLOWED_HOSTS ALLOWED_ORIGINS
kubectl -n phase exec \
deployment/phase-console-worker -- printenv HOST ALLOWED_HOSTS
For the standard AKS application-routing path, wait for DNS and the certificate before onboarding:
kubectl -n phase wait certificate/phase-console-tls \
--for=condition=Ready \
--timeout=10m
kubectl -n phase get certificate phase-console-tls
After it is ready, add only this annotation under ingress.annotations in the
standard phase-values.yaml. Chart 1.0.2 already emits an
ssl-redirect key, so adding that key again would create duplicate YAML:
ingress:
annotations:
# 👇 Replace with a trusted public /32 or private range such as 10.20.0.0/16.
nginx.ingress.kubernetes.io/whitelist-source-range: "<trusted-bootstrap-cidr>"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
Apply the updated values and confirm that HTTP redirects before creating the
first account. Run the request from the trusted bootstrap network; an
untrusted source should receive 403 instead:
helm upgrade phase-console phase/phase \
--version 1.0.2 \
--namespace phase \
--values phase-values.yaml \
--timeout 15m
curl --head "http://phase.example.com/"
For a custom controller, enforce and test its equivalent HTTPS redirect and bootstrap allowlist. Run the following health checks from an allowed source; for Tailscale ingress, use a device authorized on the tailnet:
curl --fail --show-error "https://phase.example.com/api/health"
curl --fail --show-error "https://phase.example.com/service/health/"
Expected responses resemble:
{"status":"alive"}
{"status":"alive","version":"v2.71.0"}
Open https://<phase-host> from the allowed source and create the first
account. It becomes the owner of its new organization. Copy or download the
recovery kit before finishing onboarding; it cannot be retrieved later.
The minimal values explicitly enable password authentication and do not configure SMTP. In this mode Phase cannot send a verification email, so password registrations become active immediately. Keep the standard or custom ingress bootstrap allowlist in place, and do not enable Funnel, until you deliberately configure and test the intended public registration policy. An email-domain allowlist alone is not identity proof without working email verification.
Before making the instance public, choose and test one authentication posture:
-
Configure SSO and its Secret values. In a fresh private browser session, verify the intended organization owner can complete SSO and has the expected membership before setting
passwordAuth.enabled: false; otherwise a provider or claim mismatch can lock every administrator out. Apply the change, verify SSO again in another fresh session, then confirm a non-destructive empty password registration request returns403:REGISTER_STATUS="$(curl --silent --show-error --output /tmp/phase-register-check.json \ --write-out '%{http_code}' \ --request POST \ --header 'content-type: application/json' \ --data '{}' \ "https://${PHASE_HOST}/service/auth/password/register/")" test "$REGISTER_STATUS" = 403 -
Or configure a working SMTP gateway, retain password authentication, and set the intended registration/domain policy. Complete a controlled email verification test before opening the network gate.
Use the
SSO configuration or
email gateway configuration
for the selected path. Keep provider and SMTP credentials in the existing
Kubernetes Secret, never in phase-values.yaml. After changing authentication
values, apply them and restart the ConfigMap consumers while the bootstrap
allowlist remains:
helm upgrade phase-console phase/phase \
--version 1.0.2 \
--namespace phase \
--values phase-values.yaml \
--timeout 15m
kubectl -n phase rollout restart \
deployment/phase-console-frontend \
deployment/phase-console-backend \
deployment/phase-console-worker
Only after the selected posture passes its test should a standard deployment
remove nginx.ingress.kubernetes.io/whitelist-source-range from
phase-values.yaml and run the Helm upgrade again. A custom-controller
deployment should remove its equivalent gate. Keep either allowlist permanently
when the instance is not intended for the whole internet.
For Tailscale ingress, verify that Azure still exposes only a private NGINX address:
kubectl -n app-routing-system get service nginx -o wide
kubectl -n app-routing-system get ingress phase-tailscale-bridge -o wide
Confirm that Helm applied the real-IP configuration to the Phase Ingress:
kubectl -n phase get ingress phase-console \
-o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/configuration-snippet}{"\n"}'
The command must print the three real_ip directives from the values file.
From a tailnet device, perform a new action that appears in the Phase audit
log. Its client address should be that device's Tailscale IPv4 address in
100.64.0.0/10 or its Tailscale IPv6 address, not an address in the AKS pod
CIDR. Existing audit events are historical and are not rewritten by this
change.
Optional networking
The base deployment is now healthy. The following options document the private ingress boundary, an explicit full-instance Funnel endpoint, and destination-specific tailnet egress. Ingress and private egress are independent choices.
Install the Tailscale Kubernetes operator, create the ingress bridge, and read
the assigned .ts.net hostname before installing Phase. Put that final
hostname in both global.host and ingress.host so the first Phase pods start
with the correct API origin.
This path is private to the tailnet unless you explicitly enable Funnel. The tested deployment sends Tailscale HTTPS ingress to the internal AKS NGINX Service; Phase keeps using the existing Helm-chart ingress routing.
No additional Phase configuration is required. Backend and worker replicas use normal Kubernetes and AKS DNS/routing for public APIs and services reachable through your Azure virtual network.
Upgrading
Back up the database and phase-console-secret, update the repositories, and
review the new chart and application release notes:
helm repo update phase
helm upgrade phase-console phase/phase \
--version "<new-chart-version>" \
--namespace phase \
--values phase-values.yaml \
--timeout 15m
Chart 1.0.2 does not checksum the application ConfigMap in pod templates.
After changing global.host or another ConfigMap-backed value, explicitly
restart its consumers:
kubectl -n phase rollout restart \
deployment/phase-console-frontend \
deployment/phase-console-backend \
deployment/phase-console-worker
kubectl -n phase rollout status \
deployment/phase-console-frontend
kubectl -n phase rollout status \
deployment/phase-console-backend
kubectl -n phase rollout status \
deployment/phase-console-worker
Security and availability
For a Tailscale route, HTTPS terminates at the operator proxy. Restrict direct access to the internal NGINX load balancer with Azure network controls so it does not become an unintended bypass around tailnet identity policy.
The CIDR passed to set_real_ip_from is also a trust boundary. The validated
Azure CNI Overlay setup trusts the pod CIDR because the standalone Tailscale
proxy can move between pod addresses. Restrict who can edit Ingress
annotations, prevent untrusted pods from reaching NGINX, and narrow the trusted
proxy range when your network design provides a stable smaller range. Never
trust 0.0.0.0/0, especially when Phase IP-based access rules depend on the
result.
One optional subnet-level control is the preview
service.beta.kubernetes.io/azure-allowed-ip-ranges load-balancer annotation,
set through the AKS application-routing
NginxIngressController resource.
Determine and verify the real source ranges first. With Azure CNI Overlay,
outbound pod traffic uses node IPs, so an allowlist normally needs the node
subnet and does not isolate one cluster pod from another. See the
AKS load-balancer restriction guidance
and add Kubernetes NetworkPolicy for pod-level boundaries.
Before production:
- Replace the bundled single-replica PostgreSQL and Redis workloads with managed services or an explicitly designed highly available deployment.
- Run multiple frontend, backend, and worker replicas and enable their pod disruption budgets.
- Back up the database and
SERVER_SECRET, then test restoration. - Use a multi-replica egress ProxyGroup and a private-ingress ProxyGroup where supported when one proxy is unacceptable. This does not make the validated Funnel path highly available.
- Apply Kubernetes NetworkPolicy and narrow Tailscale grants according to workload trust boundaries.
- Monitor public authentication and SCIM endpoints when Funnel is enabled.
- Test AKS upgrades and node auto-provisioning consolidation with stateful workloads before production.
Troubleshooting
AKS cannot allocate a node
Check both total regional quota and the candidate SKU's VM-family quota. A family limit of zero blocks that SKU even when total regional vCPUs are free. If both quotas are sufficient, the failure may be live Azure capacity; try another allowed SKU, zone, or region.
The Tailscale URL returns NGINX 404
Before Phase is installed, this is expected: the bridge reached internal NGINX but no Phase Ingress matched the hostname. After installation, compare the bridge, Phase Ingress, and backend configuration:
printf 'Bridge: %s\n' "$(kubectl -n app-routing-system get ingress \
phase-tailscale-bridge \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}')"
kubectl -n phase get ingress phase-console \
-o jsonpath='{.spec.rules[0].host}{"\n"}'
kubectl -n phase exec \
deployment/phase-console-backend -- printenv HOST ALLOWED_HOSTS
They must use the same full hostname.
Audit events show the Tailscale proxy pod address
Without the real-IP annotation in the Tailscale values, AKS managed NGINX
replaces Tailscale's client address and Phase records an overlay address such
as 10.244.x.x. Compare the cluster pod CIDR with the live Phase Ingress:
az aks show \
--resource-group "phase-aks-rg" \
--name "phase-aks" \
--query networkProfile.podCidr \
--output tsv
kubectl -n phase get ingress phase-console \
-o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/configuration-snippet}{"\n"}'
Correct the CIDR in phase-values.yaml, rerun the Helm upgrade from Install
Phase, and create a new audit event. Private tailnet events should
show a Tailscale IPv4 or IPv6 address; Funnel events should show the public
client address. Older events keep their original recorded value.
The frontend calls an invalid or old API hostname
Install ingress first and put its final hostname into global.host and the
matching chart-managed or custom Ingress host. If the values were corrected
after installation, run the three rollout restarts in the upgrade section and
clear the old site's browser cache.
Helm appears stuck on migrations
The backend, worker, and frontend init containers wait for database migrations. Inspect the migration hook while Helm is still running:
kubectl -n phase get pods
kubectl -n phase logs \
job/phase-console-migrations --all-containers
Check PostgreSQL and Redis connectivity, credentials, PVC binding, and node capacity. Do not delete the hook until the underlying failure is understood.
The egress Service never becomes ready
Operator 1.98.9 reports TailscaleProxyReady=True. Older examples may name
a different condition. Inspect the Service, proxy pods, operator logs, target
hostname, tag ownership, and grants:
kubectl -n phase describe service phase-local-llm-gateway
kubectl -n tailscale get pods
kubectl -n tailscale logs deployment/operator --tail=200
Funnel appears private during testing
A tailnet client resolves the .ts.net hostname to its private Tailscale
address. Use the DNS-over-HTTPS and curl --resolve checks above to force the
public path. A direct dig @1.1.1.1 can still be intercepted by local
Tailscale DNS.
Uninstalling
Remove Phase without deleting its Kubernetes Secret or persistent volume claims:
helm uninstall phase-console \
--namespace phase
kubectl -n phase get secret,pvc
Deleting phase-console-secret or the PostgreSQL PVC is destructive. Retain
them unless you intentionally want to discard the deployment's encrypted data.
If no other workload uses them, remove only the Phase-specific manifest files that exist on the current machine:
for manifest in \
phase-local-llm-gateway.yaml \
tailscale-bridge.yaml \
tailscale-proxy-class.yaml
do
if [ -f "$manifest" ]
then
kubectl delete -f "$manifest" --ignore-not-found
fi
done
Uninstall the operator only when no other cluster resource uses it:
helm uninstall tailscale-operator --namespace tailscale
Revoke its OAuth client in the Tailscale admin console. Disable AKS application routing only after confirming that no other cluster Ingress uses the controller.