OpenShift Deployment
This page audits the TruePPM Helm chart (packages/helm/) against OpenShift’s
default restricted-v2 Security Context Constraint (SCC) and its Ingress→Route
conversion, and says plainly which parts work, which need a values override, and
which do not work today. It assumes you already know OpenShift; for the rest of
the install (secrets, ingress/TLS, sizing, probes) see
Deployment, Networking,
and Helm Values — this page only covers what is
different on OpenShift.
What works today
Section titled “What works today”The API, web, worker, and beat tiers satisfy restricted-v2 with one override
Section titled “The API, web, worker, and beat tiers satisfy restricted-v2 with one override”containerSecurityContext already matches every restricted-v2 requirement:
allowPrivilegeEscalation: false, all capabilities dropped, seccompProfile: RuntimeDefault, runAsNonRoot: true. The one line that does not is
podSecurityContext.runAsUser: 1000 — a fixed UID, where restricted-v2 uses
MustRunAsRange and requires the runtime to assign an arbitrary UID from the
namespace’s allocated range. podSecurityContext.fsGroup: 1000 is the same kind
of problem: the chart sets it so a persistence.media or backup.persistence
volume is writable on plain Kubernetes, but restricted-v2 uses MustRunAs with
the namespace’s range for fsGroup and rejects a fixed 1000 at admission. Set
both to null in your values to drop the fields entirely, letting OpenShift
assign the UID and the volume group:
podSecurityContext: runAsNonRoot: true runAsUser: null # let restricted-v2 assign a UID from the namespace's range fsGroup: null # let restricted-v2 assign the volume group from the same rangeThis was verified with helm template packages/helm -f openshift-values.yaml: a
values key set to null deletes it from the merged map (rather than being
skipped, which would leave the chart default in place), so the rendered
securityContext carries runAsNonRoot: true only. No containerSecurityContext
change is needed — it never sets a runAsUser of its own.
This override applies fleet-wide, because templates/api/deployment.yaml,
templates/web/deployment.yaml, templates/celery-worker/deployment.yaml,
templates/celery-beat/deployment.yaml, templates/cronjob-backup.yaml,
templates/demo-seed-job.yaml, and templates/tests/api-connection.yaml all
render the same .Values.podSecurityContext / .Values.containerSecurityContext
verbatim — one override, every pod. values.schema.json leaves both keys as an
open object (they are rendered wholesale with toYaml), so this is not
rejected by schema validation either.
Why the images themselves do not block this. The API image
(packages/api/Dockerfile) bakes USER trueppm (a named uid-1000 account) and
the web image uses nginxinc/nginx-unprivileged (USER 101), but neither
Dockerfile’s USER line matters once OpenShift injects a pod-level
runAsUser: Kubernetes’ securityContext.runAsUser always overrides an image’s
USER directive. What the arbitrary UID actually needs is read access to
/app and the venv (world-readable from the image build) and write access
only to the mounted volumes — /tmp, /app/staticfiles, /run/trueppm, and
persistence.media when it is enabled — every one of which is an emptyDir or a
PersistentVolumeClaim, not a path baked into the image. emptyDir volumes are
created world-writable by the kubelet regardless of which UID the container
runs as, so no fsGroup is required for those. A persistence.media claim gets
its group from the fsGroup OpenShift assigns from the namespace range, which is
why the chart’s own fsGroup: 1000 is nulled above. Nothing in either Dockerfile
mkdirs or chowns a path the arbitrary UID would need and not get.
No privileged ports
Section titled “No privileged ports”The API listens on 8000, the web tier’s unprivileged nginx on 8080
(web.containerPort, matching the base image’s own unprivileged default) — see
templates/api/deployment.yaml and templates/web/deployment.yaml. Nothing in
the chart binds a container port below 1024, so restricted-v2’s port
restriction (which only matters for hostPort/hostNetwork, both absent here
anyway) is a non-issue.
Ingress converts to Route with no chart changes
Section titled “Ingress converts to Route with no chart changes”The chart renders a plain networking.k8s.io/v1 Ingress
(templates/ingress.yaml) — there is no OpenShift-specific object. OpenShift’s
built-in ingress-to-route controller watches every namespace and mirrors each
Ingress into one or more Route objects automatically; no cluster-admin action
and no ingressClassName are required for this to happen. Set
ingress.className: "" (the default) so the chart does not stamp an
ingressClassName that names a controller OpenShift’s router is not:
ingress: enabled: true className: "" hosts: - host: trueppm.apps.example-cluster.example.com paths: - path: /api pathType: Prefix service: api - path: /ws pathType: Prefix service: api - path: / pathType: Prefix service: web tls: - secretName: trueppm-tls hosts: - trueppm.apps.example-cluster.example.comTwo behaviors worth knowing before you rely on them:
- One path, one Route. The sync controller creates a separate
Routeper(host, path)pair, so the three paths above become three Route objects. Each carries its ownpathfield, and the router selects the longest matching path the same wayingress-nginxdoes — the chart’s “list/apiand/wsbefore/” ordering advice in Helm Values still applies conceptually, but path length (not list order) is what the OpenShift router actually matches on. - TLS Secrets convert to edge-terminated Routes. A
secretNameunderingress.tlsbecomes a Route withtls.termination: edge, sourcing the certificate and key from that Secret. If you run cert-manager on the cluster (installed as an Operator or manually — it is not bundled with OpenShift), it populates the named Secret exactly as on any other Kubernetes cluster, and the sync controller picks up the result. Leavingingress.tlsempty renders an HTTP-only Ingress and an HTTP-only Route — fine for a dev/demo project, never for production, same as on vanilla Kubernetes. - Ingress annotations are copied onto the generated Routes. The controller
copies the Ingress’s annotations verbatim, so OpenShift router annotations set
under
ingress.annotations— for examplehaproxy.router.openshift.io/timeoutfor long uploads, orroute.openshift.io/termination: reencrypt/passthroughinstead of the default edge termination — reach the Route without a Route template.
ingress.annotations’ shipped default,
nginx.ingress.kubernetes.io/proxy-body-size: "110m"
(see Upload size limits), is
harmless but inert on OpenShift — it is an ingress-nginx annotation, and the
generated Route ignores it. The application’s own upload caps
(MAX_ATTACHMENT_SIZE_BYTES, MSPROJECT_MAX_UPLOAD_MB, and friends — see
Configuration) still apply and are what
actually rejects an oversized file; the OpenShift HAProxy router imposes no
default body-size ceiling of its own, so you do not need a replacement
annotation for the common case. If you need a router-side cap anyway, that is a
customization of the router’s own HAProxy template, outside anything this chart
renders.
NetworkPolicy is enforced under OVN-Kubernetes
Section titled “NetworkPolicy is enforced under OVN-Kubernetes”networkPolicy.enabled: true (the default) restricts ingress to the bundled
datastores to the tiers that need it — see Network and pod
security. OVN-Kubernetes
is OpenShift’s default network plugin and, from 4.15, the only option for new
installations (OpenShift SDN was deprecated in 4.14). It fully enforces standard
networking.k8s.io/v1 NetworkPolicy — the same object this chart renders — so no
OpenShift-specific change is needed. A cluster upgraded from an older release may
still run the deprecated OpenShift SDN plugin. To check which plugin your cluster
runs:
oc get network.config cluster -o jsonpath='{.spec.networkType}{"\n"}'Private / mirrored registries
Section titled “Private / mirrored registries”imagePullSecrets (see Placement and
scheduling) is the same
air-gapped path on OpenShift as anywhere else: mirror
registry.gitlab.com/trueppm/trueppm/{api,web} into a registry your cluster can
reach, point image.repository / image.webRepository there, and name a pull
secret. OpenShift’s own internal registry (image-registry.openshift-image-registry.svc:5000)
is one option for that mirror but is not required — any registry reachable from
the cluster works, exactly as on plain Kubernetes.
Values to set for OpenShift
Section titled “Values to set for OpenShift”# openshift-values.yaml — layer on top of values-prod.yamlpodSecurityContext: runAsNonRoot: true runAsUser: null # let restricted-v2 assign the UID fsGroup: null # the chart defaults this to 1000; restricted-v2 rejects a fixed value
ingress: enabled: true className: "" # empty — do not name an ingress controller that isn't OpenShift's router hosts: - host: trueppm.apps.example-cluster.example.com paths: - path: /api pathType: Prefix service: api - path: /ws pathType: Prefix service: api - path: / pathType: Prefix service: web tls: - secretName: trueppm-tls hosts: - trueppm.apps.example-cluster.example.com
# Bundled dev/demo datastores do not satisfy restricted-v2 — see below.postgresql: enabled: falsevalkey: enabled: falseEverything else — env.DATABASE_URL / env.REDIS_URL, the envFrom Secret
carrying SECRET_KEY / ALLOWED_HOSTS / INTEGRATION_ENCRYPTION_KEY, resource
sizing, probes — follows the same rules as any other Kubernetes install; see
Deployment → Production install walkthrough.
Create the project and install the same way you would kubectl create namespace + helm install anywhere else, substituting the OpenShift CLI’s
project verb:
oc new-project trueppmhelm install trueppm packages/helm \ -f packages/helm/values-prod.yaml \ -f openshift-values.yaml \ -f my-values.yamlNo oc adm policy add-scc-to-user call is needed for the api/web/worker/beat
tiers — every service account in a normal OpenShift project already has
restricted-v2 available, and the chart never creates or references a custom
ServiceAccount (automountServiceAccountToken: false on every pod; all tiers
run as the project’s default account with its token unmounted).
Verifying it
Section titled “Verifying it”oc get scc restricted-v2 -o yamloc get project trueppm -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}'oc get pods -n trueppmoc get routes -n trueppmA pod stuck in CreateContainerConfigError or rejected at admission with a
message naming runAsUser or fsGroup means either a values file upstream of
openshift-values.yaml re-introduced a fixed UID or group, or fsGroup: null is
missing so the chart default of 1000 applies — check for a values file loaded
after this one that also sets podSecurityContext, since a later -f file’s
keys win.
What does not work today
Section titled “What does not work today”The bundled dev/demo postgresql and valkey subcharts
Section titled “The bundled dev/demo postgresql and valkey subcharts”packages/helm/charts/postgresql/templates/statefulset.yaml and
charts/valkey/templates/statefulset.yaml each hardcode
runAsUser: 999 / runAsGroup: 999 / fsGroup: 999 directly in the template —
not read from any values key, so there is no override available from the
parent chart’s values.yaml. restricted-v2’s MustRunAsRange strategy
rejects a pod that requests a specific UID outside the namespace’s assigned
range, so postgresql.enabled: true / valkey.enabled: true will not admit
on a stock OpenShift project.
Two ways around it, neither of which this chart can do for you:
- Use external managed PostgreSQL/Valkey (
postgresql.enabled: false,valkey.enabled: false,env.DATABASE_URL/env.REDIS_URL) — already the chart’s documented production recommendation regardless of platform (see Bundled datastores), and the only path this page can currently recommend for OpenShift. The database must allow theltree,pg_trgm, andbtree_gistextensions — see Managed PostgreSQL. - Grant a permissive SCC (
anyuid, or a custom SCC scoped to uid 999) to the project — requires a cluster-admin, is a real security downgrade for what is meant to be a dev/demo workload, and is not something the chart or these docs configure for you.
Making the subcharts’ UID/GID configurable is tracked in #3731; until it ships, treat the bundled datastores as unsupported on OpenShift, on any project running the default SCC set.
No native Route object
Section titled “No native Route object”The chart renders Ingress only. Because the ingress-to-route controller copies
Ingress annotations onto each Route it generates (see
above), per-Route router
annotations such as timeouts work through ingress.annotations. What the
conversion cannot express is Route spec that has no Ingress equivalent —
weighted alternateBackends for blue/green traffic splitting, or a
re-encrypt destination CA managed alongside the Route. If you need those, create
the Route objects yourself outside the chart; an optional native Route template
is tracked in #3732.
No CI coverage against a real OpenShift SCC
Section titled “No CI coverage against a real OpenShift SCC”helm:install and helm:netpol (the chart’s install and NetworkPolicy
enforcement drills) run on plain kind, which admits any pod regardless of UID
— it has no SCC concept at all. Nothing in CI currently re-runs either drill
under an SCC-enforcing cluster, so a future change to podSecurityContext,
containerSecurityContext, or either subchart’s statefulset.yaml could
silently reintroduce an OpenShift regression with every existing gate still
green.
Proposed follow-up
Section titled “Proposed follow-up”The gaps above are tracked as chart issues:
- #3731 — make the bundled
postgresql/valkeysubcharts’securityContextconfigurable (exposerunAsUser/runAsGroup/fsGroupas subchart values, defaulting to today’s999so nothing changes for existing installs) so a project with a permissive-enough SCC — or a futurerestricted-v2-compatible image — can run them. Today there is no override at all. - #3732 — an optional native
Routetemplate (route.enabled, gated off by default) for operators who need Route spec the Ingress conversion cannot express, such as weighted backends. - #3733 — an
OpenShift/CRC-equivalent CI drill alongside
helm:install/helm:netpolso arestricted-v2regression fails a pipeline instead of waiting for an operator to hit it — the gap this page’s own “audited, not cluster-verified” caveat exists to name.
Related
Section titled “Related”- Deployment — the full Compose / Helm / systemd install paths
- Networking — DNS, TLS topologies, and the ports/firewall matrix this page does not repeat
- Helm Values Reference — every chart key, including
podSecurityContext,containerSecurityContext, andingress.* - Deployment Sizing — replica counts and resource profiles, unchanged on OpenShift