Deploying TestPlanIt on Kubernetes with Helm
This guide deploys a single-tenant, self-hosted TestPlanIt instance to a Kubernetes cluster (Amazon EKS, GKE, AKS, or k3s) using the official Helm chart. It is the recommended path for organizations that already run their internal tools on Kubernetes.
The chart lives in the repository at testplanit/helm/testplanit.
What gets deployed
- Web application — the Next.js server (the UI and API).
- Background workers — BullMQ job processors plus the scheduler.
- Migrate/seed job — a Helm hook that applies migrations, audit triggers, and PostgreSQL extensions, then seeds the initial admin. It is the single authoritative schema owner; the app and worker pods wait for it and never migrate on boot.
- Bundled datastores (optional, on by default) — PostgreSQL, Valkey, Elasticsearch, and MinIO, so a first install works out of the box. Each can be turned off and pointed at a managed service (Amazon RDS / ElastiCache / OpenSearch / S3).
Prerequisites
- Kubernetes ≥ 1.25 and Helm ≥ 3.8
kubectlandhelmconfigured against your cluster- An ingress controller — the default annotations target
ingress-nginx; or set
ingress.enabled=falseand expose the Service yourself - A default
StorageClass(for the bundled datastores) — or use managed services
Images
The chart uses the official public images by default — no build step. They
are domain-agnostic and multi-arch (linux/amd64 + linux/arm64), so they run
unmodified on standard x86 EKS/GKE/AKS nodegroups and on arm64 / Graviton:
ghcr.io/testplanit/testplanit-selfhost:latest # server
ghcr.io/testplanit/testplanit-selfhost:latest-workers # workers
Pin image.tag to a released version (e.g. 1.0.0) for reproducible deploys
rather than the moving latest.
Prefer to build & push your own?
git clone https://github.com/testplanit/testplanit.git
cd testplanit/testplanit
docker buildx bake -f docker-bake.hcl --push selfhost \
--set "*.args.VERSION=1.0.0" \
--set "production-selfhost.tags=<registry>/testplanit:1.0.0" \
--set "workers-selfhost.tags=<registry>/testplanit:1.0.0-workers"
The selfhost targets build with SELF_HOSTED=true (Next image optimizer off,
so no domain is baked in) and multi-arch. Then set image.repository /
image.tag. For a private registry, set image.pullSecrets (on EKS with ECR,
attach an ECR pull policy to the node role or use a pull secret).
Step 1: Minimal values
Create my-values.yaml:
# Public HTTPS URL you serve TestPlanIt on. Sets NEXTAUTH_URL.
appUrl: https://testplanit.example.com
ingress:
host: testplanit.example.com
tls:
enabled: true
secretName: testplanit-tls # a cert for the host (e.g. from cert-manager)
secrets:
adminPassword: "change-me" # initial admin login
encryptionKey: "" # STRONGLY recommended — set & back up (see below)
# image: # optional — defaults to the public image
# tag: "1.0.0" # pin instead of latest
The notification bell and live milestone / test-run updates use long-lived SSE
streams. The chart's default ingress annotations do not disable proxy buffering, so
for these to work reliably override ingress.annotations — see
SSE Notifications and Live Updates.
Step 2: Install
helm install testplanit ./testplanit/helm/testplanit \
--namespace testplanit --create-namespace \
-f my-values.yaml
Watch the rollout:
# Migrate/seed job first
kubectl -n testplanit logs -l app.kubernetes.io/component=migrate --tail=100 -f
# Then the app + workers
kubectl -n testplanit rollout status deploy/testplanit
kubectl -n testplanit get pods
Sign in at your appUrl as [email protected] (change via config.admin.email)
with the password from secrets.adminPassword.
Private registry pull secret
kubectl -n testplanit create secret docker-registry regcred \
--docker-server=<registry> --docker-username=<user> --docker-password=<pass>
image:
pullSecrets:
- name: regcred
Step 3: Use managed datastores (production)
For anything beyond evaluation, disable the bundled datastores and point at managed services. The bundled datastores are single-replica and intended for quick starts and small installs.
postgresql:
enabled: false
external:
# If a transaction-mode pooler (RDS Proxy / pgbouncer) fronts the URL above,
# append ?pgbouncer=true to it and provide a direct (non-pooled) URL that the
# schema-sync/migrate step uses:
redis:
enabled: false
external:
url: "valkey://my-elasticache.cache.amazonaws.com:6379"
elasticsearch:
enabled: false
external:
node: "https://my-opensearch.es.amazonaws.com:9200" # empty string disables search
objectStorage:
minio:
enabled: false
external:
region: us-east-1
bucket: my-testplanit-bucket
# Omit both keys to use pod IAM (IRSA) instead of static credentials:
accessKeyId: "..."
secretAccessKey: "..."
See External Database Deployment for the PostgreSQL ownership/permission setup, and Horizontal Read Scaling to add read replicas.
S3 access without static keys (EKS / IRSA)
Attach an IAM role to the chart's ServiceAccount and leave the S3 keys empty:
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/testplanit-s3
objectStorage:
minio:
enabled: false
external:
region: us-east-1
bucket: my-testplanit-bucket
accessKeyId: "" # empty -> use the pod's IAM role
secretAccessKey: ""
The ENCRYPTION_KEY
ENCRYPTION_KEY encrypts integration credentials and API tokens at rest. When
you don't set it, the chart generates one on first install and preserves it
across helm upgrade. It must never change — if it is lost or rotated, all
encrypted data becomes unreadable.
For any real deployment, set it explicitly and back it up. Generate one with:
openssl rand -hex 32
Then set secrets.encryptionKey (or supply it via secrets.existingSecret). To
read the auto-generated value:
kubectl -n testplanit get secret testplanit-env \
-o jsonpath='{.data.ENCRYPTION_KEY}' | base64 -d
Secrets management (Vault / External Secrets / SOPS)
To manage secrets outside the chart, set secrets.existingSecret to the name of
a Secret you control. The chart then creates no Secret and reads every secret env
var from yours. Required keys: NEXTAUTH_SECRET, ENCRYPTION_KEY,
DATABASE_URL, VALKEY_URL, ADMIN_PASSWORD; when the matching bundled
datastore is enabled, also POSTGRES_PASSWORD, MINIO_ROOT_USER,
MINIO_ROOT_PASSWORD; and as needed DIRECT_DATABASE_URL, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, EMAIL_SERVER_PASSWORD.
Email (SMTP)
config:
email:
host: smtp.example.com
port: "587"
secrets:
emailPassword: "smtp-password"
Upgrades
Build and push a new image tag, then:
helm upgrade testplanit ./testplanit/helm/testplanit \
--namespace testplanit -f my-values.yaml \
--set image.tag=1.1.0
The migrate/seed job re-runs on every upgrade (idempotent) and applies any new migrations before the new app pods take traffic. The web tier rolls with zero downtime (surge-up + a 15s pre-stop drain behind ingress-nginx).
Scaling
- Web tier — increase
server.replicaCount(a PodDisruptionBudget is created automatically at ≥ 2). Size withserver.resources. - Workers — keep
workers.replicaCountat 1: the workers pod also registers the scheduled/repeatable jobs, so multiple replicas would double-register cron work. Give it more headroom withworkers.resources.
Uninstall
helm uninstall testplanit --namespace testplanit
PersistentVolumeClaims from the bundled datastores are retained by default — delete them explicitly to remove the data:
kubectl -n testplanit delete pvc -l app.kubernetes.io/instance=testplanit
Troubleshooting
- App pods stuck in
Init— thewait-for-dbinit container blocks until the database is reachable and the schema exists. Check the migrate job:kubectl -n testplanit logs -l app.kubernetes.io/component=migrate. ImagePullBackOff— the cluster can't pull your image. Verify the tag and setimage.pullSecretsfor a private registry.- Health —
GET /api/healthreports per-dependency status (database is required; redis / elasticsearch / storage degrade gracefully):kubectl -n testplanit port-forward svc/testplanit 3000:3000thencurl -s localhost:3000/api/health | jq. - Event-loop lag — the same response carries an
eventLoopobject withp50/p99/maxlag in milliseconds. The app serves every request from a single JS thread, so this is the capacity signal container CPU% cannot show — a risingp99means the thread is saturated even while CPU looks healthy. It is reported for monitoring only and never changes the overallstatus, so it will not flap your liveness probe.
For the Docker Compose deployment, see Deployment.