Kubernetes Helm Deployment
Learn how to set up the Phase Console using Helm on Kubernetes.
$ npx skills add phasehq/ai -s k8sThen ask your agent to deploy Phase on Kubernetes
This guide will walk you through installing the Phase Console on a Kubernetes cluster. By default, the installation includes:
- Phase Console
- Database services (PostgreSQL and Redis) running inside the cluster
- NGINX ingress controller
- Let's Encrypt certificate for TLS
By default, this guide sets up Phase Console with in-cluster databases and an NGINX ingress. However, you have the flexibility to use external databases if preferred. If you're using your own ingress controller and TLS certificates, you can skip steps 1 to 5 of the deployment process.
Prerequisites
Minimum Requirements
- CPU: 2.35 cores (2350m)
- Memory: 3.768 GB (3866Mi)
- Storage: 50Gi for PostgreSQL
Deployment
1. Install cert-manager
Install cert-manager (replace v1.17.2 with the latest version from https://github.com/cert-manager/cert-manager/releases):
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml
Wait for cert-manager to be fully deployed:
kubectl wait --for=condition=Ready pods -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=60s
2. Configure ClusterIssuer
Create a file named cluster-issuer.yaml with the following content:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected] # 👈 Replace with your email here
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
Apply the ClusterIssuer:
kubectl apply -f cluster-issuer.yaml
3. Install NGINX Ingress Controller
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--set controller.publishService.enabled=true
This installs the ingress controller into its own ingress-nginx namespace, which is where the Client IP Forwarding ConfigMap and the troubleshooting steps below expect to find it. If you omit --namespace, Helm installs it into whatever namespace your kubectl context currently points at, and those later steps will not match your cluster.
4. Get the LoadBalancer IP
Run the following command to get the external IP of the ingress-nginx-controller service:
kubectl get svc -n ingress-nginx
Look for the EXTERNAL-IP of the ingress-nginx-controller service. This may be an IP address or a hostname, depending on your cloud provider.
5. Update your DNS
Point your domain (e.g., phase.your-domain.com) to the LoadBalancer IP or hostname obtained in step 4. Use an A/AAAA record for an IP address or a CNAME record for a hostname.
6. Add the Phase Helm repository
helm repo add phase https://helm.phase.dev
helm repo update
7. Create a Kubernetes managed secret containing the Phase Console secrets
Create a secret named phase-console-secret with the following necessary secrets:
kubectl 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)
Please see the Secret and deployment configuration for more information on the required secrets.
8. Create a values file
Create a file named phase-values.yaml with the following config:
global:
host: "phase.your-domain.com" # 👈 Replace with your domain
version: "latest" # 👈 Replace with your preferred version https://github.com/phasehq/console/releases
phaseSecrets: phase-console-secret # 👈 The name of the secret you have created previously
ingress:
enabled: true
className: "nginx"
annotations: {}
certManager:
enabled: true
issuerName: "letsencrypt-prod"
issuerKind: "ClusterIssuer"
You can find additional configuration options in the values.yaml file.
You can find the latest version of the Phase Console on the GitHub release
9. Install Phase
helm install phase-console phase/phase -f phase-values.yaml
10. Verify the deployment
kubectl get pods
kubectl get ingress
kubectl get certificate
This process may take up to 10 minutes to complete. You may run watch kubectl get pods and keep an eye out for the cert-manager pod to exit successfully. This would mean that the certificate is issued and the ingress is ready to use.
11. Access Phase Console
Once DNS propagation is complete and the certificate is issued (which may take a up to tens of minutes), you should be able to access your Phase Console at https://phase.your-domain.com.
12. Create your first account
The Helm chart enables email and password authentication by default (ENABLE_PASSWORD_AUTH), so no additional configuration is needed to sign in:
- Go to
https://phase.your-domain.comand click Create an account. - Enter your name, email address and a password. The first account you create becomes the owner of your organisation.
- Choose an organisation name, then copy or download your recovery kit. The Finish button stays disabled until you do — the recovery kit is the only way to regain access if you forget your
sudopassword, and it cannot be retrieved later.
Because no SMTP server is configured in this guide, email verification is skipped automatically and the account is active immediately. If you do configure SMTP, new users must verify their email before logging in — set SKIP_EMAIL_VERIFICATION to true to keep signup instant.
To run an SSO-only instance instead, configure a provider under sso.providers and set passwordAuth.enabled: false in your values file. Make sure at least one SSO provider works before disabling password auth, or the instance will have no usable sign-in path.
Upgrading
To upgrade your Phase Console deployment:
helm repo update
helm upgrade phase-console phase/phase -f phase-values.yaml
Upgrading to 1.0.0
Version 1.0.0 introduces breaking changes that require attention:
PostgreSQL StatefulSet Migration
PostgreSQL has been changed from a Deployment to a StatefulSet for improved data safety. This changes the PVC naming:
- Old:
<release>-postgres-pvc - New:
postgres-data-<release>-postgres-0
If you have existing data, you'll need to migrate it manually:
# 1. Create a backup before upgrading
kubectl exec -it <postgres-pod> -- pg_dumpall -U postgres > backup.sql
# 2. Upgrade the chart
helm upgrade phase-console phase/phase -f phase-values.yaml
# 3. Restore the data
kubectl exec -i <new-postgres-pod> -- psql -U postgres < backup.sql
File-Based Secrets
Secrets are now mounted as files at /etc/phase/secrets/ with corresponding _FILE environment variables (e.g., DATABASE_PASSWORD_FILE=/etc/phase/secrets/DATABASE_PASSWORD). The Phase Console application already supports this pattern, so no application changes are needed.
Per-Component Secret Isolation
Each component (backend, frontend, worker) now only receives the secrets it needs. For example, the frontend no longer has access to DATABASE_PASSWORD. This is configured in values.yaml under the secrets key.
Uninstalling
To uninstall Phase Console:
helm uninstall phase-console
helm uninstall does not remove everything. The following are intentionally left behind, so that your data and certificate survive a reinstall:
kubectl get pvc # PostgreSQL data — in-cluster database only
kubectl get secret phase-console-secret # your SECRET_KEY / SERVER_SECRET / DB passwords
kubectl get secret phase-console-tls # the issued TLS certificate
kubectl get configmap phase-console-config
Deleting phase-console-secret destroys your SERVER_SECRET, which is required to decrypt existing data. Do not delete it unless you are also discarding the database.
If you ran with both an external PostgreSQL and an external Redis, also delete the ConfigMap before reinstalling — see ConfigMap ownership error below for why:
kubectl delete configmap phase-console-config
Security
Enforcing HTTPS
The Helm chart sets nginx.ingress.kubernetes.io/ssl-redirect: "false" on the ingress by default, so the Console is served over both HTTP and HTTPS and plain HTTP requests are not redirected. TLS is available, but nothing forces clients onto it.
To redirect all HTTP traffic to HTTPS, add these annotations to your phase-values.yaml. Values set under ingress.annotations are applied after the chart's defaults, so they take precedence:
ingress:
enabled: true
className: "nginx"
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
Apply the change and verify that HTTP now redirects:
helm upgrade phase-console phase/phase -f phase-values.yaml
curl -sI http://phase.your-domain.com/ | head -2
# Expected: HTTP/1.1 308 Permanent Redirect
# Location: https://phase.your-domain.com/
Only enable this once your certificate has been issued. Let's Encrypt solves the HTTP-01 challenge over plain HTTP, and force-ssl-redirect applies to the whole host — turning it on before the certificate exists can prevent issuance or renewal. If you use the DNS-01 solver instead, this does not apply.
Client IP Forwarding
By default, the NGINX ingress controller may not forward the real client IP address to the Phase backend. This means audit logs and rate limiting will see the ingress controller's internal pod IP instead of the actual client IP.
Phase's backend reads the client IP from the X-Real-IP header set by the NGINX ingress controller. To ensure this header contains the real client IP (and not a spoofed value), configure the NGINX ingress controller's ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
use-forwarded-headers: "true"
compute-real-ip-from: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" # 👈 Replace with your load balancer / VPC CIDR
Apply the ConfigMap:
kubectl apply -f ingress-nginx-configmap.yaml
The compute-real-ip-from setting defines which source IPs are trusted to send X-Forwarded-For headers. Only headers from IPs matching these CIDRs are trusted — all others are overwritten with the actual TCP source IP, preventing client-side IP spoofing.
Set this to the CIDR range of your load balancer or VPC. For example, if your cluster VPC uses 10.0.0.0/16, set compute-real-ip-from: "10.0.0.0/16".
Troubleshooting
If you encounter issues during or after deployment:
- Check your DNS records and make sure they're pointing to your load balancer public IP address:
dig A phase.your-domain.com
-
If you visit your domain and see a self-signed certificate or errors after DNS propagation:
- Check the cert-manager pod logs in the
cert-managernamespace - Check the logs of the
cm-acme-http-solverpod:
kubectl logs -l acme.cert-manager.io/http01-solver=true -n your-phase-namespace # Replace with the namespace phase is installed inNote that this pod briefly appears to host the Let's Encrypt challenge and terminates itself after completion. If you don't see the pod, the certificate issuance might not be triggering correctly.
- Check the cert-manager pod logs in the
-
Describe the Certificate resource to check its status and events:
kubectl describe certificate phase-console-tls -n your-phase-namespace # Replace with the namespace phase is installed in
-
Check NGINX Ingress controller logs in the
ingress-nginxnamespace. -
Understanding the routing structure:
https://phase.your-domain.com/*routes tohttp://frontend:3000https://phase.your-domain.com/service/*routes tohttp://backend:8000(path is stripped)
The default ingress configuration handles this routing using regex and path rewriting. You can see the implementation in the NGINX ingress template of the Helm chart. If you are using a custom ingress (Traefik, etc), please make sure to replicate the same routing configuration.
-
Check application health endpoints:
- Frontend health check:
curl -v https://phase.your-domain.com/api/health # Expected response: {"status":"alive"}- Backend health check:
curl -v https://phase.your-domain.com/service/health/ # Expected response: {"status": "alive", "version": "x.x.x"} -
If you update any configuration or secrets, you may need to restart the pods to pick up the changes:
kubectl rollout restart deployment/phase-console-frontend kubectl rollout restart deployment/phase-console-backendThis will gracefully restart the pods and ensure they pick up the new configuration or secret values.
-
Installation stuck on migrations job
Database migrations run as a Helm hook job that deletes itself once it succeeds. On a healthy install you will therefore not see it in
kubectl get jobsafter the fact —No resources foundis the normal, expected result and does not indicate a problem.The symptom of a genuinely stuck migration is that your
helm install/helm upgradecommand itself hangs, because Helm waits for the hook to complete. While it is hanging, check the job's pod from a second terminal:kubectl get podsExample output showing a stuck migration:
NAME READY STATUS RESTARTS AGE phase-console-migrations-tfz46 0/1 Init:0/2 0 4m34sThe
Init:0/2status means the job is still waiting on its init containers, which block until Postgres and Redis are reachable. This usually points at database connectivity — a wrongdatabase.host, badDATABASE_PASSWORD, or a firewall/security group blocking the cluster. Check why with:kubectl logs job/phase-console-migrations --all-containersFix: Once you have addressed the underlying cause, delete the stuck job so the install can proceed:
kubectl delete job phase-console-migrations -
ConfigMap ownership error during upgrade
This is most commonly hit after running with both an external Postgres and an external Redis. In that configuration the chart renders
phase-console-configas a Helm hook, and Helm does not track hook resources as part of the release — sohelm uninstallleaves the ConfigMap behind. A later install that renders it as a normal resource (for example, moving Postgres or Redis back in-cluster) then fails, because the orphaned ConfigMap has no release ownership metadata.If you encounter a ConfigMap ownership error when upgrading or reinstalling:
helm upgrade --install phase-console phase/phase -f phase-values.yamlError message:
Error: Unable to continue with install: ConfigMap "phase-console-config" in namespace "default" exists and cannot be imported into the current release: invalid ownership metadata; annotation validation error: missing key "meta.helm.sh/release-name": must be set to "phase-console"; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "default"Fix: Delete the orphaned ConfigMap to allow Helm to create a new one with proper ownership:
kubectl delete configmap phase-console-configThen retry your Helm install or upgrade command.