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:
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: RuntimeDefaultWhen 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
| Setup | Use it when | Values |
|---|---|---|
| Default | Your cluster has no Pod Security Admission policy | Nothing to set (enabled: false) |
| Non-root, UID 1000 | Most Kubernetes clusters, including those enforcing the restricted profile | securityContext.enabled: true |
| Platform-pinned UID | Your platform requires a specific UID, such as 10001 | Set the UID, keep the group at 0 |
| Platform-assigned UID | OpenShift's restricted-v2 SCC, or any platform that assigns the UID itself | Set 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:
securityContext:
enabled: trueEvery 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:
securityContext:
enabled: true
podSecurityContext:
runAsUser: 10001
runAsGroup: 0
fsGroup: 0
containerSecurityContext:
runAsUser: 10001WARNING
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:
securityContext:
enabled: true
podSecurityContext:
runAsUser: null
runAsGroup: null
fsGroup: null
containerSecurityContext:
runAsUser: null
services:
email_service:
runAsUser: null # this service pins its own; see belowWARNING
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.
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: 100Leave 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:
services:
opensearch:
local_setup: false
runAsUser: 1000
fsGroup: 1000It'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:
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:
kubectl create namespace plane
kubectl label namespace plane pod-security.kubernetes.io/enforce=restrictedThen install without --create-namespace:
helm upgrade --install plane-app plane/plane-enterprise \
--namespace plane \
-f values.yaml \
--timeout 10m \
--wait \
--wait-for-jobsTo 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:
kubectl label --dry-run=server --overwrite namespace plane \
pod-security.kubernetes.io/enforce=restrictedOn 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
oclogged in as a user who can create projectshelmon the same machineExternal 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:
bashoc 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:
helm repo add plane https://helm.plane.so/
helm pull plane/plane-enterprise --untarBesides 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:
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: falseSet your values
Create values.yaml with what the overlay leaves to you:
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 volumeThen add the datastore connection settings from Use external datastores. The overlay already sets local_setup: false for each of them.
ssl.externalTermination: truetells the chart that users reach Plane over HTTPS even though it manages no certificate. Without it, the overlay alone gives Planehttp://URLs for itself, such asAPP_BASE_URLandWEB_URL, while the Routes redirect every browser tohttps://.env.storageClassmust name a storage class fromoc get storageclass. Left empty, the monitor's volume claim asks for no class and staysPendingunless a matching volume already exists.
Install
Create a project and install into it:
oc new-project planehelm upgrade --install plane-app plane/plane-enterprise \
--namespace plane \
-f values.yaml \
-f plane-enterprise/examples/values-openshift.yaml \
--timeout 10m \
--wait \
--wait-for-jobsWhen 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:
| Path | Service |
|---|---|
/ | 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:
| Key | Default | What 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:
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:routerThen point the Routes at it:
ingress:
openshift:
externalCertificate: plane-tlsThis needs OpenShift 4.16 or later.
Verify on OpenShift
Check that every pod is Running and every Route is admitted on your hostname:
oc -n plane get pods
oc -n plane get routesEach Route shows your hostname, its path, and edge/Redirect termination.
Check which SCC admitted each pod:
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>:
oc get project plane -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}{"\n"}'
oc -n plane exec deploy/plane-app-api-wl -- idThe 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:
securityContext:
enabled: trueNo 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
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:
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:
kubectl -n plane exec deploy/plane-app-api-wl -- idWith 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:
kubectl -n plane exec deploy/plane-app-api-wl -- grep -E '^(Uid|Gid):' /proc/1/statusNone 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
kubectl get namespace plane --show-labelsThe 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.

