Skip to content

Add Helm chart for Kubernetes deployment of Plane - #2

Open
crewletbot wants to merge 3 commits into
previewfrom
claude/plane-helm-chart
Open

Add Helm chart for Kubernetes deployment of Plane#2
crewletbot wants to merge 3 commits into
previewfrom
claude/plane-helm-chart

Conversation

@crewletbot

@crewletbot crewletbot commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds a Helm chart at deployments/helm/plane for deploying Plane (community edition) on Kubernetes, from the images this repository publishes to GHCR. It packages the Plane components (web, space, admin, live, api, worker, beat) behind the Caddy proxy, with optional Valkey and RabbitMQ statefulsets, into one configurable release.

Upstream links to makeplane's chart on Artifact Hub. To be clear about why this one exists, since that chart is more configurable than it first appears: it does support an external Postgres (postgres.local_setup: false), real S3 instead of MinIO (minio.local_setup: false), and no ingress (ingress.enabled: false). What it has no extension point for is a service account annotation — templates/service-account.yaml accepts none, so there is no way to reach S3 through a workload identity role rather than static keys — or an externally managed image pull secret, which private registry images need. It also splits the image across seven separate values, where this chart takes one.

Notable design points:

  • One image reference drives six images. All six component images are published side by side under a single registry namespace, so image.repository is treated as a prefix that each component completes with its own suffix (-backend, -frontend, -space, -admin, -live, -proxy). A single image.repository/image.tag pair therefore configures the whole release, which is what lets a generic GitOps Application template — one that only knows how to inject a single image reference — drive a multi-image chart. Setting image on an individual component still pins that one elsewhere.
  • Routing. Caddy keeps the path split from apps/proxy/Caddyfile.ce (/spaces, /god-mode, /live, /api, /auth, /static, everything else to web), minus the MinIO route. It listens on 8080 rather than 80 so it needs no NET_BIND_SERVICE capability. TLS terminates upstream; Django reads X-Forwarded-Proto, which Caddy only forwards from a peer in proxy.trustedProxies.
  • Migrations. manage.py migrate runs as a pre-install,pre-upgrade Helm hook, which Argo CD maps onto its PreSync phase, so the schema is current before any new api, worker or beat pod starts.
  • Cache and broker, three ways. The chart's own statefulsets; a plaintext endpoint in values; or — with the statefulsets disabled and nothing in values — REDIS_URL and AMQP_URL arriving through extraEnvFrom. That last path is what a managed cache or broker needs, since its URL carries credentials and belongs in a secret. Both TLS schemes involved are already handled: django-redis and redis-py branch on rediss://, ioredis enables TLS from the scheme alone, and kombu speaks amqps://.
  • Security contexts. Capabilities are dropped by default. The four components that shed root themselves get back only what that transition needs: web and admin (nginx hands its workers to the nginx user) keep SETUID/SETGID; redis and rabbitmq (entrypoints chown the data dir and gosu into the service user) additionally keep CHOWN, DAC_OVERRIDE and FOWNER. Dropping ALL on those four stops them booting, so they are deliberately not uniform.
  • Secrets. Non-secret settings render into a ConfigMap; secret material is either rendered by the chart (secrets.create: true, guarded so a missing required value fails the render) or consumed from an external store via extraEnvFrom.

Also included is a one-line settings fix that the chart depends on. AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY defaulted to the literal strings access-key and secret-key. The workspace export task and the expired-export cleanup task both build their own boto3 client from those Django settings, so with the variables unset they handed S3 a placeholder key and got InvalidAccessKeyId instead of falling through to boto3's credential chain — which is exactly how a deployment on an instance profile or a service account role is configured. S3Storage already read os.environ directly with no default, so uploads worked while exports did not. The placeholders only ever fitted the bundled MinIO, and both compose files set the pair explicitly, so nothing that relied on them loses it.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Feature (non-breaking change which adds functionality)
  • Documentation update

Screenshots and Media (if applicable)

n/a — no user-facing UI change.

Test Scenarios

Chart:

  • helm lint — clean.
  • helm template with chart defaults, and again with a GitOps-style values overlay. Rendered manifests were parsed and asserted on: the six image references resolve per component, the service account annotation lands, no static AWS credentials are emitted (so boto3 falls through to the role), and the ConfigMap/secret wiring is correct.
  • helm template with secrets.create=true, including the guards that fail the render when a required secret value is missing.
  • All three cache/broker paths rendered and asserted: in-cluster statefulsets, plaintext external endpoints, and secret-supplied URLs (which must emit no Redis/RabbitMQ keys in the ConfigMap at all).
  • Confirmed the documentation pass over the chart left rendered output byte-identical, by diffing parsed YAML before and after.

Settings change — imported plane.settings.common under three environments and asserted on the result:

Environment AWS_ACCESS_KEY_ID Why it matters
unset None boto3 reaches the instance/service-account role
empty string None an explicitly blank var is treated as unset
access-key / secret-key set passed through unchanged the MinIO path keeps working

And the consequence, against boto3 itself: a client built with aws_access_key_id=None resolves credentials from the default chain, whereas one built with the placeholder strings resolves to access-key — which is the bug, the placeholder shadowing the role.

Note for reviewers: no workflow in this repository runs the Django/pytest suite — Lint API is ruff check only — so the checks on this PR do not exercise apps/api behaviour. The assertions above were run locally in place of that; the full suite (docker-compose-test.yml) needs Docker, which was unavailable in the environment this was authored in.

References

aliyousefiaan and others added 3 commits August 17, 2026 20:40
Upstream ships compose, swarm and AIO deployments plus a link to
makeplane's chart on Artifact Hub, but nothing that fits how this fork is
actually run: a shared RDS instance instead of a bundled Postgres, S3 with
IRSA instead of MinIO, and ingress through a Cloudflare Tunnel instead of a
load balancer.

The chart deploys all six images this repository publishes to GHCR --
proxy, web, space, admin, live and backend (as api, worker, beat and a
migration job) -- plus the Valkey and RabbitMQ they depend on. Because the
images sit side by side under one namespace, image.repository is treated as
a prefix that each component completes with its own suffix; a single
repository/tag pair then configures all six, which is what lets a generic
GitOps Application template drive a multi-image chart.

Caddy keeps the path split from apps/proxy/Caddyfile.ce but drops the MinIO
route and listens on 8080, so it needs no NET_BIND_SERVICE capability. The
migration job runs as a pre-install/pre-upgrade hook, which Argo CD maps
onto PreSync, so the schema is current before any new pod starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY defaulted to the literal
strings "access-key" and "secret-key". Anything reading them through
django settings -- the workspace export task and the expired-export
cleanup task both build their own boto3 client that way -- therefore
handed S3 a placeholder key whenever the variables were unset, and got
InvalidAccessKeyId back instead of falling through to boto3's credential
chain.

That is exactly how a deployment on an instance profile or an EKS service
account role is configured: no keys in the environment at all. S3Storage
already got this right by reading os.environ directly with no default, so
uploads worked while exports did not.

The placeholders only ever fitted the bundled MinIO, and both compose
files set the pair explicitly, so nothing that relied on them loses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chart offered two ways to reach Redis and RabbitMQ: its own
statefulsets, or a plaintext endpoint in values. A managed cache or broker
fits neither -- its URL carries credentials, so it belongs in a secret, and
requiring externalRedis.url in values failed the render for a deployment
that supplies REDIS_URL through extraEnvFrom instead.

Disabling a statefulset without naming a replacement in values now simply
omits those keys from the ConfigMap, leaving the URL to arrive from the
secret. Plane already prefers AMQP_URL over the assembled RABBITMQ_* parts,
so the broker needs nothing further, and both TLS schemes these endpoints
use are understood already: django-redis and redis-py branch on rediss://,
ioredis turns TLS on from the scheme alone, and kombu speaks amqps://.

The other two paths are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants