Skip to content

Non-root deployment on Kubernetes Commercial Edition ​

By default the plane-enterprise Helm chart sets no securityContext, so every container runs as its image's default user and some of them start as root. That works on most clusters, but not on one that enforces the Pod Security Admission restricted profile, or on OpenShift.

This page shows how to run every Plane workload as a non-root user with one block of Helm values, which datastores have to live outside the cluster for that, how to deploy on OpenShift, and how to check the result.

INFO

Requires plane-enterprise 2.5.0 or later for the securityContext block, and 3.5.2 or later for the OpenShift setup (examples/values-openshift.yaml and a nullable services.email_service.runAsUser).

Running under any UID other than 1000, including an OpenShift-assigned one, also needs Plane v3.1.1 or later. Those images grant group 0 write access to the directories they write to at runtime.

The securityContext block ​

Everything on this page is driven by the top-level securityContext block in the chart's values.yaml:

yaml
securityContext:
  enabled: false
  # Rendered at spec.template.spec.securityContext (pod level).
  podSecurityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  # Rendered at each container's / initContainer's securityContext.
  containerSecurityContext:
    allowPrivilegeEscalation: false
    runAsNonRoot: true
    runAsUser: 1000
    capabilities:
      drop:
        - ALL
    seccompProfile:
      type: RuntimeDefault

When enabled is true, the chart renders podSecurityContext on the pod and containerSecurityContext on every container and init container of each Plane workload: api, web, space, admin, live, worker, beat-worker, silo, monitor, the Plane AI services, the optional consumers and workers, and the migration Jobs. When it's false, the chart renders neither.

The defaults already meet the restricted profile: non-root, no privilege escalation, all capabilities dropped, and the RuntimeDefault seccomp profile.

Two things are outside this block:

  • The bundled datastores. Postgres, Redis, RabbitMQ, MinIO and OpenSearch are third-party images with their own UID requirements, so the chart doesn't apply the block to them. See Use external datastores.
  • The email service's UID. It takes everything else from the block, but keeps its own runAsUser. See Services that set their own UID.

Choose a setup ​

SetupUse it whenValues
DefaultYour cluster has no Pod Security Admission policyNothing to set (enabled: false)
Non-root, UID 1000Most Kubernetes clusters, including those enforcing the restricted profilesecurityContext.enabled: true
Platform-pinned UIDYour platform requires a specific UID, such as 10001Set the UID, keep the group at 0
Platform-assigned UIDOpenShift's restricted-v2 SCC, or any platform that assigns the UID itselfSet every ID to null, or use examples/values-openshift.yaml

Non-root as UID 1000 ​

UID 1000 is the plane user built into the Plane images. Turning the block on is all it takes:

yaml
securityContext:
  enabled: true

Every Plane pod then runs as UID 1000, group 1000, with fsGroup: 1000 applied to its volumes.

A UID your platform pins ​

If your platform requires a specific UID, override the UID in both places and keep the group at 0:

yaml
securityContext:
  enabled: true
  podSecurityContext:
    runAsUser: 10001
    runAsGroup: 0
    fsGroup: 0
  containerSecurityContext:
    runAsUser: 10001

WARNING

Keep runAsGroup at 0, not your UID. The images give write access to their runtime directories through group 0, so a process in any other group can't write to them. With runAsGroup: 10001, for example, the web container can't create its nginx cache directory and crashes. The restricted profile doesn't restrict the group.

OpenShift or an assigned UID ​

OpenShift's restricted-v2 SCC runs every container under a UID it picks from the namespace's range, always in group 0. It also rejects any pod that asks for a specific UID or fsGroup outside that range.

So keep the hardening and remove the IDs. A null in your values file removes the key during Helm's value merge, which leaves runAsNonRoot, the seccomp profile and the dropped capabilities in place with no UID:

yaml
securityContext:
  enabled: true
  podSecurityContext:
    runAsUser: null
    runAsGroup: null
    fsGroup: null
  containerSecurityContext:
    runAsUser: null
services:
  email_service:
    runAsUser: null # this service pins its own; see below

WARNING

Don't leave the 1000 defaults in place on OpenShift. restricted-v2 validates with MustRunAsRange, so every pod requesting UID 1000 fails admission and nothing schedules.

The chart ships these values, along with the Route and datastore settings OpenShift needs, as examples/values-openshift.yaml. For the full walkthrough, see Deploy on OpenShift.

Services that set their own UID ​

The email service's image runs as UID 100, which owns its working directory, so the service keeps its own UID rather than following the block. It still takes runAsNonRoot, the dropped capabilities and the seccomp profile from containerSecurityContext.

yaml
services:
  email_service:
    # The image's app-user is uid 100 and owns /opt/email, so this service pins
    # its own UID rather than following the shared securityContext block above.
    # Set to null where the platform assigns the UID (OpenShift restricted-v2).
    runAsUser: 100

Leave it at 100 unless your platform assigns UIDs itself, as OpenShift does. Then set it to null.

The bundled OpenSearch sets its own UID and fsGroup too, because its image needs 1000 to own its data directory:

yaml
services:
  opensearch:
    local_setup: false
    runAsUser: 1000
    fsGroup: 1000

It's off by default. It can't run under an assigned UID at all, so on OpenShift use an external OpenSearch instead.

Use external datastores ​

The bundled Postgres, Redis, RabbitMQ, MinIO and OpenSearch, and their helper Jobs, run with the security settings of their upstream images. They're meant for local and evaluation installs, can't run under an assigned UID, and the restricted profile rejects them.

For a production non-root install, run them as managed or separately operated services and point the chart at them:

yaml
services:
  postgres:
    local_setup: false
  redis:
    local_setup: false
  rabbitmq:
    local_setup: false
    external_rabbitmq_url: "<amqp(s) connection URL>"
  minio:
    local_setup: false
  opensearch:
    local_setup: false

env:
  pgdb_remote_url: "<postgres connection URL>"
  remote_redis_url: "<redis connection URL>"
  docstore_bucket: "<bucket name>"
  aws_region: "<region>"
  aws_s3_endpoint_url: "<S3 endpoint, for S3-compatible storage>"
  # Only if you use advanced search
  opensearch_remote_url: "<opensearch URL>"

See Configuration settings for every datastore key, including storage credentials, and Configure external services for setting the services up.

Keep services.drawio.local_setup off too (the default). The self-hosted draw.io image doesn't get the hardened settings. Point env.drawio_remote_url at a draw.io you host elsewhere, or leave it empty to use the diagrams.net cloud.

INFO

Turning the block on doesn't change the bundled datastores, so they keep working in a namespace that doesn't enforce the restricted profile. You can switch Plane's own workloads to non-root first and move the datastores out later.

Enforce the restricted profile ​

Pod Security Admission is configured per namespace with a label. Create the namespace and label it before you install:

bash
kubectl create namespace plane
kubectl label namespace plane pod-security.kubernetes.io/enforce=restricted

Then install without --create-namespace:

bash
helm upgrade --install plane-app plane/plane-enterprise \
    --namespace plane \
    -f values.yaml \
    --timeout 10m \
    --wait \
    --wait-for-jobs

To label a namespace that already runs Plane, do a server-side dry run first. It lists every running pod that would violate the profile, without enforcing anything:

bash
kubectl label --dry-run=server --overwrite namespace plane \
    pod-security.kubernetes.io/enforce=restricted

On OpenShift, skip the label: restricted-v2 already applies. See Deploy on OpenShift.

Deploy on OpenShift ​

Plane runs under OpenShift's default restricted-v2 SCC. Its workloads need no extra SCC grant such as anyuid.

INFO

This walkthrough needs plane-enterprise 3.5.2 or later and Plane v3.1.1 or later. The chart doesn't state a minimum OpenShift version. Serving your own certificate from a Secret (ingress.openshift.externalCertificate) needs OpenShift 4.16 or later.

Prerequisites ​

  • An OpenShift cluster, and oc logged in as a user who can create projects

  • helm on the same machine

  • External Postgres, Redis or Valkey, RabbitMQ and S3-compatible storage, plus OpenSearch if you use advanced search. The bundled ones can't run under an assigned UID. See Use external datastores.

  • A hostname for Plane. A name under the cluster's apps domain is simplest, because the router already resolves it and serves a wildcard certificate for it. To find the domain, run:

    bash
    oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}{"\n"}'

    For a hostname outside that domain, point its DNS at the router and use your own certificate.

Get the OpenShift values ​

The chart includes an overlay with the OpenShift settings, examples/values-openshift.yaml. Pull the chart to get it:

bash
helm repo add plane https://helm.plane.so/
helm pull plane/plane-enterprise --untar

Besides the security context from OpenShift or an assigned UID, the overlay turns the bundled datastores off, renders OpenShift Routes, and turns off the chart's own certificate handling:

yaml
services:
  postgres:
    local_setup: false
  redis:
    local_setup: false
  rabbitmq:
    local_setup: false
  minio:
    local_setup: false
  opensearch:
    local_setup: false

ingress:
  enabled: true
  controller: "openshift"
  ingressClass: "openshift"
  openshift:
    # The router default is 30s, which severs /live/'s collaborative-editing
    # WebSockets and /pi/'s streaming responses. Do not lower this.
    timeout: "300s"
    termination: "edge"
    insecureEdgeTerminationPolicy: "Redirect"
    # Leave empty to serve the Ingress Operator's wildcard certificate.
    externalCertificate: ""

# The router terminates TLS, so the chart's own cert-manager wiring is not used.
ssl:
  createIssuer: false
  generateCerts: false

Set your values ​

Create values.yaml with what the overlay leaves to you:

yaml
planeVersion: <release-tag>

license:
  licenseDomain: plane.apps.example.com # the hostname every Route serves

# The router terminates TLS in front of Plane
ssl:
  externalTermination: true

env:
  storageClass: "<storage class>" # for the monitor's persistent volume

Then add the datastore connection settings from Use external datastores. The overlay already sets local_setup: false for each of them.

  • ssl.externalTermination: true tells the chart that users reach Plane over HTTPS even though it manages no certificate. Without it, the overlay alone gives Plane http:// URLs for itself, such as APP_BASE_URL and WEB_URL, while the Routes redirect every browser to https://.
  • env.storageClass must name a storage class from oc get storageclass. Left empty, the monitor's volume claim asks for no class and stays Pending unless a matching volume already exists.

Install ​

Create a project and install into it:

bash
oc new-project plane
bash
helm upgrade --install plane-app plane/plane-enterprise \
    --namespace plane \
    -f values.yaml \
    -f plane-enterprise/examples/values-openshift.yaml \
    --timeout 10m \
    --wait \
    --wait-for-jobs

When the same key is set in more than one file, the last -f wins. To change a setting the overlay sets, such as the Route timeout, add another values file after the overlay.

Routes ​

Instead of an Ingress, the chart renders one route.openshift.io/v1 Route per path, all on license.licenseDomain:

PathService
/web
/api/, /auth/, /static/, /graphql/, /marketplace/api
/god-mode/admin
/spaces/space
/live/live

Enabled optional services get their own Routes too, such as /silo/ and /pi/. The router matches the most specific path, so the order of the Routes doesn't matter.

The Routes are configured under ingress.openshift:

KeyDefaultWhat it does
timeout'300s'Written to every Route as haproxy.router.openshift.io/timeout. The router's default of 30 seconds cuts off real-time collaboration on /live/ and streamed Plane AI responses.
termination'edge'The router terminates TLS and forwards plain HTTP to Plane. Keep edge. Path-based Routes can't use passthrough, and the chart refuses to render with it.
insecureEdgeTerminationPolicy'Redirect'Redirects http:// requests to https://.
externalCertificate''The name of a TLS Secret to serve instead of the router's wildcard certificate. See Use your own certificate.
route_annotations{}Extra annotations added to every Route.

Set the timeout through ingress.openshift.timeout, not route_annotations. The chart always writes the timeout annotation, so adding it again produces a duplicate key on every Route.

Routes have no request-body limit, so the upload cap that ingress.traefik.maxRequestBodyBytes sets on Traefik doesn't apply on OpenShift. If you need one at the edge, enforce it in a proxy or WAF in front of the router.

INFO

You can instead let OpenShift convert a standard Ingress into Routes, by setting ingress.controller: 'nginx' and ingress.ingressClass: 'openshift-default' (chart 3.5.5 or later). The chart then doesn't set the Route timeout, so live collaboration disconnects after 30 seconds. Use the chart's Routes unless you have a reason not to.

Use your own certificate ​

For a hostname the router's wildcard certificate doesn't cover, store your certificate in a TLS Secret and let the router read it:

bash
oc -n plane create secret tls plane-tls --cert=tls.crt --key=tls.key
oc -n plane create role secret-reader --verb=get,list,watch \
    --resource=secrets --resource-name=plane-tls
oc -n plane create rolebinding secret-reader-binding \
    --role=secret-reader --serviceaccount=openshift-ingress:router

Then point the Routes at it:

yaml
ingress:
  openshift:
    externalCertificate: plane-tls

This needs OpenShift 4.16 or later.

Verify on OpenShift ​

Check that every pod is Running and every Route is admitted on your hostname:

bash
oc -n plane get pods
oc -n plane get routes

Each Route shows your hostname, its path, and edge/Redirect termination.

Check which SCC admitted each pod:

bash
oc -n plane get pods \
    -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.openshift\.io/scc}{"\n"}{end}'

Every Plane pod should show restricted-v2.

Check that the pods run under a UID from the project's range. The range is an annotation on the project, in the form <first UID>/<size>:

bash
oc get project plane -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}{"\n"}'
oc -n plane exec deploy/plane-app-api-wl -- id

The uid that id prints should fall inside that range, with gid=0(root). Then open https://<your hostname> in a browser.

Troubleshooting on OpenShift ​

No Routes are created. The chart renders Routes only when license.licenseDomain is set and ingress.controller is openshift (or, when controller is empty, ingress.ingressClass is openshift). Check that a later values file doesn't override them.

Pods fail with unable to validate against any security context constraint and must be in the ranges. A pod is requesting a fixed UID or fsGroup. Use the values from OpenShift or an assigned UID, including services.email_service.runAsUser: null.

A pod shows the anyuid SCC instead of restricted-v2. All Plane workloads share one service account, <release>-srv-account. If anyuid was granted to it, for example to run a bundled datastore, every Plane pod uses it too. Remove the grant and use external datastores.

Live collaboration disconnects, or Plane AI responses stop, after about 30 seconds. The Routes are missing the timeout. Check that ingress.openshift.timeout isn't lowered and that you're using the chart's Routes rather than a converted Ingress.

Links, redirects or sign-in callbacks use http://. Set ssl.externalTermination: true.

helm fails with ingress.openshift.termination must be "edge" or "reencrypt". Path-based Routes can't use passthrough. Set termination back to edge.

The browser shows the router's "Application is not available" page. No admitted Route matches the hostname. Check the hostname in oc -n plane get routes against license.licenseDomain and your DNS record.

Switch an existing install to non-root ​

An install running with the default (enabled: false) can switch with a normal upgrade. Add the block to your values file and run helm upgrade with the same chart version:

yaml
securityContext:
  enabled: true

No data migration or chown is needed. When the new pods start, the kubelet applies the fsGroup to the persistent volumes they mount, so files written while the pods ran as root stay readable and writable. This relies on your storage driver supporting fsGroup, which most block-storage drivers do.

Try the switch on a staging copy of your install first. Label the namespace for the restricted profile only after the upgrade succeeds and the datastores are external.

Verify ​

Check the rendered security context ​

bash
kubectl -n plane get deployment plane-app-api-wl \
    -o jsonpath='{.spec.template.spec.securityContext}{"\n"}{.spec.template.spec.containers[0].securityContext}{"\n"}'

With enabled: true this prints the pod- and container-level blocks, including "runAsNonRoot":true. On OpenShift it prints the same blocks with no runAsUser, runAsGroup or fsGroup.

To check every pod in the namespace at once:

bash
kubectl -n plane get pods -o custom-columns='POD:.metadata.name,NON_ROOT:.spec.securityContext.runAsNonRoot,POD_UID:.spec.securityContext.runAsUser,CONTAINER_UID:.spec.containers[*].securityContext.runAsUser'

Check the user a container runs as ​

kubectl exec runs its command as the container's securityContext user, so id shows the identity the workload has:

bash
kubectl -n plane exec deploy/plane-app-api-wl -- id

With UID 1000 this prints uid=1000(plane) gid=1000(plane). On OpenShift it shows the assigned UID with gid=0(root).

To see what the application process itself runs as, read the status of process 1:

bash
kubectl -n plane exec deploy/plane-app-api-wl -- grep -E '^(Uid|Gid):' /proc/1/status

None of the values should be 0. This check also works with the block off, where kubectl exec runs as the image's default user, which may be root even when the application isn't.

Check admission ​

bash
kubectl get namespace plane --show-labels

The label pod-security.kubernetes.io/enforce=restricted should be present, and every Plane pod should be Running. On OpenShift, see Verify on OpenShift.

Troubleshooting ​

violates PodSecurity "restricted:latest" for a Postgres, Redis, RabbitMQ, MinIO, OpenSearch or draw.io pod. Those are the bundled services the hardened settings don't cover. Move them out as described in Use external datastores.

For OpenShift-specific problems, see Troubleshooting on OpenShift.

The web or admin pod crashes with mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied). The container runs under a UID other than 1000 and either the group isn't 0 or the images are older than v3.1.1. Keep runAsGroup at 0 (or null on OpenShift) and run Plane v3.1.1 or later.