From e502d2505df4fc97b03a578aa5a688872c4234a2 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Fri, 4 Sep 2026 15:02:07 +0530 Subject: [PATCH 1/2] docs(airgapped): correct and expand the Kubernetes guide Verified every value in this guide against plane-enterprise chart 3.5.7 (appVersion v3.1.4). Fixes for things that would fail if followed: - Chart version was pinned to 1.6.4 in four places (latest is 3.5.7). Now a single CHART_VERSION variable, with `helm repo add`/`helm pull` as the primary path and Artifact Hub as the version source. - Every service image placeholder was malformed (`image: /web-commercial`, and `image: /email-commercial:` with a trailing colon). The chart renders `:`, so these produced invalid references. Fixed to full internal-registry paths, with a warning that `services.*.image` takes a repo without a tag and that iframely is the sole exception. - busybox was missing from the transfer list. The chart runs BusyBox init containers for silo (wait-for-rabbitmq, unconditional), silo/live (prepare-ca-bundle, when an S3 CA secret is set) and the MinIO client job. Added an "Init container images" section covering the three init_image keys and recommending a pinned tag over the implicit busybox:latest. - dockerRegistry was never mentioned, so an authenticated internal registry left every pod in ImagePullBackOff. Added. - license.licenseDomain was never set, so the guide produced an instance on plane.example.com. Added. - The TLS example drove cert-manager at Let's Encrypt (and offered Cloudflare and DigitalOcean DNS issuers), none of which are reachable from an air gap. Rewritten around ssl.tls_secret_name from an internal CA, with ssl.externalTermination for upstream termination. - The chart's shipped placeholder secrets were copied into the page verbatim. Replaced with a "Rotate the default secrets" section covering secret_key, live_server_secret_key, the silo_envs keys, pi_envs.internal_secret and the bundled datastore credentials, with generate commands. - Valkey was listed as both 7.2.5-alpine and 7.2.11-alpine on the same page; the chart says 7.2.11-alpine. - OpenSearch was called out in two callouts but had no config block. Additions: - Staging-host vs airgapped-host labelling on every command, and an explicit step for transferring the chart archive, licence file and CA certificate. - env.storageClass guidance (airgapped clusters often have no default class, and PVCs otherwise sit Pending). - ingress.controller, which silently renders no ingress at all when the class name is not exactly nginx/openshift/traefik*. - pullPolicy: IfNotPresent rationale for airgapped registries. - Optional Plane AI (services.pi, services.runner) and hardened-cluster (securityContext) sections. - Real log commands in Verify; the page previously said "check the logs using the commands above" with no such commands. - An Upgrade section, noting licences are issued per Plane version. - A Troubleshooting section for ImagePullBackOff, Pending PVCs, migrator failures and the missing-ingress case. Structure: airgapped-specific frontmatter description (it was a byte-for-byte copy of kubernetes.md), title and H1 aligned, consistent "airgapped" spelling, a/b/c/d sub-steps promoted to headings, and relative links with descriptive text. Co-Authored-By: Claude Opus 5 (1M context) --- .../methods/airgapped-edition-kubernetes.md | 811 ++++++++++++------ 1 file changed, 566 insertions(+), 245 deletions(-) diff --git a/docs/self-hosting/methods/airgapped-edition-kubernetes.md b/docs/self-hosting/methods/airgapped-edition-kubernetes.md index baac2328..f5e5d13e 100644 --- a/docs/self-hosting/methods/airgapped-edition-kubernetes.md +++ b/docs/self-hosting/methods/airgapped-edition-kubernetes.md @@ -1,31 +1,44 @@ --- -title: Deploy Plane with airgapped Kubernetes -description: Deploy Plane on Kubernetes using Helm charts. Complete guide for production-ready Kubernetes deployment with scaling and management. +title: Deploy Plane airgapped on Kubernetes +description: Deploy Plane Commercial in an airgapped Kubernetes cluster with Helm. Mirror images to an internal registry, configure offline values, install, and activate a license without internet access. keywords: plane airgapped kubernetes, offline k8s deployment, air-gapped helm, kubernetes offline, plane helm airgapped, self-hosting --- -# Deploy Plane Airgapped on Kubernetes +# Deploy Plane airgapped on Kubernetes ::: info Airgapped deployments are available exclusively for Enterprise Grid customers with a minimum commitment of 100 seats. Contact our [Sales team](mailto:sales@plane.so) for trials, exceptions to the seat cut-off, tailored pricing, and licensing info. ::: -This guide walks you through deploying Plane Commercial in an airgapped Kubernetes environment using Helm charts and pre-packaged Docker images. +This guide walks you through deploying Plane Commercial in an airgapped Kubernetes cluster using the `plane-enterprise` Helm chart and images mirrored to your internal registry. + +Read [Airgapped deployment architecture](/self-hosting/methods/airgapped-requirements) first. It covers the registry, ingress, storage, certificate, and secret-management requirements that this guide assumes are already in place. ## What you'll need -Before starting, ensure you have: +You will work across two machines: + +| Machine | Connectivity | Used for | +| ------------------ | ------------------ | ---------------------------------------------------------------------------- | +| **Staging host** | Internet access | Downloading the Helm chart and mirroring images into your internal registry. | +| **Airgapped host** | No internet access | Running `helm` and `kubectl` against the target cluster. | + +Every command below is labeled with the host it runs on. + +On the cluster side, make sure you have: - Kubernetes cluster (v1.31 - v1.33) -- Helm 3.x installed -- `kubectl` configured to access your cluster -- `cert-manager` available in the cluster -- A valid and working ingress controller (nginx, traefik, etc) +- Helm 3.x and `kubectl` configured to reach the cluster **from the airgapped host** +- An internal OCI/container registry reachable from every node, holding the mirrored images +- A `StorageClass` you can name explicitly — airgapped clusters often have no default, and PVCs will sit in `Pending` without one +- A working ingress controller (`ingress-nginx`, Traefik, or OpenShift Routes) +- A TLS certificate issued by your internal CA, loaded into the cluster as a Secret +- `cert-manager` **only if** you issue certificates in-cluster. Its images must be mirrored too, and it must be configured with an internal CA issuer — public ACME and DNS-01 issuers are unreachable from an air gap - Required ports opened to access the application (80, 443) - SMTP ports opened if using email intake (25, 465, 587) ::: warning -While Kubernetes can run stateful services with persistent volumes, and Plane's Helm chart supports deploying PostgreSQL, MinIO, RabbitMQ, and Redis, we strongly recommend using external managed services for better reliability in backup/restore operations and disaster recovery. +While Kubernetes can run stateful services with persistent volumes, and Plane's Helm chart supports deploying PostgreSQL, MinIO, RabbitMQ, OpenSearch, and Redis, we strongly recommend using external managed services for better reliability in backup/restore operations and disaster recovery. Consider these alternatives: @@ -36,257 +49,565 @@ Consider these alternatives: - **OpenSearch**: Use a managed OpenSearch service ::: -## Install Plane +## Step 1: Download the Helm chart + +**On the staging host.** + +Set the versions and registry once, and reuse them throughout: + +```bash +# Chart version - check Artifact Hub for the latest +export CHART_VERSION=3.5.7 + +# Plane version - copy this from prime.plane.so. It must match the version +# your license was issued for. +export PLANE_VERSION=v3.1.4 + +# Where you will mirror the images +export DESTINATION_REGISTRY=registry.internal.example.com/plane +``` + +::: tip +Check the latest chart version on [Artifact Hub](https://artifacthub.io/packages/helm/makeplane/plane-enterprise). Each chart ships a default `planeVersion` (chart `3.5.7` ships `v3.1.4`), but you should set `planeVersion` explicitly to the version your license covers rather than relying on the chart default. +::: + +Pull the chart archive: + +```bash +helm repo add plane https://helm.plane.so/ +helm repo update +helm pull plane/plane-enterprise --version "${CHART_VERSION}" +``` + +Or download the release artifact directly: + +```bash +curl -L -O "https://github.com/makeplane/helm-charts/releases/download/plane-enterprise-${CHART_VERSION}/plane-enterprise-${CHART_VERSION}.tgz" +``` + +## Step 2: Mirror the images + +**On the staging host.** + +Follow [Clone Docker images to your private registry](/self-hosting/methods/clone-docker-images) to copy images from the Plane artifact registry into your internal registry. + +The Kubernetes chart does not use every image in that list, and it needs a few that are not in it. Mirror the following: + +**Plane services — always required** + +```text +makeplane/backend-commercial:${PLANE_VERSION} # api, workers, consumers, migrator +makeplane/web-commercial:${PLANE_VERSION} +makeplane/space-commercial:${PLANE_VERSION} +makeplane/admin-commercial:${PLANE_VERSION} +makeplane/live-commercial:${PLANE_VERSION} +makeplane/monitor-commercial:${PLANE_VERSION} +makeplane/silo-commercial:${PLANE_VERSION} +``` + +**Plane services — only if you enable them** + +```text +makeplane/email-commercial:${PLANE_VERSION} # services.email_service (email intake) +makeplane/iframely:v1.2.0 # services.iframely (link previews) +makeplane/plane-pi-commercial:${PLANE_VERSION} # services.pi (Plane AI) +makeplane/node-runner-commercial:${PLANE_VERSION} # services.runner (Plane AI) +``` + +::: warning +**`busybox` is required and is not in the clone list.** The chart runs BusyBox init containers for: + +- `silo` — a `wait-for-rabbitmq` init container that runs on **every** install +- `silo` and `live` — a `prepare-ca-bundle` init container, rendered when `airgapped.enabled` is set together with an S3 CA secret +- `minio` — an init container on the MinIO client job, when `services.minio.local_setup` is `true` + +The chart defaults these to `busybox` (implicitly `busybox:latest`). Mirror a pinned tag and point the `init_image` keys at it, as shown in [Init container images](#init-container-images). +::: + +**Infrastructure images — only for services you run in-cluster (`local_setup: true`)** + +```text +valkey/valkey:7.2.11-alpine +postgres:15.7-alpine +rabbitmq:3.13.6-management-alpine +minio/minio:latest +minio/mc:latest +opensearchproject/opensearch:3.3.2 +busybox:1.36 +``` + +::: info +The Plane artifact registry does not host these infrastructure images. Pull them from their public registries on the staging host and mirror them yourself. If you point the chart at managed PostgreSQL, Redis/Valkey, RabbitMQ, OpenSearch, and S3, you only need `busybox` from this list. +::: + +`proxy-commercial` is used by the Docker Compose deployment only — the Kubernetes chart routes traffic through your ingress controller instead, so you do not need to mirror it for this install. + +## Step 3: Transfer artifacts into the air gap + +Move the following across the gap by whatever method your security policy allows: + +- `plane-enterprise-${CHART_VERSION}.tgz` +- The license file for `${PLANE_VERSION}`, downloaded from the [Prime portal](https://prime.plane.so/licenses) (see [Activate your license](#step-6-activate-your-license)) +- Your internal CA certificate, if the API and `live` services need to trust a private S3 endpoint + +The mirrored images do not need transferring separately if your internal registry is already reachable from the cluster. + +## Step 4: Configure your values file + +**On the airgapped host.** + +Extract the chart defaults into a file you can edit and keep under version control: + +```bash +helm show values "plane-enterprise-${CHART_VERSION}.tgz" > custom-values.yaml +``` + +The sections below cover the values that matter for an airgapped install. For the full reference, see [Configuration settings](/self-hosting/methods/kubernetes#configuration-settings). + +### Version, license domain, and airgapped mode + +```yaml +# The Plane version to deploy. Must match the version your license was issued for. +planeVersion: v3.1.4 + +license: + # Required. The FQDN the license is bound to. This is also used as the + # ingress host, so this is how you reach Plane. + licenseDomain: plane.internal.example.com + +# Enable airgapped mode (REQUIRED) +airgapped: + enabled: true + # Optional: CA certificates the API and live services should trust when + # talking to an internal S3-compatible endpoint. Create each Secret first: + # kubectl -n plane create secret generic plane-s3-ca \ + # --from-file=s3-custom-ca.crt=/path/to/ca.crt + s3Secrets: + - name: plane-s3-ca + key: s3-custom-ca.crt +``` + +::: info +`license.licenseServer` defaults to `https://prime.plane.so`, which is unreachable from an air gap. With `airgapped.enabled: true`, licensing is handled entirely by the license file you upload in [step 6](#step-6-activate-your-license) — leave `licenseServer` at its default and do not attempt to make it reachable. +::: + +### Private registry credentials + +If your internal registry requires authentication, the chart needs an image pull secret. Without this, every pod fails with `ImagePullBackOff`. + +Either let the chart create the secret: + +```yaml +dockerRegistry: + enabled: true + registry: registry.internal.example.com + loginid: + password: +``` + +Or reference a `kubernetes.io/dockerconfigjson` Secret you manage yourself: + +```yaml +dockerRegistry: + enabled: true + existingSecret: plane-registry-creds +``` + +### Service images + +Point every service at your internal registry. + +::: warning +`services..image` is a repository **without a tag** — the chart appends `planeVersion` as the tag itself. Adding a tag (or leaving a trailing `:`) produces an invalid image reference, and the pod will never start. + +`services.iframely.image` is the one exception: it carries its own tag, because it is versioned independently of Plane. +::: + +```yaml +services: + web: + image: registry.internal.example.com/plane/web-commercial + + api: + image: registry.internal.example.com/plane/backend-commercial + + space: + image: registry.internal.example.com/plane/space-commercial + + admin: + image: registry.internal.example.com/plane/admin-commercial + + live: + image: registry.internal.example.com/plane/live-commercial + + monitor: + image: registry.internal.example.com/plane/monitor-commercial + + silo: + enabled: true + image: registry.internal.example.com/plane/silo-commercial + + # Optional services - disabled by default in the chart + email_service: + enabled: true + image: registry.internal.example.com/plane/email-commercial + + iframely: + enabled: true + image: registry.internal.example.com/plane/iframely:v1.2.0 # tag included +``` + +`api`, `worker`, `worker_importers`, `beatworker`, `external_api`, `outbox_poller`, and the consumer and migration workloads all share `services.api.image`, so you only set it once. + +### Init container images + +Override the three `init_image` keys so init containers pull from your registry with a pinned tag instead of `busybox:latest`: + +```yaml +services: + silo: + init_image: registry.internal.example.com/plane/busybox:1.36 + live: + init_image: registry.internal.example.com/plane/busybox:1.36 + minio: + init_image: registry.internal.example.com/plane/busybox:1.36 +``` + +Any override must be BusyBox-compatible — the init scripts need `/bin/sh`, `grep`, `nslookup`, `sleep`, `ls`, `cat`, and `touch`. + +### Image pull policy + +The chart defaults Plane's application services to `pullPolicy: Always`. In an airgapped cluster that means every pod restart re-hits your internal registry and hard-fails when it is briefly unavailable. Prefer `IfNotPresent`: + +```yaml +services: + web: + pullPolicy: IfNotPresent + api: + pullPolicy: IfNotPresent + # ...repeat for space, admin, live, monitor, silo, email_service, iframely +``` + +### Storage class + +Airgapped clusters frequently have no default `StorageClass`. Name yours explicitly, otherwise the PVCs for the bundled datastores stay `Pending` and the install times out: + +```yaml +env: + storageClass: +``` + +Confirm the name with `kubectl get storageclass`. + +### Infrastructure services + +For each datastore, either run it in-cluster (`local_setup: true`, using a mirrored image) or point Plane at a managed service reachable inside your network. + +```yaml +services: + redis: + local_setup: true # false if using an external service + image: valkey/valkey:7.2.11-alpine + + postgres: + local_setup: true # false if using an external service + image: postgres:15.7-alpine + + rabbitmq: + local_setup: true # false if using an external service + image: rabbitmq:3.13.6-management-alpine + external_rabbitmq_url: "" # required only when local_setup is false + + minio: + local_setup: true # false if using S3 or another S3-compatible service + image: minio/minio:latest + image_mc: minio/mc:latest + + opensearch: + local_setup: false # chart default; true to run OpenSearch in-cluster + image: opensearchproject/opensearch:3.3.2 + +env: + # Required when the corresponding local_setup is false + remote_redis_url: "" + pgdb_remote_url: "" + + # Required when minio.local_setup is false + aws_access_key: "" + aws_secret_access_key: "" + aws_region: "" + aws_s3_endpoint_url: "" + + # Required when opensearch.local_setup is false + opensearch_remote_url: "" + opensearch_remote_username: "" + opensearch_remote_password: "" +``` + +::: tip +If your internal S3 endpoint presents a certificate from a private CA, set `airgapped.s3Secrets` as shown above. The `boto` client used by the API does not read the container's CA bundle, so the certificate has to be supplied this way. +::: + +### Ingress and TLS + +```yaml +ingress: + enabled: true + # Selects WHICH KIND of ingress resource is rendered: nginx, traefik, or openshift. + controller: nginx + # The class name written to the Ingress. Free-form. + ingressClass: nginx + ingress_annotations: + { "nginx.ingress.kubernetes.io/proxy-body-size": "5m", "nginx.ingress.kubernetes.io/proxy-buffer-size": "16k" } + +ssl: + # A TLS Secret holding the certificate issued by your internal CA. + tls_secret_name: plane-tls + createIssuer: false + generateCerts: false +``` + +::: warning +**Do not use the chart's Let's Encrypt path in an air gap.** `ssl.createIssuer` with `generateCerts: true` drives cert-manager against `https://acme-v02.api.letsencrypt.org/directory`, and the `cloudflare` and `digitalocean` issuers call out to those providers' DNS APIs. None of these are reachable from an isolated network, and the install will stall waiting for a certificate that never issues. + +Instead, issue the certificate from your internal PKI, load it as a Secret, and reference it with `ssl.tls_secret_name`. If TLS is terminated in front of Plane by an internal load balancer, set `ssl.externalTermination: true` instead so the app still builds `https://` URLs for itself. +::: + +::: warning +If `ingress.controller` is empty and your `ingressClass` is not exactly `nginx`, `openshift`, or `traefik*`, **the chart renders no ingress at all and reports no error**. Set `ingress.controller` explicitly whenever your class name is anything else — for example `controller: nginx` with `ingressClass: nginx-internal`. +::: + +### Rotate the default secrets + +The chart ships working placeholder values for several secrets so that a first install succeeds. Every one of them is public — replace all of them before you go live: + +```bash +openssl rand -hex 32 # for secret_key, live_server_secret_key, hmac_secret_key, internal_secret +openssl rand -hex 16 # for aes_secret_key - must be exactly 32 characters +``` + +```yaml +env: + secret_key: + live_server_secret_key: + silo_envs: + hmac_secret_key: + aes_secret_key: # exactly 32 characters + cursor_webhook_secret: + pi_envs: + internal_secret: # only if Plane AI is enabled + + # Only when running the bundled datastores + pgdb_username: + pgdb_password: + +services: + rabbitmq: + default_user: + default_password: + minio: + root_user: + root_password: + opensearch: + username: + # Min 8 chars with an uppercase, lowercase, digit, and special character + password: +``` + +::: tip +Rather than keeping these in `custom-values.yaml`, you can point the chart at Secrets you manage with Vault, External Secrets Operator, or sealed-secrets using the `external_secrets.*_existingSecret` keys. See [External Secrets Config](/self-hosting/methods/kubernetes#external-secrets-config) and the secrets-management section of [Airgapped deployment architecture](/self-hosting/methods/airgapped-requirements). +::: + +### Integrations and importers (optional) + +Integrations point at your **internal** GitHub Enterprise, GitLab, or Slack-compatible instances — no SaaS endpoint is contacted. Configure them under `services.silo.connectors`: + +```yaml +services: + silo: + enabled: true + connectors: + slack: + enabled: false + client_id: "" + client_secret: "" + base_url: "" + signing_secret: "" + github: + enabled: false + client_id: "" + client_secret: "" + app_name: "" + app_id: "" + private_key: "" + webhook_secret: "" + gitlab: + enabled: false + client_id: "" + client_secret: "" + +env: + silo_envs: + batch_size: 100 + mq_prefetch_count: 1 + request_interval: 400 +``` + +The chart also supports `sentry`, `bitbucket`, and `hubspot` connectors under the same key. + +### Email intake (optional) + +Email intake lets Plane capture incoming email as work items. Configure DNS first, following [Configure DNS for the email service](/self-hosting/govern/configure-dns-email-service). + +```yaml +services: + email_service: + enabled: true + replicas: 1 + image: registry.internal.example.com/plane/email-commercial + pullPolicy: IfNotPresent + memoryLimit: 1000Mi + cpuLimit: 500m + memoryRequest: 128Mi + cpuRequest: 100m + +env: + email_service_envs: + smtp_domain: mail.internal.example.com +``` + +### Plane AI (optional) + +`services.pi` and `services.runner` are disabled by default. If you enable them, mirror `plane-pi-commercial` and `node-runner-commercial`, and point Plane AI at an LLM and embedding endpoint hosted **inside** your network — see [Configure Plane AI](/self-hosting/govern/plane-ai/configure-plane-ai) and [Configure an embedding model](/self-hosting/govern/plane-ai/configure-embedding-model). + +```yaml +services: + pi: + enabled: true + image: registry.internal.example.com/plane/plane-pi-commercial + runner: + enabled: true + image: registry.internal.example.com/plane/node-runner-commercial +``` + +### Hardened clusters (optional) + +If your cluster enforces the Pod Security Admission `restricted` profile, enable the chart's hardened security context: + +```yaml +securityContext: + enabled: true +``` + +This applies a non-root (uid 1000) pod and container security context to all first-party Plane workloads. It does not cover the bundled third-party datastores — run those externally in hardened clusters. For OpenShift, which assigns UIDs itself, the chart ships `examples/values-openshift.yaml`. + +## Step 5: Install + +**On the airgapped host.** + +```bash +helm upgrade plane-app "plane-enterprise-${CHART_VERSION}.tgz" \ + --install \ + --create-namespace \ + --namespace plane \ + -f custom-values.yaml \ + --timeout 20m \ + --wait \ + --wait-for-jobs +``` + +A first airgapped install pulls every image from your internal registry and runs the database migrations, so allow more time than a connected install would need. + +### Verify the installation + +```bash +# Pods - everything should reach Running or Completed +kubectl get pods -n plane + +# Services and ingress +kubectl get services -n plane +kubectl get ingress -n plane -o wide + +# Persistent volumes - watch for PVCs stuck in Pending +kubectl get pv,pvc -n plane +``` + +Check the logs of the migration job and the API. Workload pods are labeled `app.name=--`, so with namespace `plane` and release `plane-app`: + +```bash +# Database migrations +kubectl get jobs -n plane +kubectl logs -n plane -l app.name=plane-plane-app-api-migrate --tail=200 + +# API +kubectl logs -n plane -l app.name=plane-plane-app-api --tail=100 -f +``` + +Once the migration job shows `Completed` and the API is serving, open `https://` in a browser and create the first admin account. + +## Step 6: Activate your license + +Follow [Activate Airgapped Edition license](/self-hosting/manage/manage-licenses/activate-airgapped) to upload the license file you transferred in step 3. + +## Upgrade an airgapped install + +An upgrade is the same flow as an install, repeated for the new version: + +1. **On the staging host** — set the new `PLANE_VERSION` and `CHART_VERSION`, and mirror the new image tags into your internal registry. Old tags are not reused. +2. **Download a new license file.** Licenses are issued per Plane version, so a version bump needs a fresh download from the [Prime portal](https://prime.plane.so/licenses). +3. **Transfer** the new chart archive and license file across the gap. +4. **On the airgapped host** — take a backup (see [Backup and restore](/self-hosting/manage/backup-restore)), update `planeVersion` in `custom-values.yaml`, and re-run the `helm upgrade` command from step 5 with the new chart archive. +5. **Re-upload the license file** if activation prompts for it. + +Never set `planeVersion: stable` — always pin the exact version your license covers. + +## Troubleshooting -1. **Download Plane Enterprise Helm chart** +**Pods stuck in `ImagePullBackOff` or `ErrImagePull`** - Get the Plane Enterprise Helm chart from the official release. Check for the latest version at [Artifact Hub](https://artifacthub.io/packages/helm/makeplane/plane-enterprise). +```bash +kubectl describe pod -n plane | tail -20 +``` - ```bash - # Using wget - wget https://github.com/makeplane/helm-charts/releases/download/plane-enterprise-1.6.4/plane-enterprise-1.6.4.tgz +Check, in order: - # Using curl - curl -L -O https://github.com/makeplane/helm-charts/releases/download/plane-enterprise-1.6.4/plane-enterprise-1.6.4.tgz - ``` +- The image reference resolves in your registry: `crane manifest /:` +- `services..image` has **no** tag on it (the chart appends `:planeVersion`), and no trailing `:` +- `dockerRegistry` is configured, if your registry needs authentication +- `busybox` is mirrored and the `init_image` keys point at it — an init container failure keeps the pod in `Init:ImagePullBackOff` +- Nodes can reach the registry, and trust its certificate if it is served from a private CA -2. **Prepare Docker images for airgapped environment** +**PVCs stuck in `Pending`** - Refer to [this document](/self-hosting/methods/clone-docker-images) to download the Docker images from the public repository to your internal repository. +```bash +kubectl get pvc -n plane +kubectl get storageclass +``` - ::: info - This process will NOT download or clone these infrastructure images: - - `valkey:7.2.5-alpine` - - `postgres:15.7-alpine` - - `rabbitmq:3.13.6-management-alpine` - - `minio/minio:latest` - - `minio/mc:latest` - - `opensearchproject/opensearch:3.3.2` +Set `env.storageClass` to a class that exists, or run the datastores externally and set the corresponding `local_setup: false`. - If you're using `local_setup: true` for any of these services, you'll need to pull and transfer these images separately. - ::: +**Migration job fails or retries** -3. **Configure custom values file** +```bash +kubectl logs -n plane -l app.name=plane-plane-app-api-migrate --tail=200 +``` - a. Extract the default values from the Helm chart. +Most failures here are database connectivity: check `env.pgdb_remote_url` (or the bundled Postgres pod), and that the database user can create schema objects. - ```bash - helm show values plane-enterprise-1.6.4.tgz > custom-values.yaml - ``` +**No ingress created, no error reported** - b. Update Docker image references +```bash +kubectl get ingress -n plane +``` - Edit the `custom-values.yaml` file to point to your local or private registry images and configure important settings. +If this is empty, set `ingress.controller` explicitly — see the warning in [Ingress and TLS](#ingress-and-tls). - **Basic configuration:** +**`live` or `silo` crash-looping against internal S3** - ```yaml - # Specify the Plane version - planeVersion: +Confirm `airgapped.enabled: true` and that `airgapped.s3Secrets` references a Secret that exists in the `plane` namespace with the key name you specified. - # Enable airgapped mode (REQUIRED) - airgapped: - enabled: true # Must be TRUE for airgapped installations - # If using custom root CA for S3 storage - s3Secrets: - - name: plane-s3-ca - key: s3-custom-ca.crt - - name: plane-s3-ca-2 - key: s3-custom-ca-2.crt - ``` - - **Service images:** - - ```yaml - services: - web: - image: /web-commercial - - api: - image: /backend-commercial - - space: - image: /space-commercial - - admin: - image: /admin-commercial - - live: - image: /live-commercial - - monitor: - image: /monitor-commercial - - email_service: - enabled: true - image: /email-commercial - - silo: - enabled: true - image: /silo-commercial - - iframely: - enabled: true - image: /iframely:v1.2.0 - ``` - - **Infrastructure services:** - - Configure whether to use local (in-cluster) or external services: - - ```yaml - services: - # Database and infrastructure images - redis: - local_setup: true # Set to false if using external service - image: valkey/valkey:7.2.11-alpine - - postgres: - local_setup: true # Set to false if using external service - image: postgres:15.7-alpine - - rabbitmq: - local_setup: true # Set to false if using external service - image: rabbitmq:3.13.6-management-alpine - external_rabbitmq_url: "" # Required only if using remote RabbitMQ - - minio: - local_setup: true # Set to false if using external service - image: minio/minio:latest - image_mc: minio/mc:latest - ``` - - **Environment variables:** - - ```yaml - env: - storageClass: "" - remote_redis_url: "" # Required only if using remote Redis - pgdb_remote_url: "" # Required only if using remote PostgreSQL - # Required if MinIO local_setup is false - aws_access_key: "" - aws_secret_access_key: "" - aws_region: "" - aws_s3_endpoint_url: "" - ``` - - c. **Configure integrations and importers** - - To set up integrations with external systems like Slack, GitHub, and GitLab, configure these values in `custom-values.yaml`: - - ```yaml - services: - silo: - enabled: true - connectors: - slack: - enabled: false - client_id: "" - client_secret: "" - github: - enabled: false - client_id: "" - client_secret: "" - app_name: "" - app_id: "" - private_key: "" - gitlab: - enabled: false - client_id: "" - client_secret: "" - - env: - silo_envs: - batch_size: 100 - mq_prefetch_count: 1 - request_interval: 400 - hmac_secret_key: "" - aes_secret_key: "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr" - ``` - - d. **Configure intake email** - - The email intake feature in Plane lets you capture incoming emails. Before or after setting up the application, configure DNS settings following [this guide](https://developers.plane.so/self-hosting/govern/configure-dns-email-service). - - Add these required values to `custom-values.yaml`: - - ```yaml - ingress: - enabled: true - ingressClass: 'nginx' # Or as per your cluster - ingress_annotations: {} - - ssl: - tls_secret_name: '' # If you have a custom TLS secret name - # If you want to use Let's Encrypt, set createIssuer and generateCerts to true - createIssuer: false - issuer: http # Allowed: cloudflare, digitalocean, http - token: '' # Not required for http - server: https://acme-v02.api.letsencrypt.org/directory - email: plane@example.com # A valid email address - generateCerts: true - - services: - email_service: - enabled: true - replicas: 1 - memoryLimit: 1000Mi - cpuLimit: 500m - memoryRequest: 50Mi - cpuRequest: 50m - image: /email-commercial: - pullPolicy: Always - nodeSelector: {} - tolerations: [] - affinity: {} - labels: {} - annotations: {} - - env: - email_service_envs: - smtp_domain: '' - ``` - -4. **Install or upgrade with custom values** - - Install Plane Enterprise using your customized values file: - - ```bash - helm upgrade plane-app plane-enterprise-1.6.4.tgz \ - --install \ - --create-namespace \ - --namespace plane \ - -f custom-values.yaml \ - --timeout 10m \ - --wait \ - --wait-for-jobs - ``` - -5. **Verify installation** - - Check that all components are running: - - ```bash - # Check all pods - kubectl get pods -n plane - - # Check services - kubectl get services -n plane - - # Check ingress - kubectl get ingress -n plane - - # Check persistent volumes - kubectl get pv,pvc -n plane - - # Get the ingress URL - kubectl get ingress -n plane -o wide - ``` - - You now have Plane running in your air-gapped environment. If you run into any issues, check the logs using the commands above, or reach out to our support team for assistance. - -6. [Activate your license key](/self-hosting/manage/manage-licenses/activate-airgapped). +For more, see [Troubleshooting](/self-hosting/troubleshoot/installation-errors), or reach out to our support team. ## Additional configuration -For more advanced Plane configuration options, refer to the [Kubernetes documentation](https://developers.plane.so/self-hosting/methods/kubernetes#configuration-settings). +For the full list of chart values, see [Configuration settings](/self-hosting/methods/kubernetes#configuration-settings). From 5d3b2f38c3f91a48500bebf63b3e5a1eadf81a5e Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Fri, 4 Sep 2026 15:42:20 +0530 Subject: [PATCH 2/2] docs(airgapped): address review feedback on the Kubernetes guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pull policy: the IfNotPresent list omitted services.pi and services.runner. The chart has no global pull-policy value and both default to Always, so a cached Plane AI image could still fail to restart while the registry was unreachable. Now spells out every enabled service, and notes the bundled datastores already default to IfNotPresent. - cert-manager: this guide scoped it to in-cluster issuance while airgapped-requirements.md listed it as a required component. Both pages now state the two supported paths — a pre-issued certificate loaded as a TLS Secret and referenced with ssl.tls_secret_name, or cert-manager with an internal CA issuer — and that public ACME and DNS-01 issuers are unreachable either way. - Credentials: the guide told readers to keep custom-values.yaml under version control while showing inline credential fields. It now marks those fields as placeholders and directs real values to Kubernetes Secrets via dockerRegistry.existingSecret and the external_secrets.*_existingSecret keys, managed by Vault, ESO, or sealed-secrets. Connector client secrets and the GitHub App private key are called out at the integrations section too. Co-Authored-By: Claude Opus 5 (1M context) --- .../methods/airgapped-edition-kubernetes.md | 48 +++++++++++++++++-- .../methods/airgapped-requirements.md | 4 +- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/self-hosting/methods/airgapped-edition-kubernetes.md b/docs/self-hosting/methods/airgapped-edition-kubernetes.md index f5e5d13e..0e2f9371 100644 --- a/docs/self-hosting/methods/airgapped-edition-kubernetes.md +++ b/docs/self-hosting/methods/airgapped-edition-kubernetes.md @@ -32,8 +32,9 @@ On the cluster side, make sure you have: - An internal OCI/container registry reachable from every node, holding the mirrored images - A `StorageClass` you can name explicitly — airgapped clusters often have no default, and PVCs will sit in `Pending` without one - A working ingress controller (`ingress-nginx`, Traefik, or OpenShift Routes) -- A TLS certificate issued by your internal CA, loaded into the cluster as a Secret -- `cert-manager` **only if** you issue certificates in-cluster. Its images must be mirrored too, and it must be configured with an internal CA issuer — public ACME and DNS-01 issuers are unreachable from an air gap +- A TLS certificate for `license.licenseDomain`, issued by your internal CA. Either of two paths is supported: + - **Pre-issued certificate** — issue it from your internal PKI, load it into the `plane` namespace as a TLS Secret, and reference it with `ssl.tls_secret_name`. No `cert-manager` required. + - **`cert-manager` issuing in-cluster** — mirror its images, and configure it with an internal CA issuer. Public ACME and DNS-01 issuers are unreachable from an air gap, so the chart's Let's Encrypt path cannot be used either way. See [Ingress and TLS](#ingress-and-tls). - Required ports opened to access the application (80, 443) - SMTP ports opened if using email intake (25, 465, 587) @@ -156,7 +157,7 @@ The mirrored images do not need transferring separately if your internal registr **On the airgapped host.** -Extract the chart defaults into a file you can edit and keep under version control: +Extract the chart defaults into a file you can edit: ```bash helm show values "plane-enterprise-${CHART_VERSION}.tgz" > custom-values.yaml @@ -164,6 +165,19 @@ helm show values "plane-enterprise-${CHART_VERSION}.tgz" > custom-values.yaml The sections below cover the values that matter for an airgapped install. For the full reference, see [Configuration settings](/self-hosting/methods/kubernetes#configuration-settings). +::: warning Keep credentials out of this file +`custom-values.yaml` belongs in version control, so treat every credential field shown below as a **placeholder**. Do not commit registry passwords, database credentials, connector client secrets, GitHub App private keys, or the generated application secrets into it. + +Create those as Kubernetes Secrets out of band and have the chart read them: + +- **Registry credentials** — `dockerRegistry.existingSecret` +- **Everything else** — the `external_secrets.*_existingSecret` keys: `pgdb_existingSecret`, `rabbitmq_existingSecret`, `opensearch_existingSecret`, `doc_store_existingSecret`, `app_env_existingSecret`, `live_env_existingSecret`, `silo_env_existingSecret`, `pi_api_env_existingSecret`, and `runner_env_existingSecret` + +Manage those Secrets with the store you already run inside the air gap — Vault, External Secrets Operator, or sealed-secrets. See [External Secrets Config](/self-hosting/methods/kubernetes#external-secrets-config) and the secrets-management section of [Airgapped deployment architecture](/self-hosting/methods/airgapped-requirements). + +If you set a credential inline while testing, keep that copy of the file out of version control and treat it as a secret at rest. +::: + ### Version, license domain, and airgapped mode ```yaml @@ -277,7 +291,9 @@ Any override must be BusyBox-compatible — the init scripts need `/bin/sh`, `gr ### Image pull policy -The chart defaults Plane's application services to `pullPolicy: Always`. In an airgapped cluster that means every pod restart re-hits your internal registry and hard-fails when it is briefly unavailable. Prefer `IfNotPresent`: +The chart defaults Plane's application services to `pullPolicy: Always`. In an airgapped cluster that means every pod restart re-hits your internal registry and hard-fails when it is briefly unavailable. Prefer `IfNotPresent`. + +There is no global pull-policy value, so set it on **every service you enable** — `web`, `api`, `space`, `admin`, `live`, `monitor`, `silo`, `email_service`, `iframely`, `pi`, and `runner`. Any service left at `Always` can fail to restart while the registry is unreachable, even though its image is already cached on the node. ```yaml services: @@ -285,9 +301,29 @@ services: pullPolicy: IfNotPresent api: pullPolicy: IfNotPresent - # ...repeat for space, admin, live, monitor, silo, email_service, iframely + space: + pullPolicy: IfNotPresent + admin: + pullPolicy: IfNotPresent + live: + pullPolicy: IfNotPresent + monitor: + pullPolicy: IfNotPresent + silo: + pullPolicy: IfNotPresent + email_service: + pullPolicy: IfNotPresent + iframely: + pullPolicy: IfNotPresent + # Only if Plane AI is enabled + pi: + pullPolicy: IfNotPresent + runner: + pullPolicy: IfNotPresent ``` +The bundled datastores (`postgres`, `redis`, `rabbitmq`, `minio`, `opensearch`) already default to `IfNotPresent`. + ### Storage class Airgapped clusters frequently have no default `StorageClass`. Name yours explicitly, otherwise the PVCs for the bundled datastores stay `Pending` and the install times out: @@ -455,6 +491,8 @@ env: The chart also supports `sentry`, `bitbucket`, and `hubspot` connectors under the same key. +Connector client secrets and the GitHub App private key are credentials — supply them through `external_secrets.silo_env_existingSecret` rather than inline in `custom-values.yaml`. + ### Email intake (optional) Email intake lets Plane capture incoming email as work items. Configure DNS first, following [Configure DNS for the email service](/self-hosting/govern/configure-dns-email-service). diff --git a/docs/self-hosting/methods/airgapped-requirements.md b/docs/self-hosting/methods/airgapped-requirements.md index b50bbdc3..b54441f6 100644 --- a/docs/self-hosting/methods/airgapped-requirements.md +++ b/docs/self-hosting/methods/airgapped-requirements.md @@ -88,7 +88,7 @@ Deploying airgapped Plane via Kubernetes requires preparing all dependencies to - IngressClass configured - StorageClass available -- cert-manager configured with an internal CA +- A TLS certificate for the Plane domain, issued by an internal CA. Either load a pre-issued certificate into the cluster as a TLS Secret, or run cert-manager configured with an internal CA issuer — public ACME and DNS-01 issuers are unreachable from an air gap **Node requirements:** @@ -118,5 +118,5 @@ Plane supports using existing external secret stores, provided they are reachabl ### Additional considerations - Ensure all secret providers can function without external network access -- cert-manager must use an internal certificate authority +- If you run cert-manager, it must use an internal certificate authority - Keys and secret rotation policies should be part of the airgap operational procedures