Deploy on Kubernetes

Vaks PM · Deployment on an existing cluster · Kubernetes · June 2026

Purpose of this guide. This document explains how to install Vaks PM, a multi-user self-hosted project-management application, onto an existing Kubernetes cluster using the provided Helm chart. It is written for a system administrator who knows nothing about this product but is comfortable with Kubernetes and Helm. It lists every prerequisite and the expected initial state, gives two complete example configurations (single-node evaluation and high-availability production), and covers the encryption layers that must be enabled before production. The chart deploys the same container images used by the Docker Swarm installation path described in Deploy on Docker Swarm.
Encryption is not fully on by default. The quick-start values enable HTTPS at the cluster edge and the application encrypts its own field-level secrets, but internal-service TLS and at-rest volume/Secret encryption are cluster controls left off by the quick-start. Before going to production, complete every layer in the Encryption on Kubernetes sections below.

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.

TermMeaning
K8sKubernetes: a container-orchestration platform that schedules, scales and supervises containerized workloads.
HelmKubernetes package manager. A packaged install is a chart; its tunable settings live in a values file.
ChartA bundle of Kubernetes resource templates parameterized by a values file. The Vaks PM chart is at deploy/helm/vaks-pm.
Values fileA YAML file (YAML Ain't Markup Language) supplying the chart's parameters, passed with -f at install time.
IngressThe 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.
PVCPersistentVolumeClaim: a request for durable disk in Kubernetes, so a container's data survives restarts.
StorageClassA named storage profile the cluster uses to satisfy a PVC automatically. A default StorageClass is required if the bundled dependencies are used.
SecretA 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).
CNIContainer Network Interface: the cluster's network plugin (for example Cilium, Calico or Flannel). Some CNIs can encrypt pod-to-pod traffic.
mTLSMutual Transport Layer Security: TLS where both ends present and verify a certificate; used by service meshes to encrypt pod-to-pod traffic.
TLSTransport Layer Security: the encryption layer behind HTTPS.
KMSKey Management Service: a managed key store (for example AWS KMS) that performs key operations without exposing the key material.
HSMHardware Security Module: a tamper-resistant appliance that holds keys and performs cryptographic operations on-premise.
KEKKey Encryption Key: a master key that wraps (encrypts) the data keys. It can be rotated without re-encrypting any data.
DEKData Encryption Key: a per-tenant key that encrypts the data; it is itself wrapped by the KEK.
RPORecovery Point Objective: the maximum acceptable amount of data loss, measured in time, after a failure. Drives backup frequency.
JWTJSON Web Token: a signed session token issued after sign-in.
SMTPSimple Mail Transfer Protocol: the protocol for sending email; used for notifications.
TenantOne 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.

ComponentRoleDeployed
apiNestJS back end: the REST API (prefix /api/v1) plus the real-time WebSocket channel.Always
webServes the React single-page front end (static assets, via nginx).Always
workerBackground job runner (email sending, webhook delivery, audit forwarding, scheduled crons), backed by the Redis queue.Always
controlplaneShared authentication front door that issues a tenant-stamped token. Same image as api, a different role flag.pooled tenancy only
PostgreSQL 17Primary relational database. One database per tenant.Optional (bundle or external)
Redis 7Cache, 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:

PathTarget service
/api/v1/authcontrolplane pooled; otherwise api
/socket.ioapi (real-time WebSocket channel)
/apiapi
/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.

ModeMeaning
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.
pooledSeveral 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

ItemRequirement
Kubernetes clusterVersion 1.24 or newer, in working order.
Namespace rightsAdministrative access to at least one namespace (the examples use vaks-pm).
Ingress controllerA working controller already installed (for example ingress-nginx or Traefik). Its class name is needed at install time.
Default StorageClassRequired if the bundled PostgreSQL / Redis / MinIO are used, since they request PVCs. Not needed when all three dependencies are external.
kubectlInstalled on the workstation and configured to target the cluster (valid kubeconfig).
helmHelm 3.x installed on the workstation.
opensslAvailable on the workstation to generate the random secrets.
Application imagesThe 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

Optional, configurable after install

Never place credentials, passwords or addresses in source control. All sensitive values belong in cluster Secrets or in a values file kept out of version control. For production, supply them through 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.

AspectSINGLE / evaluationHA / production
PurposeEvaluation, demos, disconnected trials.Production for ~200+ users.
App replicas (api/web/worker/controlplane)replicaCount: 1replicaCount: 2 to 3 each.
Pod spreadingNone.Pod anti-affinity via each service's affinity value, so replicas land on different nodes.
PostgreSQLBundled single-node (postgresql.enabled=true) acceptable.External / managed PostgreSQL MANDATORY (postgresql.enabled=false + externalDatabase.*).
RedisBundled single-node (redis.enabled=true) acceptable.External / managed Redis MANDATORY (redis.enabled=false + externalRedis.url).
S3 object storageBundled single-node MinIO (minio.enabled=true) acceptable.External / managed S3 MANDATORY (minio.enabled=false + s3.external.*).
Health probesBuilt into the chart (liveness + readiness on api/web/worker).Same; already present, and relied on for rolling, zero-downtime upgrades.
The bundled PostgreSQL, Redis and MinIO are single-node only. They have no high availability and request a single PVC each. They are intended for evaluation or non-critical use. For any production deployment, the database, cache and object store must be external, highly-available services brought by the organization. Set postgresql.enabled=false, redis.enabled=false and minio.enabled=false, and point the chart at the managed services.
Anti-affinity. Each application service exposes an 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.

ServiceReplicas (default)CPU requestMemory requestCPU limitMemory limit
api2250 m512 Mi11 Gi
web250 m64 Mi250 m256 Mi
worker2100 m256 Mi500 m512 Mi
controlplane (pooled)2250 m512 Mi11 Gi
postgresql (if bundled)1250 m512 Mi11 Gi
redis (if bundled)150 m64 Mi250 m256 Mi
minio (if bundled)1100 m256 Mi500 m512 Mi
Every value is configurable per service through its 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
SecretUsed for
jwtAccessSecret / jwtRefreshSecretSigning the session tokens issued at sign-in. Must be long random values.
mfaEncryptionKeyEncrypting per-user two-factor secrets at rest. Also the default field-level KEK (see Field-level secrets).
workerApiTokenThe 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.
Production: manage all of these, together with the database and storage credentials, in a Kubernetes Secret provisioned out-of-band, and point the chart at it with 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>"
Passwords for bundled dependencies that are not supplied (PostgreSQL, MinIO) are generated automatically and stored in the chart's Secret, which is retained across upgrades and uninstalls so data access is preserved.

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
endpointEmpty → native Amazon S3 (the address is derived from the region). A full URL or host name for MinIO, Ceph, Garage, etc.
port / useSSLUsed when endpoint is a bare host name. useSSL: true (default) selects HTTPS to the store.
forcePathStyleBucket addressing style. Set true for MinIO / Ceph / Garage; false for Amazon S3. Auto-picked if left null.
regionService region (for example eu-west-1).
accessKey / secretKeyAccess credentials. Prefer supplying the secret key via secrets.existingSecret (S3_SECRET_KEY).
bucketPrefixPrefix applied to all buckets. Indispensable on Amazon S3, whose bucket namespace is global; also lets several instances share one store.
createBucketsSet 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/
The 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.
When the health response shows 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.

What the quick-start leaves OFF. The evaluation values protect only field-level application secrets and the external HTTPS edge. Internal-service TLS and at-rest volume/Secret encryption are cluster-level controls the operator must enable. The table below states each layer's default and how to turn it on; the security model behind field-level encryption is described in Security.
LayerProtectsDefault (quick-start)Enable with
Field-level secrets (envelope)SMTP / MFA / SSO secrets in the DBONbuilt-in; custody via crypto.*
External edge TLSbrowser ↔ clusterON (if Ingress TLS set)ingress.tls + certificate
app ↔ PostgreSQL TLSdatabase trafficOFFtls.postgres
app ↔ Redis / S3 TLScache / object trafficOFFrediss:// / s3.external.useSSL
Pod-to-pod networkall in-cluster trafficOFFencrypted CNI / service-mesh mTLS
Data volumes (PVC) at restfull DB / cache / objects on diskOFFencrypted StorageClass
Kubernetes Secrets at restKEK keyring, keys, credentialsOFFdatastore encryption / external store
Plan the OFF rows before building. The first two rows are application/configuration controls that can be enabled at any time. The rows marked OFF are foundational: they depend on the cluster's datastore (Secret encryption), network plugin (pod-to-pod) and storage class (volumes at rest), all established when the cluster and its storage are created. Turning them on afterward means re-laying that foundation, which is disruptive or impossible live. Decide them before building a production cluster.

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.

The default 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:

HopSettingEffect
app ↔ PostgreSQLtls.postgres: trueSets PG_TLS=1. For certificate verification add tls.postgresCaSecret (a Secret with ca.crt) and tls.postgresVerifyMode (verify-full or verify-ca).
app ↔ RedisexternalRedis.url: rediss://…A TLS Redis connection.
app ↔ S3s3.external.useSSL: true + an https endpointA TLS object-storage connection.
The bundled single-node PostgreSQL / Redis / MinIO do not have TLS enabled. Internal-service TLS is meant for managed/external dependencies that terminate TLS, another reason production must use external services. Note: the migration job (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:

An encrypted CNI is the recommended blanket control: it covers every internal hop without per-service TLS configuration. The CNI/backend is chosen at cluster install; changing it on a running cluster is disruptive, so plan it up front.

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:

Without datastore encryption, anyone who can read the cluster datastore (a stolen etcd/SQLite copy, or node root access) can decode every Secret, including the KEK keyring. Enable datastore encryption or an external store before storing real secrets.

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)
Production hardening checklist. Field-level secrets (choose custody) · external edge TLS · internal in-transit (encrypted CNI, or per-service 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.

At a glance. (1) mirror the new image tag into your registry; (2) on a populated database, back up first; (3) 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
Precondition: the database role must own the schema. The push assumes the role in the connection string is allowed to modify the schema — true for the embedded PostgreSQL and for a dedicated database whose owner the chart uses. If you point 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'
Destructive changes and rollback. 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.
Pooled mode — one database per tenant. The hook aligns the database in the connection string, not the whole fleet. For a multi-tenant deployment, every tenant database (<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
Rollback restores the workloads, not the data: if the upgrade applied a schema change, the older image may no longer match the migrated schema. Only roll back across a schema change together with a database restore to the matching point in time.

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
The persistent volumes (PVCs) and the Secret generated by the chart are retained after uninstallation, so the data and generated passwords are not destroyed. Delete them manually if a complete reset is desired.

Parameter reference

The most-used parameters. See deploy/helm/vaks-pm/values.yaml for the exhaustive, commented list.

ParameterDefaultDescription
image.registry""Host of the registry holding the images (chart appends the repository).
image.tagchart appVersionCommon tag for the three images.
imagePullSecrets[]Secrets for a private registry.
publicBaseUrl(required)Public URL of the application (email links and CORS).
tenancy.modededicateddedicated or pooled.
tenancy.baseDomain""Root domain. Required in pooled mode.
api/web/worker.replicaCount2Replica count per service (set 1 for eval, 2 to 3 for HA).
controlplane.replicaCount2Control-plane replicas (pooled only).
api/web/worker.resourcessee SizingCPU/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.enabledtrueDeploy bundled single-node PostgreSQL. Set false for production.
externalDatabase.url""Connection string for an external database.
redis.enabledtrueDeploy bundled single-node Redis. Set false for production.
externalRedis.url""Connection string for an external Redis (use rediss:// for TLS).
minio.enabledfalseDeploy bundled single-node MinIO (eval).
s3.external.endpoint / region / useSSL / forcePathStyle / accessKey / secretKey / bucketPrefix / createBucketssee S3 storageConnection to an external S3 service.
crypto.keysDir/etc/vaks/keysMount 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.providerlocallocal | kms | hsm: KEK custody for new wraps.
crypto.kms.* / crypto.hsm.*KMS (keyId/region/endpoint/existingSecret) or HSM (brokerUrl/keyLabel/existingSecret) settings.
tls.postgresfalseEnable TLS to PostgreSQL (PG_TLS=1).
tls.postgresCaSecret / tls.postgresVerifyMode"" / verify-fullCA Secret (ca.crt) and verification mode for PostgreSQL TLS.
persistence.storageClass (per dependency)""Encrypted StorageClass for at-rest volume encryption.
ingress.enabled / className / hosttrue / "" / ""Ingress toggle, controller class, public host name.
ingress.tls.enabled / secretNametrue / ""Edge TLS toggle and the TLS Secret name.
migrations.enabledtrueRun the schema-migration hook on install/upgrade.
migrations.acceptDataLosstrueAllow prisma db push to apply changes that drop data. Review before enabling on a populated DB.
migrations.seedDemofalseCreate the demo organization and administrator. Leave false in production.

Troubleshooting

SymptomLikely cause & fix
A pod stays PendingInsufficient 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 ImagePullBackOffImage not found or registry unreachable. Check image.registry, image.tag and the required imagePullSecrets.
Health shows db:downThe 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 failsSchema 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 failsS3 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 nodeNo anti-affinity set. Add a podAntiAffinity under api.affinity / web.affinity / worker.affinity (see Single vs HA).

Related: The product · Architecture · Deploy on Docker Swarm · Security · Operations · Demo