Deploy on Kubernetes
Overview
Vaks PM is delivered as a small set of container images and is designed to run entirely on infrastructure the organization controls, with no mandatory dependency on any external online service. The only optional outbound connections are an email relay (for notifications) and a corporate identity provider (for single sign-on).
This guide covers the Kubernetes installation path, which is intended for organizations that already operate a Kubernetes cluster. The other supported path, a guided installer that provisions a Docker Swarm cluster from scratch over SSH, is documented separately in Deploy on Docker Swarm. Both paths run the identical application from the identical images; the choice is driven by existing operational tooling, not by feature differences.
Installation uses Helm, the Kubernetes package manager. A ready-to-use chart (Helm package) ships in the repository at deploy/helm/vaks-pm. The chart deploys all of the stateless application services and, optionally, the three backing dependencies (database, cache, object storage) either inside the cluster (for evaluation) or by pointing at existing external services (the production recommendation).
Glossary
Acronyms and terms used throughout this guide, defined once here for reference.
| Term | Meaning |
|---|---|
| K8s | Kubernetes: a container-orchestration platform that schedules, scales and supervises containerized workloads. |
| Helm | Kubernetes package manager. A packaged install is a chart; its tunable settings live in a values file. |
| Chart | A bundle of Kubernetes resource templates parameterized by a values file. The Vaks PM chart is at deploy/helm/vaks-pm. |
| Values file | A YAML file (YAML Ain't Markup Language) supplying the chart's parameters, passed with -f at install time. |
| Ingress | The Kubernetes object that exposes HTTP/HTTPS services to the outside and routes by host name and path. An Ingress controller must be installed for it to take effect. |
| PVC | PersistentVolumeClaim: a request for durable disk in Kubernetes, so a container's data survives restarts. |
| StorageClass | A named storage profile the cluster uses to satisfy a PVC automatically. A default StorageClass is required if the bundled dependencies are used. |
| Secret | A Kubernetes object holding sensitive values (keys, passwords, connection strings). Base64-encoded in the cluster datastore, not encrypted unless datastore encryption is enabled (see At rest). |
| CNI | Container Network Interface: the cluster's network plugin (for example Cilium, Calico or Flannel). Some CNIs can encrypt pod-to-pod traffic. |
| mTLS | Mutual Transport Layer Security: TLS where both ends present and verify a certificate; used by service meshes to encrypt pod-to-pod traffic. |
| TLS | Transport Layer Security: the encryption layer behind HTTPS. |
| KMS | Key Management Service: a managed key store (for example AWS KMS) that performs key operations without exposing the key material. |
| HSM | Hardware Security Module: a tamper-resistant appliance that holds keys and performs cryptographic operations on-premise. |
| KEK | Key Encryption Key: a master key that wraps (encrypts) the data keys. It can be rotated without re-encrypting any data. |
| DEK | Data Encryption Key: a per-tenant key that encrypts the data; it is itself wrapped by the KEK. |
| RPO | Recovery Point Objective: the maximum acceptable amount of data loss, measured in time, after a failure. Drives backup frequency. |
| JWT | JSON Web Token: a signed session token issued after sign-in. |
| SMTP | Simple Mail Transfer Protocol: the protocol for sending email; used for notifications. |
| Tenant | One isolated organization served by the instance. Each tenant has its own database. |
Deployed architecture
Whichever options are chosen, the running system is the same set of services. The application is stateless where it can be (the api, web and worker services hold no local data and scale horizontally); all durable state lives in three backing stores: PostgreSQL, Redis and the S3 object store.
| Component | Role | Deployed |
|---|---|---|
api | NestJS back end: the REST API (prefix /api/v1) plus the real-time WebSocket channel. | Always |
web | Serves the React single-page front end (static assets, via nginx). | Always |
worker | Background job runner (email sending, webhook delivery, audit forwarding, scheduled crons), backed by the Redis queue. | Always |
controlplane | Shared authentication front door that issues a tenant-stamped token. Same image as api, a different role flag. | pooled tenancy only |
| PostgreSQL 17 | Primary relational database. One database per tenant. | Optional (bundle or external) |
| Redis 7 | Cache, pub/sub, and the background job queue. | Optional (bundle or external) |
| MinIO (S3) | S3-compatible object store for file attachments, branding, templates and audit archives. | Optional (bundle or external) |
A schema-migration job (a Helm hook named vaks-pm-dbpush) runs automatically on every install and upgrade, before the services start, to synchronize the database structure. No manual migration step is required.
Network routing (Ingress)
The chart generates a single Ingress that splits traffic for one host name by request path:
| Path | Target service |
|---|---|
/api/v1/auth | controlplane pooled; otherwise api |
/socket.io | api (real-time WebSocket channel) |
/api | api |
/ | web |
Tenancy modes
The chart supports two tenancy models, selected by tenancy.mode. This is independent of the single-vs-HA decision in the next section.
| Mode | Meaning |
|---|---|
| dedicated (default) | One organization per instance, one domain name. Authentication is handled by the api service; there is no separate control plane. This is the model for an organization deploying the tool for its own use. Choose this if in doubt. |
| pooled | Several organizations ("tenants") share one instance. The organization is resolved from the request subdomain (<slug>.<baseDomain>) and a separate controlplane service issues authentication tokens. Requires a wildcard-domain Ingress and the tenancy.baseDomain parameter. Relevant only for a provider reselling the application as a service. |
Prerequisites & initial state
The lists below describe the expected starting point before any install command is run. The expected initial state is a working Kubernetes cluster on which the administrator has full rights over at least one namespace.
Cluster and workstation
| Item | Requirement |
|---|---|
| Kubernetes cluster | Version 1.24 or newer, in working order. |
| Namespace rights | Administrative access to at least one namespace (the examples use vaks-pm). |
| Ingress controller | A working controller already installed (for example ingress-nginx or Traefik). Its class name is needed at install time. |
| Default StorageClass | Required if the bundled PostgreSQL / Redis / MinIO are used, since they request PVCs. Not needed when all three dependencies are external. |
kubectl | Installed on the workstation and configured to target the cluster (valid kubeconfig). |
helm | Helm 3.x installed on the workstation. |
openssl | Available on the workstation to generate the random secrets. |
| Application images | The three images vaks-pm/api, vaks-pm/web, vaks-pm/worker reachable from a registry the cluster can pull from (with pull credentials if the registry is private). |
Network and certificates
- A public host name for the instance (for example
pm.example.com) and the authority to create a DNS record pointing it to the Ingress controller's entry point. - For production, a TLS certificate for that host name, supplied as an existing Kubernetes Secret or issued automatically by a certificate manager (for example cert-manager).
- Only the standard HTTPS port (443) needs to be reachable from clients; everything else stays inside the cluster.
Optional, configurable after install
- An outbound email relay (SMTP) for notifications.
- A corporate identity provider (for example Microsoft Entra ID) only if single sign-on is required.
- For production: existing PostgreSQL, Redis and S3 services. For evaluation only, the chart can bundle all three in-cluster.
secrets.existingSecret so plaintext never lands in a values file or in Helm release history.Single vs HA · choose the topology
The chart can install in two very different shapes. This choice is independent of the tenancy mode and is the most consequential decision for a production deployment. Read the table carefully: a single install is suitable only for evaluation.
| Aspect | SINGLE / evaluation | HA / production |
|---|---|---|
| Purpose | Evaluation, demos, disconnected trials. | Production for ~200+ users. |
App replicas (api/web/worker/controlplane) | replicaCount: 1 | replicaCount: 2 to 3 each. |
| Pod spreading | None. | Pod anti-affinity via each service's affinity value, so replicas land on different nodes. |
| PostgreSQL | Bundled single-node (postgresql.enabled=true) acceptable. | External / managed PostgreSQL MANDATORY (postgresql.enabled=false + externalDatabase.*). |
| Redis | Bundled single-node (redis.enabled=true) acceptable. | External / managed Redis MANDATORY (redis.enabled=false + externalRedis.url). |
| S3 object storage | Bundled single-node MinIO (minio.enabled=true) acceptable. | External / managed S3 MANDATORY (minio.enabled=false + s3.external.*). |
| Health probes | Built into the chart (liveness + readiness on api/web/worker). | Same; already present, and relied on for rolling, zero-downtime upgrades. |
postgresql.enabled=false, redis.enabled=false and minio.enabled=false, and point the chart at the managed services.affinity value (api.affinity, web.affinity, worker.affinity) that is passed straight through to the pod spec. Set a podAntiAffinity there so that the two or three replicas of a service are scheduled on distinct nodes; otherwise Kubernetes may co-locate them and a single node failure removes the whole service.Sizing
The values below are the chart's default requests (reservations), indicative for a production deployment serving roughly 200 users. Adjust to observed load. The unit m is the processor milli-core (1000 m = 1 vCPU); Mi and Gi are mebibytes and gibibytes of memory.
| Service | Replicas (default) | CPU request | Memory request | CPU limit | Memory limit |
|---|---|---|---|---|---|
api | 2 | 250 m | 512 Mi | 1 | 1 Gi |
web | 2 | 50 m | 64 Mi | 250 m | 256 Mi |
worker | 2 | 100 m | 256 Mi | 500 m | 512 Mi |
controlplane (pooled) | 2 | 250 m | 512 Mi | 1 | 1 Gi |
postgresql (if bundled) | 1 | 250 m | 512 Mi | 1 | 1 Gi |
redis (if bundled) | 1 | 50 m | 64 Mi | 250 m | 256 Mi |
minio (if bundled) | 1 | 100 m | 256 Mi | 500 m | 512 Mi |
resources and replicaCount keys. On a resource-constrained test environment, reduce replicas to 1 and lower the reservations. For ~200 users under sustained load, increase api and worker replicas to 3 and raise their limits before adding more nodes.Images · mirror and pull credentials
Three application images are required: vaks-pm/api, vaks-pm/web and vaks-pm/worker. Mirror them into a registry the cluster can reach, then note that registry's host address (for example registry.example.com). Set the host as image.registry; the chart appends the repository (vaks-pm/api and so on), giving registry.example.com/vaks-pm/api. Do not repeat the vaks-pm segment in image.registry.
If the registry stores the images under different names, override image.api.repository, image.web.repository and image.worker.repository. The three images share one tag (image.tag, defaulting to the chart's appVersion).
Create the namespace and, for a private registry, a pull Secret to reference later via imagePullSecrets:
kubectl create namespace vaks-pm
# Private registry only:
kubectl -n vaks-pm create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=<user> \
--docker-password=<password>
Secrets
The application requires four mandatory secrets. Generate them with strong random values:
openssl rand -hex 32 # jwtAccessSecret
openssl rand -hex 32 # jwtRefreshSecret
openssl rand -base64 32 # mfaEncryptionKey
openssl rand -hex 32 # workerApiToken
| Secret | Used for |
|---|---|
jwtAccessSecret / jwtRefreshSecret | Signing the session tokens issued at sign-in. Must be long random values. |
mfaEncryptionKey | Encrypting per-user two-factor secrets at rest. Also the default field-level KEK (see Field-level secrets). |
workerApiToken | The token the worker uses to call the internal API for callbacks. |
smtpPassword (optional) | Authenticating to the email relay, only when notifications use password auth. |
ssoOidcClientSecret (optional) | The OIDC client secret, only when single sign-on is configured. |
syslogClientKey (optional) | PEM private key for audit-log syslog forwarding over mTLS; mounted into the worker. |
secrets.existingSecret. That Secret must contain the keys DATABASE_URL, REDIS_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, MFA_ENCRYPTION_KEY, WORKER_API_TOKEN, S3_SECRET_KEY, and where applicable SMTP_PASSWORD and SSO_OIDC_CLIENT_SECRET. This keeps plaintext out of the values file and the Helm history.Values files
The configuration lives in a values file passed with -f. Two complete examples follow: Scenario A for production (external dependencies, HA replicas, anti-affinity) and Scenario B for evaluation (bundled dependencies, single replicas).
Scenario A · Production (external PostgreSQL / Redis / S3, HA)
The organization's own Kubernetes, managed database and cache, an existing S3 service, and replicas spread across nodes by anti-affinity. Single-organization (dedicated) mode.
image:
registry: registry.example.com # host only; chart appends vaks-pm/api|web|worker
tag: "1.0.0"
imagePullSecrets:
- name: regcred
publicBaseUrl: https://pm.example.com
tenancy:
mode: dedicated
# --- HA replicas ---
api:
replicaCount: 3
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels: { app.kubernetes.io/component: api }
web:
replicaCount: 2
worker:
replicaCount: 3
# --- Database: managed / external ---
postgresql:
enabled: false
externalDatabase:
url: "postgresql://vakspm:<password>@pg.internal.example.com:5432/vakspm?schema=public"
# --- Cache: managed / external ---
redis:
enabled: false
externalRedis:
url: "redis://redis.internal.example.com:6379"
# --- Object storage: existing S3 service (e.g. MinIO / Ceph / AWS) ---
minio:
enabled: false
s3:
external:
endpoint: "https://s3.internal.example.com:9000"
region: us-east-1
useSSL: true
forcePathStyle: true # true for MinIO/Ceph/Garage
accessKey: "<access-key>"
secretKey: "<secret-key>" # prefer secrets.existingSecret
bucketPrefix: ""
createBuckets: true
ingress:
enabled: true
className: nginx
host: pm.example.com
tls:
enabled: true
secretName: pm-example-tls
# Prefer secrets.existingSecret in production (see Secrets section).
secrets:
jwtAccessSecret: "<openssl rand -hex 32>"
jwtRefreshSecret: "<openssl rand -hex 32>"
mfaEncryptionKey: "<openssl rand -base64 32>"
workerApiToken: "<openssl rand -hex 32>"
Scenario B · Evaluation (fully bundled, single replicas)
All dependencies inside the cluster, suitable for a quick or disconnected setup. Replicas reduced to 1.
image:
registry: registry.example.com # host only; chart appends vaks-pm/api|web|worker
tag: "1.0.0"
publicBaseUrl: https://pm.example.test
tenancy:
mode: dedicated
api: { replicaCount: 1 }
web: { replicaCount: 1 }
worker: { replicaCount: 1 }
postgresql: { enabled: true }
redis: { enabled: true }
minio: { enabled: true }
ingress:
enabled: true
className: nginx
host: pm.example.test
tls: { enabled: false }
# Creates the demo organization and administrator account.
migrations:
seedDemo: true
secrets:
jwtAccessSecret: "<openssl rand -hex 32>"
jwtRefreshSecret: "<openssl rand -hex 32>"
mfaEncryptionKey: "<openssl rand -base64 32>"
workerApiToken: "<openssl rand -hex 32>"
S3 storage
Vaks PM stores binary files (task attachments, branding logos, document templates, project charters and compressed audit archives) in an S3-compatible object store. Any compliant implementation works (Amazon S3, MinIO, Ceph RGW, Garage, NetApp StorageGRID, Wasabi…). The chart translates the storage section into environment variables; only the parameters below change between providers.
Parameter (s3.external.*) | Meaning |
|---|---|
endpoint | Empty → native Amazon S3 (the address is derived from the region). A full URL or host name for MinIO, Ceph, Garage, etc. |
port / useSSL | Used when endpoint is a bare host name. useSSL: true (default) selects HTTPS to the store. |
forcePathStyle | Bucket addressing style. Set true for MinIO / Ceph / Garage; false for Amazon S3. Auto-picked if left null. |
region | Service region (for example eu-west-1). |
accessKey / secretKey | Access credentials. Prefer supplying the secret key via secrets.existingSecret (S3_SECRET_KEY). |
bucketPrefix | Prefix applied to all buckets. Indispensable on Amazon S3, whose bucket namespace is global; also lets several instances share one store. |
createBuckets | Set to false when buckets are pre-created and the account lacks the right to create them. |
The application uses five buckets (names shown before the prefix is applied): charter-blobs, document-templates, vaks-pm-attachments, vaks-pm-branding, vaks-pm-audit-archive. When createBuckets is false, create these five (prefixed) buckets beforehand and grant the service account read, write, delete and list rights on them. At start-up the API logs an S3 backend: … line summarizing the configuration in effect.
Helm install
Before installing, validate the chart rendering against the values file:
helm lint ./deploy/helm/vaks-pm -f values-production.yaml
helm template vaks-pm ./deploy/helm/vaks-pm -f values-production.yaml \
| kubectl apply --dry-run=client -f -
Then install. The --timeout gives the schema-migration job time to complete:
helm install vaks-pm ./deploy/helm/vaks-pm \
-n vaks-pm --create-namespace \
-f values-production.yaml \
--timeout 5m
Wait for the pods to become ready:
kubectl -n vaks-pm get pods
kubectl -n vaks-pm rollout status deploy/vaks-pm-api
Verification
Watch the schema-migration hook while it runs (it auto-deletes on success), then validate each network path. Replace pm.example.com with the configured host name.
# Schema-migration job. It is a Helm hook DELETED automatically on success,
# so watch it while it runs rather than after:
kubectl -n vaks-pm get pods -w # the vaks-pm-dbpush-* pod should reach Completed
# If it FAILS, the job is kept; read its logs:
kubectl -n vaks-pm logs job/vaks-pm-dbpush
# API readiness (expected response: {"status":"ok","db":"up"})
curl -s https://pm.example.com/api/v1/health/ready
# API liveness
curl -s https://pm.example.com/api/v1/health/live
# Home page (expected: HTTP 200)
curl -s -o /dev/null -w "%{http_code}\n" https://pm.example.com/
vaks-pm-dbpush job runs as a post-install/upgrade hook and is removed once it succeeds, so a NotFound when reading its logs after a successful install is expected, not an error. A failed job is retained for inspection.db:up, the home page returns 200, the seeded administrator can sign in, and uploading a task attachment succeeds (this exercises the S3 path end to end), the deployment is functional. If the demo option was enabled, an initial administrator account is created (admin@vaks-pm.local, password ChangeMe123!); change it immediately.Encryption on Kubernetes · overview
A complete deployment has three independent encryption concerns. They are orthogonal (enabling one does not enable the others), and only the first two are addressed by the quick-start values.
- Field-level secrets. Application secrets (SMTP password, multi-factor seeds, single-sign-on client secrets) are encrypted inside the database by the application itself, using envelope encryption (a per-tenant DEK wrapped by a KEK). On by default.
- In transit. Network traffic: browser↔cluster (the external edge), the internal hops to the dependencies (database, cache, object storage), and pod-to-pod traffic.
- At rest. Data on disk: the dependency volumes (PVCs) and the Kubernetes Secrets (which hold the KEK keyring, signing keys and credentials) in the cluster datastore.
| Layer | Protects | Default (quick-start) | Enable with |
|---|---|---|---|
| Field-level secrets (envelope) | SMTP / MFA / SSO secrets in the DB | ON | built-in; custody via crypto.* |
| External edge TLS | browser ↔ cluster | ON (if Ingress TLS set) | ingress.tls + certificate |
| app ↔ PostgreSQL TLS | database traffic | OFF | tls.postgres |
| app ↔ Redis / S3 TLS | cache / object traffic | OFF | rediss:// / s3.external.useSSL |
| Pod-to-pod network | all in-cluster traffic | OFF | encrypted CNI / service-mesh mTLS |
| Data volumes (PVC) at rest | full DB / cache / objects on disk | OFF | encrypted StorageClass |
| Kubernetes Secrets at rest | KEK keyring, keys, credentials | OFF | datastore encryption / external store |
Field-level secrets (envelope encryption)
Sensitive fields are encrypted with a per-tenant DEK that is wrapped by a KEK. This is built in and works out of the box: the KEK defaults to MFA_ENCRYPTION_KEY (registered under the ref env), a path that always works for both encrypt and decrypt. Nothing extra is required for a baseline.
Field-level encryption protects the listed secrets, not the bulk business data (projects, tasks, users). Protecting the full dataset at rest is the job of the volume-encryption layer in At rest.
Custody options (crypto.*)
Two custody models exist; both are portable across the Swarm and Kubernetes paths because the application only reads a mounted keyring directory (crypto.keysDir, default /etc/vaks/keys) and does not care who mounted it.
- Local key ring. Mount a versioned keyring of files named
kek_v<N>(base64, 32 bytes) and pointcrypto.activeKekat the version new writes should use; older keys stay usable to decrypt legacy blobs. Provide the keys inline viacrypto.keks(the chart builds a Secret), or, the recommended choice for production, reference a Secret managed out-of-band withcrypto.existingSecret, mounted read-only intoapi/worker/controlplane. This enables KEK rotation without re-encrypting data. - External KMS / HSM custody. Set
crypto.providertokmsorhsmto move KEK wrap/unwrap off-box, so the master key never resides in the cluster. For KMS, fillcrypto.kms.keyId/region/endpoint(prefer workload identity over static keys; otherwise a Secret viacrypto.kms.existingSecret). For an on-premise HSM, deploy the PKCS#11 key-broker sidecar and setcrypto.hsm.brokerUrl/keyLabelwith the shared token incrypto.hsm.existingSecret. Only the KEK moves off-box; the per-tenant DEK stays local. Migration is online: deploy with the provider set, then re-wrap via Admin → Encryption keys.
crypto.provider: local uses the file keyring (or, if none, MFA_ENCRYPTION_KEY). For separation of duties in a regulated deployment, move custody to a KMS or HSM. The model and its DORA control mapping are described in Security.In transit
External edge (Ingress)
Terminate HTTPS at the Ingress by supplying a certificate as an existing Kubernetes Secret, or by letting a certificate manager issue one:
ingress:
enabled: true
className: nginx # or traefik
host: pm.example.com
tls:
enabled: true
secretName: pm-example-tls # cert-manager or supplied
Internal services (database, cache, object storage)
These hops carry the application's data to its dependencies and are plaintext unless enabled. Each is a separate setting:
| Hop | Setting | Effect |
|---|---|---|
| app ↔ PostgreSQL | tls.postgres: true | Sets PG_TLS=1. For certificate verification add tls.postgresCaSecret (a Secret with ca.crt) and tls.postgresVerifyMode (verify-full or verify-ca). |
| app ↔ Redis | externalRedis.url: rediss://… | A TLS Redis connection. |
| app ↔ S3 | s3.external.useSSL: true + an https endpoint | A TLS object-storage connection. |
prisma db push) does not read PG_TLS; if the database mandates TLS, also append ?sslmode=require to the DATABASE_URL.Pod-to-pod network
The simplest way to encrypt all in-cluster traffic at once, including the database, cache, object-storage and any KMS/HSM-broker hops, is to encrypt the pod network itself rather than each service individually:
- Encrypted CNI. For example a WireGuard backend (such as k3s
--flannel-backend=wireguard-native), or Cilium / Calico with encryption enabled. This encrypts node-to-node pod traffic without any change to the application. - Service-mesh mTLS. A mesh (Istio, Linkerd) enforces mutual TLS between pods.
At rest
Data volumes (PVCs)
The database, cache and object-storage volumes hold the full dataset (not just the field-level secrets). Encrypt them with an encrypted StorageClass; only the volumes are encrypted, never the node OS:
postgresql:
persistence:
storageClass: gp3-encrypted # a cloud-KMS or CSI-encrypted class
# do the same for redis.persistence.storageClass / minio.persistence.storageClass,
# or use managed services with provider-side encryption
Options: a cloud provider's encrypted block-storage class (KMS-backed), a Container Storage Interface (CSI) driver with encryption, or nodes whose disks are encrypted (for example LUKS, Linux Unified Key Setup). When the dependencies are external/managed, use the provider's encryption (RDS encryption, S3 server-side encryption, and so on) instead.
Kubernetes Secrets
The KEK keyring, the signing keys and the database/storage credentials live in Kubernetes Secrets. A Secret is only base64-encoded in the cluster datastore, not encrypted by default. Protect it with one of:
- Datastore encryption at rest. An
EncryptionConfigurationwith anaescbcor (better) akmsprovider on the API server. On k3s this is the built-ink3s secrets-encrypt enable, which requires the embedded etcd datastore (install with--cluster-init; it has no effect on the default SQLite datastore). - External secret store. The External Secrets Operator, a CSI secrets driver, or a sealed-Secret controller, so the key material is sourced from a vault rather than stored in the datastore. This pairs with
secrets.existingSecretandcrypto.existingSecret. - Off-box KEK custody. Moving the KEK to a KMS/HSM (see Field-level secrets) means the most sensitive key is never in a Kubernetes Secret at all.
Encrypted HA example
This consolidated values file ties the production HA shape together with every application-level encryption control enabled. It still relies on cluster-level controls (encrypted CNI, datastore Secret encryption) that are configured outside Helm, as noted in the comments.
image:
registry: registry.example.com
tag: "1.0.0"
imagePullSecrets:
- name: regcred
publicBaseUrl: https://pm.example.com
tenancy:
mode: dedicated
# --- HA replicas + anti-affinity ---
api: { replicaCount: 3 }
web: { replicaCount: 2 }
worker: { replicaCount: 3 }
# --- External, managed, TLS-terminating dependencies ---
postgresql:
enabled: false
externalDatabase:
url: "postgresql://vakspm:<password>@pg.internal.example.com:5432/vakspm?schema=public&sslmode=require"
redis:
enabled: false
externalRedis:
url: "rediss://redis.internal.example.com:6379" # rediss:// = TLS
minio:
enabled: false
s3:
external:
endpoint: "https://s3.internal.example.com:9000"
region: us-east-1
useSSL: true # TLS to object storage
forcePathStyle: true
accessKey: "<access-key>"
secretKey: "<secret-key>"
createBuckets: true
# --- In-transit TLS to PostgreSQL with CA verification ---
tls:
postgres: true # PG_TLS=1
postgresCaSecret: pg-ca # Secret holding ca.crt
postgresVerifyMode: verify-full
# --- External edge TLS ---
ingress:
enabled: true
className: nginx
host: pm.example.com
tls:
enabled: true
secretName: pm-example-tls
# --- Field-level KEK: versioned keyring with rotation ---
crypto:
existingSecret: vaks-pm-keyring # holds kek_v1, kek_v2, …
activeKek: v2 # new writes wrap with kek_v2
# --- All sensitive values from an out-of-band Secret ---
secrets:
existingSecret: vaks-pm-secrets
# Cluster-level controls configured OUTSIDE Helm (plan before building):
# * encrypted CNI (e.g. WireGuard) for pod-to-pod traffic
# * datastore Secret encryption (EncryptionConfiguration / k3s secrets-encrypt)
# * encrypted StorageClass for any in-cluster PVCs (none here: all deps external)
tls.postgres + rediss:// + S3 useSSL) · data volumes at rest (encrypted StorageClass or managed-service encryption) · Kubernetes Secrets at rest (datastore encryption or external store) · key custody (move the KEK to a KMS/HSM for separation of duties).Upgrade
An upgrade points the release at a newer image tag and re-applies the chart. The customer never builds or edits code — only the tag moves. Because the application services are stateless and run several replicas, Kubernetes performs a rolling update (new pods must pass their readiness probe before old pods are retired), so the operation is zero-downtime. The database schema travels with the image and is applied by a Helm hook Job, so there is no separate SQL migration pack to run in the routine case.
helm upgrade with the new image.tag — the vaks-pm-dbpush hook aligns the schema before the app pods roll; (4) verify health. The detailed steps follow.1 · Mirror the new image tag
The chart pulls the three images (api, web, worker) from the registry named in image.registry at the tag in image.tag. Mirror the new version into that registry first (see Images · mirror and pull credentials), so every node can pull it. Pin a unique version tag (e.g. 1.4.0) rather than a moving tag, so the rolled-out revision is reproducible.
2 · Run the upgrade
Set the new tag and re-apply the same values file. The --timeout gives the schema-migration hook room to complete before Helm rolls the app pods:
helm upgrade vaks-pm ./deploy/helm/vaks-pm \
-n vaks-pm -f values-production.yaml \
--set image.tag=1.4.0 --timeout 5m
kubectl -n vaks-pm rollout status deploy/vaks-pm-api # new pods Ready
kubectl -n vaks-pm rollout status deploy/vaks-pm-web
Helm runs the vaks-pm-dbpush hook (below) as a pre-upgrade step, so the schema is aligned before the new api pods start. If the hook fails, the upgrade stops and the running pods keep serving the old version.
3 · Database schema
The schema travels in the image. The chart ships a Helm hook Job, vaks-pm-dbpush, that runs prisma db push on install and upgrade (gated by migrations.enabled, on by default). It auto-deletes on success and is retained on failure for inspection — so for a routine upgrade there is no manual migration step.
# watch the hook while it runs (it is deleted on success):
kubectl -n vaks-pm get pods -w # vaks-pm-dbpush-* should reach Completed
kubectl -n vaks-pm logs job/vaks-pm-dbpush # only present if it FAILED
externalDatabase.url at a least-privilege role with no DDL rights on the public schema, the hook fails (permission denied for schema public / must be owner of table …). In that case, apply the schema change with a DDL-capable role first, then run the upgrade with migrations.enabled=false:
kubectl -n vaks-pm run dbpush --rm -it --restart=Never \
--image=<registry>/vaks-pm/api:1.4.0 \
--env DATABASE_URL='postgresql://<owner>:…@<host>:5432/<db>' \
-- sh -c 'cd /app/api && npx prisma db push --accept-data-loss'prisma db push is forward-only: there is no reverse migration, and some type or constraint changes can drop data. migrations.acceptDataLoss (default true) lets the hook apply such changes without prompting — review it before an upgrade on a populated database, and take a backup first (see Operations). helm rollback reverts the workloads to the previous image tag, but it does not undo schema changes; rolling the schema back means restoring from backup.<dbName>_<slug>) must receive the schema change with a DDL-capable role before the app pods roll.4 · Rollback
To revert the workloads to the previous release (image tag, values, and manifests):
helm history vaks-pm -n vaks-pm # find the previous good revision
helm rollback vaks-pm <revision> -n vaks-pm --timeout 5m
5 · Verify after the upgrade
kubectl -n vaks-pm get pods # all Running on the new image
curl -s https://pm.example.com/api/v1/health/ready # {"status":"ok","db":"up"}
All app pods Running on the new tag, the migration hook Completed, sign-in works, and an attachment upload succeeds (exercises the S3 path). Adding an organization (pooled mode) is not an upgrade: provision a new tenant with its own database and host name; existing tenants are undisturbed.
Uninstall
helm uninstall vaks-pm -n vaks-pm
Parameter reference
The most-used parameters. See deploy/helm/vaks-pm/values.yaml for the exhaustive, commented list.
| Parameter | Default | Description |
|---|---|---|
image.registry | "" | Host of the registry holding the images (chart appends the repository). |
image.tag | chart appVersion | Common tag for the three images. |
imagePullSecrets | [] | Secrets for a private registry. |
publicBaseUrl | (required) | Public URL of the application (email links and CORS). |
tenancy.mode | dedicated | dedicated or pooled. |
tenancy.baseDomain | "" | Root domain. Required in pooled mode. |
api/web/worker.replicaCount | 2 | Replica count per service (set 1 for eval, 2 to 3 for HA). |
controlplane.replicaCount | 2 | Control-plane replicas (pooled only). |
api/web/worker.resources | see Sizing | CPU/memory requests and limits per service. |
api/web/worker.affinity | {} | Pod-spec affinity passthrough; set anti-affinity for HA spreading. |
secrets.existingSecret | "" | Name of an existing Secret providing the sensitive keys. Recommended in production. |
secrets.jwtAccessSecret / jwtRefreshSecret | "" | JWT signing keys (when not using existingSecret). |
secrets.mfaEncryptionKey | "" | MFA at-rest key; also the default field-level KEK. |
secrets.workerApiToken | "" | Token used by the worker to call the internal API. |
secrets.smtpPassword / ssoOidcClientSecret / syslogClientKey | "" | Optional: email auth, OIDC client secret, audit syslog mTLS key. |
postgresql.enabled | true | Deploy bundled single-node PostgreSQL. Set false for production. |
externalDatabase.url | "" | Connection string for an external database. |
redis.enabled | true | Deploy bundled single-node Redis. Set false for production. |
externalRedis.url | "" | Connection string for an external Redis (use rediss:// for TLS). |
minio.enabled | false | Deploy bundled single-node MinIO (eval). |
s3.external.endpoint / region / useSSL / forcePathStyle / accessKey / secretKey / bucketPrefix / createBuckets | see S3 storage | Connection to an external S3 service. |
crypto.keysDir | /etc/vaks/keys | Mount path of the field-level keyring (CRYPTO_KEYS_DIR). |
crypto.activeKek | "" | KEK version new writes wrap with (auto if empty). |
crypto.keks / crypto.existingSecret | {} / "" | Inline keyring entries, or an existing Secret of kek_v<N> keys. |
crypto.provider | local | local | kms | hsm: KEK custody for new wraps. |
crypto.kms.* / crypto.hsm.* | — | KMS (keyId/region/endpoint/existingSecret) or HSM (brokerUrl/keyLabel/existingSecret) settings. |
tls.postgres | false | Enable TLS to PostgreSQL (PG_TLS=1). |
tls.postgresCaSecret / tls.postgresVerifyMode | "" / verify-full | CA Secret (ca.crt) and verification mode for PostgreSQL TLS. |
persistence.storageClass (per dependency) | "" | Encrypted StorageClass for at-rest volume encryption. |
ingress.enabled / className / host | true / "" / "" | Ingress toggle, controller class, public host name. |
ingress.tls.enabled / secretName | true / "" | Edge TLS toggle and the TLS Secret name. |
migrations.enabled | true | Run the schema-migration hook on install/upgrade. |
migrations.acceptDataLoss | true | Allow prisma db push to apply changes that drop data. Review before enabling on a populated DB. |
migrations.seedDemo | false | Create the demo organization and administrator. Leave false in production. |
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
A pod stays Pending | Insufficient node resources, or no default StorageClass to satisfy a PVC. Inspect with kubectl -n vaks-pm describe pod <name> and kubectl get storageclass. |
A pod in ImagePullBackOff | Image not found or registry unreachable. Check image.registry, image.tag and the required imagePullSecrets. |
Health shows db:down | The API cannot reach the database. Check externalDatabase.url (or the postgresql pod) and network connectivity from the namespace. If the DB mandates TLS, append ?sslmode=require to the URL. |
The vaks-pm-dbpush job fails | Schema migration could not be applied. Read kubectl -n vaks-pm logs job/vaks-pm-dbpush; usually the database is unreachable at start-up (the job retries). |
| Attachment upload or report generation fails | S3 misconfigured. Check s3.external.*, that the five buckets exist (or createBuckets=true), and the access account's rights. The API logs an S3 backend: … line at start-up. |
| Replicas all on one node | No anti-affinity set. Add a podAntiAffinity under api.affinity / web.affinity / worker.affinity (see Single vs HA). |