Data Observability platform Help

Kubernetes deployment

SelectZero runs on Kubernetes as a single pod. This page is a complete runbook: follow it top to bottom and you have a working instance.

The recommended way to install is the Helm chart. It validates your settings before anything is created, and upgrades are one command. Where Helm is not an option, the raw manifests further down describe exactly the same deployment with every setting explained.

Everything here applies to any Kubernetes distribution. The examples name Amazon EKS because that is where the S3 storage mode is most often used.

Memory

Set the container's memory limit and keep JAVA_OPTIONS at -XX:MaxRAMPercentage=50.0 (the chart's default): the query engine behind comparisons and file connections uses memory outside the Java heap, in the other half. A 4Gi limit runs a test suite with three comparisons in parallel; the chart's 8Gi limit leaves room for several. Before the limit is reached, SelectZero first hands back unused Java heap, then stops the query engine work using the most memory, so that test case ends with memory usage too high instead of the pod being OOM-killed. See Memory sizing.

Choose a storage variant

Durable files are the execution results, test suite reports, uploaded data source files, JDBC drivers, connector certificates and the custom logo. They have to outlive the pod, so pick where they live before you deploy.

S3 storage mode

Persistent volume

Durable files in

An S3 bucket

A PersistentVolumeClaim (EBS, EFS, Azure Disk, GCE PD)

Volumes on the pod

emptyDir only, no PVC

One PVC

Application database

External PostgreSQL, mandatory

PostgreSQL recommended, SQLite possible

Suits

A cluster policy of "no persistent volumes"

Everything else, and the simplest setup

Configuration

SZ_STORAGE_MODE=s3 plus the bucket variables

Nothing extra

Chart value

storage.mode: s3

storage.mode: pvc (the default)

Both keep the state outside the cluster in the sense that matters: a PersistentVolumeClaim is a mount declaration, and the data sits in the storage service behind it. The difference is which service, and whether a volume object exists at all.

If you accept a file share but not a block volume, use the persistent volume variant with Amazon EFS. It needs no application configuration and is covered under Persistent volume variant.

Prerequisites

For both variants:

  • A Kubernetes cluster and kubectl access to it.

  • Nodes with the amd64 (x86_64) architecture. The SelectZero image is published for amd64 only, so it does not start on arm64 nodes such as AWS Graviton; the usual symptom is exec format error. In a cluster with both kinds of node, pin the pod to amd64 with nodeSelector: {kubernetes.io/arch: amd64}, which is the chart's nodeSelector value.

  • Access to Docker Hub, or a mirror of it. The nodes pull the image, and whatever runs helm pulls the chart, over HTTPS from three hosts: registry-1.docker.io, auth.docker.io and production.cloudfront.docker.com. The last one serves the actual downloads, so allowing only the first two lets a pull start and then time out. In a cluster without internet access, mirror selectzero/selectzero (the image, linux/amd64) and selectzero/selectzero-helm (the chart) into your own registry, or use a pull-through cache of Docker Hub, and install from there.

  • Network reachability from the pod to your application database, to the data sources you monitor, and, in S3 mode, to the S3 endpoint.

  • An ingress controller, load balancer or reverse proxy of your own. SelectZero does not ship one. See Reverse proxy for the headers it expects.

For S3 storage mode additionally:

  • External PostgreSQL. SQLite is not supported in S3 mode. Only the uploaded files, synced SharePoint workbooks, drivers, images and certificates are mirrored to the bucket; the SQLite database file itself is not, so on an emptyDir it would be lost at every restart.

  • A master encryption key supplied from outside the pod. In S3 mode the application refuses to start without SZ_ENCRYPTION_KEY, because a key generated inside the pod would die with it and leave every object in the bucket permanently unreadable. The reason is one line on standard output; see When the pod does not start.

  • An S3 bucket and an IAM identity that can read and write it.

For the persistent volume variant additionally:

Install with Helm (recommended)

The chart is published as an OCI artifact alongside the application image. There is no repository to add.

helm install selectzero oci://registry-1.docker.io/selectzero/selectzero-helm \ --version 2026.10.0 -n YOUR-NAMESPACE --create-namespace -f values.yaml

Run helm show values oci://registry-1.docker.io/selectzero/selectzero-helm for the complete list of values. Every one of them is commented.

Values for S3 storage mode

Prepare the bucket, the lifecycle rules and the IAM identity first: the chart does not create them.

storage: mode: s3 s3: bucket: YOUR-BUCKET-NAME region: YOUR-BUCKET-REGION serviceAccount: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/selectzero-s3 database: jdbcUrl: jdbc:postgresql://YOUR-DATABASE-HOST:5432/selectzero user: selectzero password: REPLACE_WITH_THE_DATABASE_PASSWORD sslMode: SSL encryption: key: REPLACE_WITH_A_LONG_RANDOM_STRING

PostgreSQL and an encryption key are both mandatory in this mode, and the chart refuses to install without them rather than letting the pod start into a state that loses data. The key may hold an aws-sm:// reference instead of the key itself.

If workload identity is impossible, set storage.s3.staticCredentials.enabled and supply accessKey and secretKey. Prefer the role.

Values for a persistent volume

This is the default mode, so the smallest useful values file is the storage class and a database.

storage: mode: pvc pvc: size: 20Gi storageClass: gp3 # see the per-cloud claims under the persistent volume variant database: jdbcUrl: jdbc:postgresql://YOUR-DATABASE-HOST:5432/selectzero user: selectzero password: REPLACE_WITH_THE_DATABASE_PASSWORD encryption: key: REPLACE_WITH_A_LONG_RANDOM_STRING

Leaving database.jdbcUrl out uses the embedded SQLite database on the volume, and leaving encryption.key out generates a key on the volume at first start. Both are acceptable here and neither is in S3 mode.

To use a claim you provisioned yourself, set storage.pvc.existingClaim and the chart creates none. The claim it does create carries helm.sh/resource-policy: keep, so helm uninstall leaves your data alone; set storage.pvc.keepOnUninstall: false if you would rather it were deleted with the release.

Bring your own Secret

By default the chart creates a Secret from the values above, which means those values sit in your values file. To keep them somewhere else, create the Secret yourself and name it:

existingSecret: selectzero-secrets

The keys are named after the environment variables they become: SZ_ENCRYPTION_KEY, SZ_DATABASE_USER, SZ_DATABASE_PASSWORD, SZ_STORAGE_S3_ACCESS_KEY and SZ_STORAGE_S3_SECRET_KEY. Only the keys your other values actually reference have to exist. These are the same names the Secret manifest below uses, so the Secret you already have works unchanged.

What the chart does not install

No Ingress, no Gateway API route, no TLS certificate, no cert-manager wiring, no PostgreSQL and no monitoring. The Service is a ClusterIP. Putting a reverse proxy in front of it is yours to do.

  • X-Forwarded-For and X-Forwarded-Proto are already honoured, so a proxy that sets them needs no application configuration. See Reverse proxy.

  • When TLS terminates upstream, run with the secure-session profile so session cookies are marked secure:

config: springProfilesActive: "on-prem,secure-session"
  • Ship your own Ingress, NetworkPolicy or anything else through extraObjects rather than forking the chart. A string entry is passed through the template engine, so it can reference the release's own names:

extraObjects: - | apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "selectzero.fullname" . }} spec: rules: - host: selectzero.example.com http: paths: - path: / pathType: Prefix backend: service: name: {{ include "selectzero.fullname" . }} port: number: {{ .Values.service.port }}

There are matching extraEnv, extraEnvFrom, extraVolumes, extraVolumeMounts, extraInitContainers and extraContainers values for the pod itself.

Reaching it without an ingress

The Service is a ClusterIP by default, reachable only inside the cluster. For an evaluation, or a cluster with no ingress controller, expose it on every node instead:

service: type: NodePort nodePort: 30090 # optional, 30000-32767; leave it out and Kubernetes picks one

SelectZero is then at http://ANY-NODE-IP:30090. LoadBalancer works the same way where your cluster provides load balancers. Both serve plain HTTP straight to the pod, so sign-in credentials cross the network unencrypted: keep this to trusted networks, or put a TLS-terminating load balancer in front. Do not combine plain HTTP with the secure-session profile, because a browser does not send secure cookies over HTTP and sign-in stops working.

Moving from the raw manifests to the chart

The chart renders the same objects the manifests below describe, under the same names when the release is called selectzero. Keep the Secret and the claim, delete the rest, and install with both referenced:

kubectl delete -n YOUR-NAMESPACE deployment/selectzero service/selectzero serviceaccount/selectzero helm install selectzero oci://registry-1.docker.io/selectzero/selectzero-helm \ --version 2026.10.0 -n YOUR-NAMESPACE -f values.yaml
existingSecret: selectzero-secrets storage: mode: pvc pvc: existingClaim: selectzero-storage-claim

S3 storage mode on Amazon EKS

For the concepts behind this mode, including what is encrypted and by whom, see S3 storage mode. This section is the deployment procedure.

Bucket configuration

Create the bucket in the same region as the cluster, then:

  1. Block all public access. The bucket holds customer data. Nothing about SelectZero needs it to be reachable from the internet.

  2. Set default encryption. Every S3 bucket encrypts at rest with SSE-S3 by default. For a key you control and can rotate, configure SSE-KMS with a customer-managed key and enable S3 Bucket Keys, because SelectZero writes many small objects and Bucket Keys cut the KMS request cost. With SSE-KMS the pod's IAM identity also needs kms:GenerateDataKey and kms:Decrypt on that key. Do not add a bucket policy that denies uploads without an explicit x-amz-server-side-encryption header: SelectZero relies on bucket default encryption and sends no such header. See Security model with file encryption disabled for how to verify this per object.

  3. Versioning is optional. If you enable it, add a noncurrent version expiration rule. Application deletes become delete markers, so without one the retention sweep stops freeing space.

Lifecycle rules

SelectZero prunes its own result and report objects from the bucket every 12 hours, using the retention settings on the General tab of Settings. Lifecycle rules are therefore a backstop for objects left behind by an interrupted run, not the primary mechanism.

Key prefix

Application setting

Default

Suggested expiration

reports/results/

CSV file history (days)

29

The setting plus 7 days

reports/sample-data/

Sample data history (days)

29 (upgrades inherit CSV file history)

The setting plus 7 days; nothing is stored at 0

reports/testSuites/

Test suite report file history (days)

29

The setting plus 7 days

database/, drivers/, keys/

none

Never expire

whole bucket

Abort incomplete multipart uploads after 7 days

Never expire database/, drivers/ or keys/. Those prefixes hold the uploaded data source files, the JDBC drivers and the connector certificates, and nothing recreates them.

If you set SZ_STORAGE_S3_PREFIX, include that prefix in the lifecycle rule filters.

Identity: IRSA or static keys

Give the pod an IAM role rather than access keys. On EKS that is IAM Roles for Service Accounts (IRSA) or EKS Pod Identity; both are picked up by the AWS default credential chain with no application configuration.

The role needs these permissions:

{ "Version": "2012-10-17", "Statement": [ { "Sid": "DurableFileStorage", "Effect": "Allow", "Action": [ "s3:ListBucket" ], "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME" }, { "Sid": "DurableFileObjects", "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject" ], "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*" } ] }

With eksctl, one command creates the role, the trust policy for your cluster OIDC provider and the annotated ServiceAccount:

eksctl create iamserviceaccount --cluster YOUR-CLUSTER --name selectzero --namespace YOUR-NAMESPACE --attach-policy-arn arn:aws:iam::ACCOUNT_ID:policy/selectzero-s3 --approve

If a workload identity is genuinely impossible, put an access key pair in the Secret and uncomment the SZ_STORAGE_S3_ACCESS_KEY and SZ_STORAGE_S3_SECRET_KEY entries in the Deployment. Keys need rotating; a role does not.

When credentials do not resolve, the AWS SDK reports every provider it tried, most of which are expected to fail. Reading a credential failure explains which line to read.

Manifests

This is what the Helm chart renders in this mode. Apply it by hand when Helm is not available, or read it to see exactly what the chart creates.

Deploy in S3 storage mode

  1. Create the Secret. Replace every REPLACE placeholder.

    # Each key below is named after the environment variable it becomes. The Deployment maps them # with secretKeyRef; the application reads them from the environment. Rename a key here and you # must rename it in the Deployment too. The full list of variables the application understands is # under "Supported environment variables" in Deploying SelectZero. apiVersion: v1 kind: Secret metadata: name: selectzero-secrets type: Opaque stringData: # The master encryption key. Generate once, store it in your own secret manager, and never # change it: every encrypted value in the database was written with this key. # This may hold an aws-sm://<secret-id> reference instead of the key itself, in which case it # is resolved at startup and the key never exists in the cluster. See "Secret references". SZ_ENCRYPTION_KEY: REPLACE_WITH_A_LONG_RANDOM_STRING SZ_DATABASE_USER: selectzero SZ_DATABASE_PASSWORD: REPLACE_WITH_THE_DATABASE_PASSWORD # Only needed when you cannot use IRSA or EKS Pod Identity. Uncomment the matching # environment variables in the Deployment as well. # SZ_STORAGE_S3_ACCESS_KEY: REPLACE # SZ_STORAGE_S3_SECRET_KEY: REPLACE

    SelectZero is configured entirely through environment variables, and it never reads this Secret directly. Kubernetes does: each key is pulled into the container as an environment variable by a secretKeyRef entry in the Deployment below. The keys are therefore named after the variables they become, so SZ_DATABASE_PASSWORD here arrives as SZ_DATABASE_PASSWORD in the container. If you rename a key to fit your own conventions, change the matching secretKeyRef in the Deployment too, or the variable simply will not be set.

    Every variable the application understands, secret or not, is listed under Supported environment variables. Only the sensitive ones belong in a Secret; the rest are plain env entries in the Deployment.

    The encryption key may hold an aws-sm:// reference instead of the key itself, in which case it is fetched at startup and never stored in the cluster.

  2. Create the ServiceAccount carrying your IAM role. Skip this if eksctl already created it.

    apiVersion: v1 kind: ServiceAccount metadata: name: selectzero annotations: # IRSA: the IAM role that carries the S3 permissions. EKS injects AWS_ROLE_ARN and # AWS_WEB_IDENTITY_TOKEN_FILE into every pod using this service account. eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/selectzero-s3
  3. Create the Deployment. Replace the VERSION image tag: the current release is 2026.10.0.

    # S3 storage mode: no PersistentVolumeClaim. Every local directory is an emptyDir that the pod # can lose at any time; the durable copies live in the bucket and in PostgreSQL. # Keep this file in sync with ../pv/deployment.yaml: a diff between the two must show only the # volumes, serviceAccountName and the SZ_STORAGE_* environment variables. apiVersion: apps/v1 kind: Deployment metadata: name: selectzero labels: app: selectzero spec: replicas: 1 # SelectZero runs its schedulers in-process; never run a second replica strategy: type: Recreate # two pods must not work on the same bucket keys during a rollout selector: matchLabels: app: selectzero template: metadata: labels: app: selectzero spec: serviceAccountName: selectzero securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 seccompProfile: type: RuntimeDefault containers: - name: selectzero image: selectzero/selectzero:VERSION # replace with the version you deploy imagePullPolicy: IfNotPresent securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] ports: - name: http containerPort: 8090 env: - name: SZ_STORAGE_MODE value: s3 - name: SZ_STORAGE_S3_BUCKET value: YOUR-BUCKET-NAME - name: SZ_STORAGE_S3_REGION value: YOUR-BUCKET-REGION # Optional key prefix, for a bucket shared with other workloads. # - name: SZ_STORAGE_S3_PREFIX # value: selectzero/ # Only when IRSA and Pod Identity are both impossible. Prefer a role. # - name: SZ_STORAGE_S3_ACCESS_KEY # valueFrom: # secretKeyRef: {name: selectzero-secrets, key: SZ_STORAGE_S3_ACCESS_KEY} # - name: SZ_STORAGE_S3_SECRET_KEY # valueFrom: # secretKeyRef: {name: selectzero-secrets, key: SZ_STORAGE_S3_SECRET_KEY} - name: SZ_ENCRYPTION_KEY valueFrom: secretKeyRef: name: selectzero-secrets key: SZ_ENCRYPTION_KEY # PostgreSQL is mandatory in S3 mode. The embedded SQLite database is not stored in # the bucket, so on an emptyDir it would be lost at every restart. - name: SZ_JDBC_URL value: jdbc:postgresql://YOUR-DATABASE-HOST:5432/selectzero - name: SZ_DATABASE_USER valueFrom: secretKeyRef: {name: selectzero-secrets, key: SZ_DATABASE_USER} - name: SZ_DATABASE_PASSWORD valueFrom: secretKeyRef: {name: selectzero-secrets, key: SZ_DATABASE_PASSWORD} - name: SZ_DATABASE_SSL_MODE value: SSL # NONE, SSL or SSL_VERIFY. An unrecognised value silently means NONE - name: JAVA_OPTIONS value: -XX:MaxRAMPercentage=50.0 resources: requests: cpu: "1" memory: 4Gi ephemeral-storage: 20Gi limits: memory: 8Gi # Must cover everything the emptyDir volumes below actually hold. ephemeral-storage: 50Gi # The application answers no HTTP request until it has restored the mirrored # directories from the bucket, so the startup probe carries the whole hydration time: # 60 x 10s = 10 minutes. Raise failureThreshold if you upload many gigabytes of files. startupProbe: httpGet: path: /actuator/health port: 8090 # Kubernetes waits 1 second by default, which a JVM exceeds during a garbage # collection or a busy moment. Six slow answers in a row restart the container. timeoutSeconds: 5 periodSeconds: 10 failureThreshold: 60 livenessProbe: httpGet: path: /actuator/health/liveness port: 8090 timeoutSeconds: 5 periodSeconds: 10 failureThreshold: 6 readinessProbe: httpGet: path: /actuator/health/readiness port: 8090 timeoutSeconds: 5 periodSeconds: 10 volumeMounts: - {name: database, mountPath: /usr/app/database} - {name: reports, mountPath: /usr/app/reports} - {name: drivers, mountPath: /usr/app/drivers} - {name: keys, mountPath: /usr/app/keys} - {name: temp-files, mountPath: /usr/app/tempFiles} - {name: logs, mountPath: /usr/app/logs} - {name: tmp, mountPath: /tmp} # Exceeding a sizeLimit evicts the pod, so size these for your data, not for the defaults. volumes: # Working copy of the uploaded data source files, restored from the bucket at startup. # Size it for the full set of uploaded files. - name: database emptyDir: sizeLimit: 20Gi # Result and report files. Written locally and to the bucket; the local copy is a # size-bounded cache that the application evicts on its own. - name: reports emptyDir: sizeLimit: 5Gi # JDBC drivers, restored from the bucket at startup. - name: drivers emptyDir: sizeLimit: 1Gi # Truststore and connector certificates, restored from the bucket at startup. - name: keys emptyDir: sizeLimit: 256Mi # Query working files, DuckDB spill, upload staging and the hydration cache. # The busiest directory: size it for your largest execution, not for your data set. - name: temp-files emptyDir: sizeLimit: 20Gi # Log files. Lost with the pod, so collect stdout as well. See "Logging on Kubernetes". - name: logs emptyDir: sizeLimit: 2Gi # Required by readOnlyRootFilesystem: JVM performance data, native library extraction # and the initial administrator password file are written here. - name: tmp emptyDir: sizeLimit: 1Gi
  4. Create the Service. It is a ClusterIP: put your own ingress or load balancer in front of it.

    apiVersion: v1 kind: Service metadata: name: selectzero labels: app: selectzero spec: type: ClusterIP selector: app: selectzero ports: - name: http port: 80 targetPort: 8090
  5. Apply everything and wait for the rollout.

    kubectl apply -n YOUR-NAMESPACE -f secret.yaml -f serviceaccount.yaml -f deployment.yaml -f service.yaml kubectl rollout status -n YOUR-NAMESPACE deployment/selectzero

Four things in that Deployment are load bearing.

The Recreate strategy. A rolling update would briefly run two pods against the same bucket keys and the same database.

The probes. /actuator/health and its liveness and readiness sub-paths are the only endpoints that answer without authentication; the rest of /actuator requires an administrator. The startup probe allows 10 minutes because the application restores its mirrored directories from the bucket before it serves anything, and that time grows with the amount of uploaded data. Raise failureThreshold if you hold many gigabytes; see Startup hydration.

The emptyDir volumes. Every application directory is ephemeral. Exceeding a sizeLimit evicts the pod, so size them for your data:

Mount

Holds

Size it for

/usr/app/database

Working copy of uploaded data source files and synced SharePoint workbooks, restored at startup

The full set of uploaded files plus every synced SharePoint workbook

/usr/app/tempFiles

Query working files, DuckDB spill, upload staging, hydration cache

Your largest single execution

/usr/app/reports

Result and report files, a cache the application evicts itself

A few gigabytes

/usr/app/drivers

JDBC drivers, restored at startup

Your driver JARs

/usr/app/keys

Truststore and connector certificates, restored at startup

Very little

/usr/app/logs

Log files, lost with the pod

Your rotation window

/tmp

JVM working data and the initial password file

Very little

readOnlyRootFilesystem: true. The application writes only into the mounts above. The one thing it would write to the image itself is the initial administrator password file, and it falls back to /tmp on its own, which is why /tmp is mounted.

First login

The initial administrator password is generated at first start and written to a file. With a read-only root filesystem that file is /tmp/admin-init.txt, and the log says so:

Could not create initial admin-init.txt to root folder, file was created to /tmp/admin-init.txt
kubectl exec -n YOUR-NAMESPACE deploy/selectzero -- cat /tmp/admin-init.txt

Before you have an ingress, reach the application with a port forward:

kubectl port-forward -n YOUR-NAMESPACE svc/selectzero 8090:80

Change the administrator password immediately. The file is deleted after the first login.

Startup hydration

On every pod start SelectZero restores the mirrored directories from the bucket before it becomes ready. Progress appears in the System log tab inside the application:

S3 hydration starting: restoring mirrored directories [database/files, database/images, database/system/sharepoint, drivers, keys] from bucket storage S3 hydration finished: restored 142 file(s) in 8317ms

Roughly 1 to 3 minutes for 15GB of uploaded files in-region, downloading in parallel. This is the time the startup probe has to allow.

Verify that a pod restart loses nothing

Run this once after the first deployment. It is the check that proves the stateless setup actually works.

Restart verification

  1. Log in, change the administrator password.

  2. Create something in each durable category: upload a JDBC driver, upload a CSV or Excel data source file, attach a certificate to a connection, run a test case so it produces a result file, and run a test suite so it produces a report.

  3. Confirm the bucket received them.

    aws s3 ls --recursive s3://YOUR-BUCKET-NAME/ | grep -E "drivers/|database/files/|keys/|reports/"
  4. Restart the pod. This destroys every local directory.

    kubectl rollout restart -n YOUR-NAMESPACE deployment/selectzero kubectl rollout status -n YOUR-NAMESPACE deployment/selectzero
  5. Open the System log tab and confirm the hydration lines from Startup hydration. They are written to the System log, not to standard output, so kubectl logs will not show them.

  6. Confirm nothing was lost: the driver is still listed, the uploaded file still previews, the result file still downloads, the test suite report still opens, the certificate is still attached, and your changed administrator password still works.

  7. Confirm the application did not fall back to a local database.

    kubectl logs -n YOUR-NAMESPACE deploy/selectzero | grep "using SQLITE instead"

    This must print nothing. See Current limitations if it does.

Persistent volume variant

The same Deployment, minus the S3 configuration, plus one PersistentVolumeClaim holding every durable directory.

This is what the Helm chart renders in its default mode. Apply it by hand when Helm is not available, or read it to see exactly what the chart creates.

Deploy on a persistent volume

  1. Create the PersistentVolumeClaim for your environment.

    A file share rather than a block volume, and the closest thing to a stateless pod without using S3 mode: the data lives in EFS, outside the cluster, and the pod can be rescheduled to any node. First the storage class, once per cluster:

    apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: efs-sc provisioner: efs.csi.aws.com parameters: provisioningMode: efs-ap # dynamic access points, one per claim fileSystemId: fs-REPLACE-WITH-YOUR-FILESYSTEM-ID directoryPerms: "700" uid: "1000" # the container's user, so the access point root is writable gid: "1000"

    Then the claim:

    apiVersion: v1 kind: PersistentVolumeClaim metadata: name: selectzero-storage-claim spec: storageClassName: efs-sc accessModes: - ReadWriteMany resources: requests: # EFS grows on demand and ignores this number, but Kubernetes requires it. storage: 20Gi

    Requires the EFS CSI driver. ReadWriteMany is what EFS offers; it is not permission to run a second replica.

    apiVersion: v1 kind: PersistentVolumeClaim metadata: name: selectzero-storage-claim spec: storageClassName: gp3 accessModes: - ReadWriteOnce resources: requests: storage: 20Gi

    Uses the Amazon EBS gp3 storage class, default on most EKS clusters. Requires the EBS CSI driver.

    apiVersion: v1 kind: PersistentVolumeClaim metadata: name: selectzero-storage-claim spec: storageClassName: managed-csi accessModes: - ReadWriteOnce resources: requests: storage: 20Gi

    Uses the Azure Managed Disk (CSI) storage class, available by default on AKS.

    apiVersion: v1 kind: PersistentVolumeClaim metadata: name: selectzero-storage-claim spec: storageClassName: standard-rwo accessModes: - ReadWriteOnce resources: requests: storage: 20Gi

    Uses the GCE Persistent Disk (CSI) storage class, available by default on GKE.

    apiVersion: v1 kind: PersistentVolumeClaim metadata: name: selectzero-storage-claim spec: accessModes: - ReadWriteOnce resources: requests: storage: 20Gi

    Omitting storageClassName uses the cluster default. Check with kubectl get storageclass that the default provides persistent storage and not hostPath.

  2. Create the Secret. The same file as in S3 mode, with the same key-to-variable mapping; SZ_ENCRYPTION_KEY is optional here, because without it a key is generated on the volume at first start.

    # Each key below is named after the environment variable it becomes. The Deployment maps them # with secretKeyRef; the application reads them from the environment. Rename a key here and you # must rename it in the Deployment too. The full list of variables the application understands is # under "Supported environment variables" in Deploying SelectZero. apiVersion: v1 kind: Secret metadata: name: selectzero-secrets type: Opaque stringData: # The master encryption key. Generate once, store it in your own secret manager, and never # change it: every encrypted value in the database was written with this key. # This may hold an aws-sm://<secret-id> reference instead of the key itself, in which case it # is resolved at startup and the key never exists in the cluster. See "Secret references". SZ_ENCRYPTION_KEY: REPLACE_WITH_A_LONG_RANDOM_STRING SZ_DATABASE_USER: selectzero SZ_DATABASE_PASSWORD: REPLACE_WITH_THE_DATABASE_PASSWORD # Only needed when you cannot use IRSA or EKS Pod Identity. Uncomment the matching # environment variables in the Deployment as well. # SZ_STORAGE_S3_ACCESS_KEY: REPLACE # SZ_STORAGE_S3_SECRET_KEY: REPLACE
  3. Create the Deployment. Replace the VERSION image tag: the current release is 2026.10.0.

    # Persistent volume variant: one PersistentVolumeClaim holds every durable directory. # Keep this file in sync with ../s3/deployment.yaml: a diff between the two must show only the # volumes, serviceAccountName and the SZ_STORAGE_* environment variables. apiVersion: apps/v1 kind: Deployment metadata: name: selectzero labels: app: selectzero spec: replicas: 1 # SelectZero runs its schedulers in-process; never run a second replica strategy: type: Recreate # two pods must not work on the same files during a rollout selector: matchLabels: app: selectzero template: metadata: labels: app: selectzero spec: securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 # makes the provisioned volume writable by the container's user seccompProfile: type: RuntimeDefault containers: - name: selectzero image: selectzero/selectzero:VERSION # replace with the version you deploy imagePullPolicy: IfNotPresent securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] ports: - name: http containerPort: 8090 env: # PostgreSQL is recommended but not required here: without SZ_JDBC_URL the embedded # SQLite database is used and lives on the volume. - name: SZ_JDBC_URL value: jdbc:postgresql://YOUR-DATABASE-HOST:5432/selectzero - name: SZ_DATABASE_USER valueFrom: secretKeyRef: {name: selectzero-secrets, key: SZ_DATABASE_USER} - name: SZ_DATABASE_PASSWORD valueFrom: secretKeyRef: {name: selectzero-secrets, key: SZ_DATABASE_PASSWORD} - name: SZ_DATABASE_SSL_MODE value: SSL # NONE, SSL or SSL_VERIFY. An unrecognised value silently means NONE # Optional here: without it a key is generated on the volume at first start. # Supply it if you want the key to live in your own secret manager. - name: SZ_ENCRYPTION_KEY valueFrom: secretKeyRef: name: selectzero-secrets key: SZ_ENCRYPTION_KEY optional: true # the container still starts when the Secret does not hold it - name: JAVA_OPTIONS value: -XX:MaxRAMPercentage=50.0 resources: requests: cpu: "1" memory: 4Gi limits: memory: 8Gi startupProbe: httpGet: path: /actuator/health port: 8090 # Kubernetes waits 1 second by default, which a JVM exceeds during a garbage # collection or a busy moment. Six slow answers in a row restart the container. timeoutSeconds: 5 periodSeconds: 10 failureThreshold: 30 livenessProbe: httpGet: path: /actuator/health/liveness port: 8090 timeoutSeconds: 5 periodSeconds: 10 failureThreshold: 6 readinessProbe: httpGet: path: /actuator/health/readiness port: 8090 timeoutSeconds: 5 periodSeconds: 10 volumeMounts: - {name: storage, mountPath: /usr/app/database, subPath: database} - {name: storage, mountPath: /usr/app/reports, subPath: reports} - {name: storage, mountPath: /usr/app/drivers, subPath: drivers} - {name: storage, mountPath: /usr/app/keys, subPath: keys} - {name: storage, mountPath: /usr/app/tempFiles, subPath: tempFiles} - {name: storage, mountPath: /usr/app/logs, subPath: logs} - {name: tmp, mountPath: /tmp} volumes: - name: storage persistentVolumeClaim: claimName: selectzero-storage-claim # Required by readOnlyRootFilesystem: JVM performance data, native library extraction # and the initial administrator password file are written here. - name: tmp emptyDir: sizeLimit: 1Gi
  4. Create the Service and apply everything.

    apiVersion: v1 kind: Service metadata: name: selectzero labels: app: selectzero spec: type: ClusterIP selector: app: selectzero ports: - name: http port: 80 targetPort: 8090
    kubectl apply -n YOUR-NAMESPACE -f pvc.yaml -f secret.yaml -f deployment.yaml -f service.yaml

fsGroup: 1000 in the pod security context makes the provisioned volume writable by the container user, so the folder permission fix is not needed on dynamically provisioned volumes.

JDBC drivers

Drivers uploaded through the Connectors settings tab persist in both variants. In S3 mode drivers is one of the mirrored prefixes: an upload is written through to the bucket and restored into the pod at the next start, which is why it appears in the hydration log line. In the persistent volume variant drivers live on the volume.

You can also bake drivers into a derived image if you prefer immutable deployments:

FROM selectzero/selectzero:2026.10.0 COPY my-driver.jar /usr/app/drivers/

This is optional. Uploading through the interface is the supported path and survives restarts on its own.

Logging

The general application log goes to standard output as well as to a file, so kubectl logs and any cluster log collector receive it with no extra configuration. The SQL, Security, Import/Export, AI assistant and System logs are written only to files inside the pod. See Logging on Kubernetes for what that means and how to collect them, including the optional one-line JSON file (SZ_LOG_JSON) that a sidecar collector can tail for every stream at once.

When the pod does not start

When SelectZero refuses to start because of its encryption key or a secret reference, the pod restarts in a loop and kubectl logs shows a long stack trace. The reason is the line that contains Startup aborted, at the top of the trace or just before it:

kubectl logs -n YOUR-NAMESPACE deploy/selectzero --previous | grep "Startup aborted"

--previous reads the log of the container that just exited; leave it out if the pod has not restarted yet. The line names one of four causes:

The line contains

Cause

Fix

Startup aborted: storage.mode=s3 requires an externally provided encryption key

S3 mode without SZ_ENCRYPTION_KEY

Set the key in the Secret, see Prerequisites

Startup aborted: Property '...' holds a secret reference that could not be resolved

An aws-sm:// reference, in SZ_ENCRYPTION_KEY or any other variable, could not be read from AWS Secrets Manager. The rest of the line gives the reason

Check the secret id, the pod's IAM permission secretsmanager:GetSecretValue and its network route to Secrets Manager, see Secret references

Startup aborted: encryption.key is still the unresolved secret reference

The aws-sm:// reference in SZ_ENCRYPTION_KEY was never looked up

See Secret references

Startup aborted: The master encryption key does not match

The key differs from the one the existing data was encrypted with

Restore the previous key; the line explains the override

Updating the version

The Recreate strategy stops the old pod before starting the new one, so there is a short outage. In S3 mode the new pod hydrates from the bucket before it becomes ready.

With the Helm chart, upgrade to the new chart version. The chart version is always the SelectZero release it installs, so upgrading to a release means passing that release as --version. Always pass your values file: an upgrade with no values at all silently reuses the previous release's values, including any image tag you pinned, and --reuse-values keeps the old chart's defaults for anything the new version added.

helm upgrade selectzero oci://registry-1.docker.io/selectzero/selectzero-helm \ --version 2026.10.0 -n YOUR-NAMESPACE -f values.yaml kubectl rollout status -n YOUR-NAMESPACE deployment/selectzero

To run a specific image without changing chart version, for example a patch release, set image.tag in your values file. Do not change the version on a Helm-managed installation with kubectl set image or kubectl edit: Helm 4 then refuses the next helm upgrade with a field conflict, and Helm 3 silently reverts your change.

With raw manifests, change the image tag and apply.

kubectl set image -n YOUR-NAMESPACE deployment/selectzero selectzero=selectzero/selectzero:2026.10.0 kubectl rollout status -n YOUR-NAMESPACE deployment/selectzero

Read the release notes before a major version jump, and back up your database first.

Current limitations

initContainers: - name: wait-for-database image: busybox:1.36 command: ["sh", "-c", "until nc -z YOUR-DATABASE-HOST 5432; do echo waiting for database; sleep 2; done"]

One replica only. Covered at the top of this page. The Helm chart refuses to install above one replica; the raw manifests do not enforce it.

The log viewer shows the current pod. The in-application log viewer and the archived log download read files inside the running pod. After a restart they start empty.

Direct HTTPS on the container needs a mounted keystore. SelectZero decides whether to serve HTTPS itself before it restores anything from the bucket, so a certificate uploaded through the interface does not enable it in S3 mode. Terminate TLS at your ingress, which is the normal Kubernetes arrangement, and run with the secure-session profile when you do.

26 October 2026