SELF-HOSTING

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.

AI Deployment Skill

aks
$ npx skills add phasehq/ai -s aks

Then 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 the kubelogin credential plugin. Cloud Shell and current Azure CLI installations normally manage it; verify it is on PATH with kubelogin --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_v5 as 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.

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.

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

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.

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 returns 403:

    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.