Skip to content

External secrets on Kubernetes Commercial Edition ​

Out of the box, the plane-enterprise Helm chart builds its Kubernetes Secrets from values.yaml. Every password, signing key and API token you set ends up in your values file, in the Helm release history, and in any Git repository you keep them in.

This page shows how to keep all of those values in an external secret store instead: AWS Secrets Manager, HashiCorp Vault, Azure Key Vault or GCP Secret Manager. External Secrets Operator (ESO) copies them into Kubernetes Secrets, and the chart reads those Secrets by name.

What you can move out of values.yaml:

  • Plane's signing and encryption keys (SECRET_KEY, AES_SECRET_KEY and the keys services use to authenticate to each other)
  • Postgres, Redis, RabbitMQ and OpenSearch credentials
  • Object storage access keys, or a GCS service-account key
  • AI provider API keys
  • Slack, GitHub, GitLab, Sentry, Bitbucket and HubSpot connector credentials
  • The Plane MCP server's OAuth client, the OpenTelemetry collector headers, the cert-manager DNS-01 token and registry pull credentials

INFO

This page is verified against plane-enterprise 3.10.7. Older chart versions have fewer external_secrets values; run helm show values plane/plane-enterprise --version <version> to see what yours supports.

The manifests here use the external-secrets.io/v1 API, which ESO serves from v0.16.1 onward (v0.16.0 introduced it, and v0.17.0 stopped serving v1beta1).

The discrete database, RabbitMQ and Redis credentials described in Infrastructure credentials need Plane v3.2.0 or later.

How the chart reads external Secrets ​

Every value under external_secrets in the chart's values.yaml takes the name of a Kubernetes Secret in the release namespace. Leave a value empty and the chart renders its own Secret from values.yaml, as it always has. There's no separate on/off switch.

The values work in three different ways, and the difference decides which keys your Secret must hold:

KindValuesWhat the chart does
Credential groupsapp_keys_existingSecret, ai_providers_existingSecret, silo_connectors_existingSecretMounts your Secret as an extra envFrom source and stops rendering that group's keys in its own Secrets. Everything else in its Secrets stays chart-managed.
Infrastructure credentialsdatabase, rabbitmq, redis, storage, opensearch (each with a secretName)Reads individual keys from your Secret into explicit env entries (secretKeyRef) and blanks the connection URL it would otherwise build. Your Secret can be a verbatim copy of a cloud-managed credential, such as {"username": "…", "password": "…"}.
Whole-Secret replacementsapp_env_existingSecret, live_env_existingSecret, silo_env_existingSecret, pi_api_env_existingSecret, runner_env_existingSecret, doc_store_existingSecret, opensearch_existingSecret, pgdb_existingSecret, rabbitmq_existingSecret, argus_env_existingSecret, mcp_server_env_existingSecret, otel_env_existingSecret, ssl_token_existingSecretStops rendering one of its own Secrets entirely and mounts yours in the same place. Your Secret must hold every key the chart's Secret held, secret or not.

Prefer the first two kinds. They let you externalize only the sensitive values, and the chart keeps rendering endpoints, flags and other non-secret settings. Use a whole-Secret replacement only for a group that has no narrower hook.

Install External Secrets Operator ​

Install ESO once per cluster from its official Helm chart:

bash
helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets --create-namespace \
  --version 2.12.0 \
  --set installCRDs=true

2.12.0 was the current release when this page was written. ESO releases often, so check the releases and pin the version you've tested. Any version from v0.16.1 onward serves the external-secrets.io/v1 API.

Confirm the CRDs serve v1:

bash
kubectl get crd externalsecrets.external-secrets.io \
  -o jsonpath='{range .spec.versions[*]}{.name} served={.served}{"\n"}{end}'

The output must include v1 served=true.

You also want Stakater Reloader if you plan to rotate anything. ESO updates a Kubernetes Secret when the source changes, but a running pod keeps the environment it started with. See Rotate a value.

Connect ESO to your secret store ​

A SecretStore tells ESO where to read from and how to authenticate. Create it in the namespace Plane runs in. A namespaced SecretStore can only be used by ExternalSecrets in its own namespace, which is what you want for one Plane installation.

Use a ClusterSecretStore only when several namespaces share one store. It's cluster-scoped, so it has no metadata.namespace, and every namespace in the cluster can read whatever its credentials allow.

AWS Secrets Manager ​

Give the ESO controller an IAM identity through EKS Pod Identity (or IRSA), so no access key is stored anywhere. The layout below keeps each Plane installation's secrets readable only by that installation:

  • Each installation keeps its secrets under its own prefix, such as plane/prod/, and has an IAM role that can read only that prefix.
  • The ESO controller has a role of its own that can do nothing except assume those per-installation roles.
  • Each installation's SecretStore names its role, and ESO assumes it for every read.

See IRSA and EKS Pod Identity for how the two mechanisms compare. Here they're bound to ESO's ServiceAccount (external-secrets in the external-secrets namespace), not Plane's.

1. Create the installation's read role. Its policy allows reading secrets under the installation's prefix and nothing else:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadPlaneSecrets",
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
      "Resource": "arn:aws:secretsmanager:<region>:<account-id>:secret:plane/prod/*"
    }
  ]
}

If the secrets are encrypted with a customer-managed KMS key, also allow kms:Decrypt on that key.

Its trust policy lets the ESO controller's role assume it:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::<account-id>:role/plane-eso-controller" },
      "Action": ["sts:AssumeRole", "sts:TagSession"]
    }
  ]
}

Keep sts:TagSession. EKS Pod Identity sessions carry session tags, and AWS refuses to chain from one without it.

2. Create the controller's role and bind it to ESO's ServiceAccount. Its only permission is assuming the installation roles:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["sts:AssumeRole", "sts:TagSession"],
      "Resource": ["arn:aws:iam::<account-id>:role/plane-prod-secrets"]
    }
  ]
}
bash
# Once per cluster: the Pod Identity Agent add-on.
aws eks create-addon --cluster-name <cluster> --addon-name eks-pod-identity-agent

# Trust policy (controller-trust.json):
# {
#   "Version": "2012-10-17",
#   "Statement": [{
#     "Effect": "Allow",
#     "Principal": { "Service": "pods.eks.amazonaws.com" },
#     "Action": ["sts:AssumeRole", "sts:TagSession"]
#   }]
# }
aws iam create-role --role-name plane-eso-controller \
  --assume-role-policy-document file://controller-trust.json
aws iam put-role-policy --role-name plane-eso-controller \
  --policy-name assume-plane-secret-roles --policy-document file://controller-policy.json

aws eks create-pod-identity-association --cluster-name <cluster> \
  --namespace external-secrets --service-account external-secrets \
  --role-arn arn:aws:iam::<account-id>:role/plane-eso-controller

# Pod Identity credentials are injected when a pod is created, so restart the controller once.
kubectl rollout restart deployment/external-secrets -n external-secrets
bash
# Create plane-eso-controller with an IRSA trust policy for
# system:serviceaccount:external-secrets:external-secrets, attach controller-policy.json, then:
helm upgrade external-secrets external-secrets/external-secrets \
  --namespace external-secrets --reuse-values \
  --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::<account-id>:role/plane-eso-controller

kubectl rollout restart deployment/external-secrets -n external-secrets

3. Create the SecretStore in Plane's namespace. It has no auth block, so ESO starts from the controller's own credentials, and role makes it assume the installation's role for every read:

yaml
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: plane
spec:
  provider:
    aws:
      service: SecretsManager
      region: <region>
      role: arn:aws:iam::<account-id>:role/plane-prod-secrets

Leave out the auth block with Pod Identity. ESO's auth.jwt form exchanges a ServiceAccount token for IRSA credentials, so it doesn't work with Pod Identity.

If the cluster runs only one Plane installation, you can skip the per-installation role: attach the read policy from step 1 to the controller's role and remove role from the SecretStore. Every SecretStore in the cluster can then read everything that role can.

Static access keys (fallback). If the cluster isn't on EKS, create an IAM user with the read policy from step 1 and store its key in Plane's namespace:

bash
kubectl create secret generic aws-eso-credentials -n plane \
  --from-literal=access-key-id=<access-key-id> \
  --from-literal=secret-access-key=<secret-access-key>
yaml
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: plane
spec:
  provider:
    aws:
      service: SecretsManager
      region: <region>
      auth:
        secretRef:
          accessKeyIDSecretRef:
            name: aws-eso-credentials
            key: access-key-id
          secretAccessKeySecretRef:
            name: aws-eso-credentials
            key: secret-access-key

That key is the one long-lived credential left in the cluster, so rotate it on a schedule.

HashiCorp Vault ​

Use Vault's Kubernetes auth method so ESO authenticates with a short-lived ServiceAccount token instead of a stored Vault token.

1. In Vault, enable a KV v2 engine (here mounted at secret), a read-only policy for Plane's path, and a Kubernetes auth role bound to a ServiceAccount in Plane's namespace:

bash
vault secrets enable -path=secret kv-v2   # skip if it already exists

vault policy write plane-read - <<'EOF'
path "secret/data/plane/prod/*" {
  capabilities = ["read"]
}
EOF

vault auth enable kubernetes   # once; then configure it for your cluster
vault write auth/kubernetes/role/plane-eso \
  bound_service_account_names=plane-eso \
  bound_service_account_namespaces=plane \
  policies=plane-read \
  ttl=1h

2. In Kubernetes, create that ServiceAccount and the SecretStore:

yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: plane-eso
  namespace: plane
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: vault
  namespace: plane
spec:
  provider:
    vault:
      server: https://vault.example.com
      path: secret
      version: v2
      auth:
        kubernetes:
          mountPath: kubernetes
          role: plane-eso
          serviceAccountRef:
            name: plane-eso

With path: secret set on the store, an ExternalSecret refers to a secret by its path inside the engine, such as plane/prod/app-keys.

Vault token (fallback). If Kubernetes auth isn't available, store a token with the same policy and reference it instead of the kubernetes block:

yaml
auth:
  tokenSecretRef:
    name: vault-token
    key: token

Create the Secret with kubectl create secret generic vault-token -n plane --from-literal=token=<token>, and use a periodic token so it doesn't expire under you.

Azure Key Vault and GCP Secret Manager ​

The chart side is identical for every provider; only the SecretStore changes. Follow ESO's provider guides, and prefer workload identity over stored credentials in both:

Azure Key Vault secret names can't contain underscores, so store each key under a name like plane-secret-key and map it to SECRET_KEY with secretKey in the ExternalSecret.

Map secrets to the chart ​

This section lists every external_secrets value in plane-enterprise 3.10.7, the keys its Secret must contain, and the Plane workloads that read it. It was produced by rendering the chart with each value set in turn and comparing the output, not by reading the templates.

Two groups of workloads come up repeatedly:

  • API workloads: api, worker, beat-worker and the api-migrate Job, plus external-api, worker-importers, webhook-consumer and agent-consumer when they're enabled.
  • Plane AI workloads: pi-api, pi-worker, pi-beat and the pi-api-migrate Job, when services.pi.enabled is true.

The Deployments are named <release>-<workload>-wl, for example plane-api-wl.

Credential groups ​

ValueKeys your Secret holdsRead by
app_keys_existingSecretSECRET_KEY, AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY, CURSOR_WEBHOOK_SECRET. Add RUNNER_HMAC_SECRET_KEY if the runner is enabled, and AES_SALT only if you already set env.silo_envs.aes_salt.API workloads, automation-consumer, outbox-poller, live, silo, Plane AI workloads, runner
ai_providers_existingSecretAny of OPENAI_API_KEY, CLAUDE_API_KEY, GROQ_API_KEY, COHERE_API_KEY, CUSTOM_LLM_API_KEY, AWS_BEARER_TOKEN_BEDROCK, BR_AWS_ACCESS_KEY_ID, BR_AWS_SECRET_ACCESS_KEY, BR_AWS_SESSION_TOKEN, AI_OPENAI_API_KEY. Include only the providers you use; leave base URLs in values.yaml.Plane AI workloads, live
silo_connectors_existingSecretFor each connector you enable: SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET, SLACK_BASE_URL; GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_APP_NAME, GITHUB_APP_ID, GITHUB_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET; GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET; SENTRY_CLIENT_ID, SENTRY_CLIENT_SECRET, SENTRY_BASE_URL, SENTRY_INTEGRATION_SLUG; BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET, BITBUCKET_WEBHOOK_SECRET; HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRETsilo, API workloads

While one of these is set, the chart stops writing that group's keys into its own Secrets, so your Secret becomes their only source. The chart also stops rendering the non-secret members of the group, such as GITHUB_APP_ID or SLACK_BASE_URL, so include those too.

app_keys_existingSecret must hold all six keys

Setting app_keys_existingSecret removes SECRET_KEY, AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY and CURSOR_WEBHOOK_SECRET from every Secret the chart renders. A Secret that holds only some of them doesn't fail the render or the install. The missing keys are simply absent from every pod, because a key that isn't in an envFrom Secret isn't an error.

What breaks depends on the key, and none of it is obvious from the pod status:

  • Without SECRET_KEY, each API pod generates its own random key, so sessions stop working across replicas and encrypted settings such as the SMTP password read back empty.
  • Without AES_SECRET_KEY, stored OAuth application secrets and MCP connection credentials can't be decrypted.
  • Without LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY or RUNNER_HMAC_SECRET_KEY, the services that use them fail to authenticate to each other.

Put all six in, even for services you haven't enabled yet, and map each key explicitly with data and property as in the worked example. An ExternalSecret that names a property the source doesn't have fails to sync, so a missing key shows up as SecretSyncedError instead of a silently broken pod.

Never rotate SECRET_KEY, AES_SECRET_KEY or AES_SALT

Don't change these on a running instance. They protect data stored in the database, and changing them makes that data unreadable. Choose them once, before the first install, and keep them in your secret store from then on.

Known issue in 3.10.7: live-exporter

The live-exporter Deployment (on by default) needs LIVE_SERVER_SECRET_KEY but doesn't mount app_keys_existingSecret, and the chart stops writing that key into the live Secret. On a release that was upgraded to app_keys_existingSecret, the exporter keeps working from the copy Helm leaves behind (see Stale keys). On a new install, or after you remove the stale copy, it fails its startup check.

Until the chart mounts the Secret there, give the key to it through extraEnv:

yaml
extraEnv:
  - name: LIVE_SERVER_SECRET_KEY
    valueFrom:
      secretKeyRef:
        name: plane-app-keys
        key: LIVE_SERVER_SECRET_KEY

extraEnv applies to every workload the chart renders, so this adds the variable to all of them. It's the same value everywhere, so services that already read it are unaffected.

Infrastructure credentials ​

These read named keys from your Secret, so the Secret can be an exact copy of what your cloud provider manages. An RDS- or CloudSQL-managed secret holds {"username": "…", "password": "…"}, and the defaults already match it. Endpoints stay in values.yaml.

ValueKeys your Secret holdsEnvironment variables setRead by
database.secretNameusernameKey (default username), passwordKey (default password); optionally hostKey, portKey, dbNameKeyPOSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_HOST/PORT/DB from env.pgdb_host, env.pgdb_port, env.pgdb_name. Plane AI gets PLANE_PI_POSTGRES_* and FOLLOWER_POSTGRES_* from the same credential.API workloads, automation-consumer, outbox-poller, silo, Plane AI workloads
database.readReplica.secretNameSame keys as above; usernameKey and passwordKey fall back to the primary's. Defaults to the primary's Secret when empty.POSTGRES_READ_REPLICA_USER, POSTGRES_READ_REPLICA_PASSWORD, plus host, port and database from env.pgdb_read_replica_*Same as database, except Plane AI. Only when services.postgres.read_replica.enabled is true.
rabbitmq.secretNameusernameKey (default username), passwordKey (default password); optionally hostKey, portKey, vhostKeyRABBITMQ_USER, RABBITMQ_PASSWORD, RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_VHOST, RABBITMQ_SSLAPI workloads, automation-consumer, outbox-poller, silo, live, live-exporter
redis.secretNamepasswordKey (default password); optionally hostKey, portKeyREDIS_PASSWORD, REDIS_HOST, REDIS_PORT, REDIS_SSLAPI workloads, automation-consumer, outbox-poller, silo, live, live-exporter, mcp-server, Plane AI workloads
storage.secretNameWhichever you name: accessKeyIdKey, secretAccessKeyKey, gcsCredentialsJsonKeyAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, GCS_CREDENTIALS_JSON (only the ones you name)API workloads, automation-consumer, outbox-poller, silo, Plane AI workloads
opensearch.secretNameusernameKey (default username), passwordKey (default password)OPENSEARCH_USERNAME, OPENSEARCH_PASSWORDAPI workloads, automation-consumer, outbox-poller, Plane AI workloads

Things to know about these:

  • They describe remote services. database, rabbitmq and redis fail the render if the matching services.<name>.local_setup is true. storage is ignored while the bundled MinIO runs, and opensearch while the bundled OpenSearch runs. Turn the bundled service off and set its endpoint (env.pgdb_host, env.rabbitmq_host, env.redis_host) first.
  • They replace the connection URLs. The chart writes DATABASE_URL, AMQP_URL, REDIS_URL and Plane AI's URLs as empty strings, and each service composes its own URL from the parts. A rotated password then needs no URL rewritten anywhere.
  • TLS is a flag, not a scheme. For Amazon MQ set env.rabbitmq_ssl: true and env.rabbitmq_port: '5671'; for ElastiCache with in-transit encryption set env.redis_ssl: true.
  • Plane AI doesn't use RabbitMQ. Its Celery broker is Redis, so rabbitmq doesn't reach the Plane AI workloads.
  • Object storage on AWS needs no Secret at all. Leave storage unset and give Plane's pods an IAM role instead. See IRSA and EKS Pod Identity. Use storage for S3-compatible stores without workload identity, or for a GCS service-account key.
  • OpenSearch on AWS can use SigV4. Leave opensearch unset and the API signs requests with the pod's IAM role. Use opensearch for basic-auth domains.

Whole-Secret replacements ​

Each of these replaces one Secret the chart would otherwise render. Your Secret must contain every key listed, because the chart stops rendering that Secret altogether. Keys in italics are only present in the chart's Secret when the condition next to them applies; the others are always there.

ValueReplaces chart SecretKeys your Secret holdsRead by
app_env_existingSecret<release>-app-secretsDATABASE_URL, REDIS_URL, AMQP_URL; DATABASE_READ_REPLICA_URL (read replica on); SECRET_KEY, AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, AES_SALT (unless app_keys_existingSecret is set)API workloads, automation-consumer, outbox-poller
live_env_existingSecret<release>-live-secretsREDIS_URL, AMQP_URL; LIVE_SERVER_SECRET_KEY (unless app_keys_existingSecret is set)live, live-exporter
silo_env_existingSecret<release>-silo-secretsDATABASE_URL, REDIS_URL, AMQP_URL; AES_SECRET_KEY, SILO_HMAC_SECRET_KEY, CURSOR_WEBHOOK_SECRET, AES_SALT (unless app_keys_existingSecret is set); the connector keys (unless silo_connectors_existingSecret is set)silo, API workloads
pi_api_env_existingSecret<release>-pi-api-secretsPLANE_PI_DATABASE_URL, FOLLOWER_POSTGRES_URI, AMQP_URL, CELERY_BROKER_URL; AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, AES_SALT (unless app_keys_existingSecret is set); the AI provider keys (unless ai_providers_existingSecret is set)Plane AI workloads
runner_env_existingSecret<release>-runner-secretsRUNNER_HMAC_SECRET_KEY (unless app_keys_existingSecret is set)runner, API workloads
doc_store_existingSecret<release>-doc-store-secretsUSE_MINIO, STORAGE_PROVIDER, FILE_SIZE_LIMIT, AWS_S3_BUCKET_NAME; with bundled MinIO, MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_ENDPOINT_URL; with external S3, whichever of AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_S3_ENDPOINT_URL you use; with GCS, GCS_BUCKET_NAME and your GCS_* settingsAPI workloads, automation-consumer, silo, Plane AI workloads, bundled MinIO and its bucket Job
opensearch_existingSecret<release>-opensearch-secretsOPENSEARCH_ENABLED, OPENSEARCH_URL, OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD, OPENSEARCH_INDEX_PREFIX, OPENSEARCH_EMBEDDING_DIMENSION; OPENSEARCH_INITIAL_ADMIN_PASSWORD (bundled OpenSearch)API workloads, automation-consumer, Plane AI workloads, bundled OpenSearch
pgdb_existingSecret<release>-pgdb-secretsPOSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DBBundled Postgres only
rabbitmq_existingSecret<release>-rabbitmq-secretsRABBITMQ_DEFAULT_USER, RABBITMQ_DEFAULT_PASSBundled RabbitMQ only
argus_env_existingSecret<release>-argus-secretsARGUS_DATABASE_URL, ARGUS_CONTENT_DATABASE_URL, ARGUS_FINGERPRINT_SECRET, ARGUS_INTERNAL_SECRETargus and its migration Job
mcp_server_env_existingSecret<release>-mcp-server-secretsPLANE_OAUTH_PROVIDER_CLIENT_ID, PLANE_OAUTH_PROVIDER_CLIENT_SECRET; REDIS_URL (unless redis.secretName is set)mcp-server
otel_env_existingSecret<release>-otel-secretsOTEL_EXPORTER_OTLP_HEADERSEvery Plane workload that exports telemetry, when observability.otel.enabled is true
ssl_token_existingSecret<release>-issuer-api-token-secretapi-tokenThe cert-manager Issuer, for the Cloudflare or DigitalOcean DNS-01 solver

The registry pull secret isn't under external_secrets: set dockerRegistry.existingSecret to a Secret of type kubernetes.io/dockerconfigjson, and the chart stops rendering its own and attaches yours to the ServiceAccount. ESO can build one with a target.template of that type.

Two of these need care:

  • pgdb_existingSecret and rabbitmq_existingSecret only set the bundled server's credentials. The applications still connect with URLs the chart builds from env.pgdb_username/env.pgdb_password and services.rabbitmq.default_user/default_password. If your Secret holds different credentials, the server and the applications disagree. Use these only when you also supply the URLs, through app_env_existingSecret, silo_env_existingSecret, live_env_existingSecret and pi_api_env_existingSecret.
  • silo_env_existingSecret reaches the API workloads too, after app_env. Its DATABASE_URL, REDIS_URL and AMQP_URL override the API's own on those workloads. Keep them identical to the API's, or leave them empty when you use the infrastructure credentials.

Secrets the chart doesn't handle ​

The SMTP password, login provider secrets (Google, GitHub, GitLab, Gitea, OIDC and SAML), LLM_API_KEY and the LDAP bind password aren't chart values. You set them in God mode, and Plane stores them encrypted in its database.

To source them from your store instead, set env.skip_env_var: '0'. The API then reads all of these settings from its environment instead of the database, so supply every one you use (for example EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_FROM) through extraEnv with secretKeyRef entries. Changes made in God mode to those settings stop taking effect.

With skip_env_var: '0', the API workloads also see the silo connector's GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, because the connector Secret is mounted on them. Login and the connector then share one pair of variables, so they must use the same GitHub client ID and secret. If they don't, keep skip_env_var at '1' and manage the login provider in God mode.

Worked example: AWS Secrets Manager ​

This example runs Plane against RDS, Amazon MQ and ElastiCache, with S3 reached through EKS Pod Identity, Plane AI using OpenAI, and the Slack connector. It assumes the SecretStore from AWS Secrets Manager exists in namespace plane.

1. Create one secret per group ​

Store each group as one JSON secret whose keys are the names the chart expects. Use your provider's managed secret for the database if it has one; the default username/password keys match it.

Secrets Manager nameJSON keys
plane/prod/app-keysSECRET_KEY, AES_SECRET_KEY, LIVE_SERVER_SECRET_KEY, PI_INTERNAL_SECRET, SILO_HMAC_SECRET_KEY, CURSOR_WEBHOOK_SECRET
plane/prod/databaseusername, password
plane/prod/rabbitmqusername, password
plane/prod/redispassword
plane/prod/ai-providersOPENAI_API_KEY
plane/prod/silo-connectorsSLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET

For a new installation, generate the app keys:

bash
umask 077
jq -n \
  --arg SECRET_KEY "$(openssl rand -hex 32)" \
  --arg AES_SECRET_KEY "$(openssl rand -hex 32)" \
  --arg LIVE_SERVER_SECRET_KEY "$(openssl rand -hex 32)" \
  --arg PI_INTERNAL_SECRET "$(openssl rand -hex 32)" \
  --arg SILO_HMAC_SECRET_KEY "$(openssl rand -hex 32)" \
  --arg CURSOR_WEBHOOK_SECRET "$(openssl rand -hex 32)" \
  '$ARGS.named' > app-keys.json
aws secretsmanager create-secret --name plane/prod/app-keys --secret-string file://app-keys.json
rm -f app-keys.json

For an existing installation, copy the values it already uses instead. See Migrate an existing installation.

2. Create the ExternalSecrets ​

The app keys are mapped one by one with data and property, so a missing key fails the sync. The other groups map 1:1, so dataFrom.extract copies every key across.

yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: plane-app-keys
  namespace: plane
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: plane-app-keys
    creationPolicy: Owner
  data:
    - secretKey: SECRET_KEY
      remoteRef: { key: plane/prod/app-keys, property: SECRET_KEY }
    - secretKey: AES_SECRET_KEY
      remoteRef: { key: plane/prod/app-keys, property: AES_SECRET_KEY }
    - secretKey: LIVE_SERVER_SECRET_KEY
      remoteRef: { key: plane/prod/app-keys, property: LIVE_SERVER_SECRET_KEY }
    - secretKey: PI_INTERNAL_SECRET
      remoteRef: { key: plane/prod/app-keys, property: PI_INTERNAL_SECRET }
    - secretKey: SILO_HMAC_SECRET_KEY
      remoteRef: { key: plane/prod/app-keys, property: SILO_HMAC_SECRET_KEY }
    - secretKey: CURSOR_WEBHOOK_SECRET
      remoteRef: { key: plane/prod/app-keys, property: CURSOR_WEBHOOK_SECRET }
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: plane-database
  namespace: plane
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: plane-database
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: plane/prod/database
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: plane-rabbitmq
  namespace: plane
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: plane-rabbitmq
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: plane/prod/rabbitmq
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: plane-redis
  namespace: plane
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: plane-redis
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: plane/prod/redis
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: plane-ai-providers
  namespace: plane
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: plane-ai-providers
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: plane/prod/ai-providers
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: plane-silo-connectors
  namespace: plane
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: plane-silo-connectors
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: plane/prod/silo-connectors

For Vault, the manifests are the same with secretStoreRef.name: vault and keys such as plane/prod/app-keys relative to the store's path.

Apply them and wait until every one reports SecretSynced:

bash
kubectl apply -f plane-external-secrets.yaml
kubectl wait externalsecret --all -n plane --for=condition=Ready --timeout=2m
kubectl get externalsecret -n plane
NAME                    STORETYPE     STORE                 REFRESH INTERVAL   STATUS         READY
plane-ai-providers      SecretStore   aws-secrets-manager   1h                 SecretSynced   True
plane-app-keys          SecretStore   aws-secrets-manager   1h                 SecretSynced   True
...

Create the ExternalSecrets before you install or upgrade Plane. The chart references these Secrets with optional: false, so a pod that starts before its Secret exists waits in CreateContainerConfigError.

3. Point the chart at them ​

yaml
services:
  postgres:
    local_setup: false
  rabbitmq:
    local_setup: false
  redis:
    local_setup: false
  minio:
    local_setup: false
  pi:
    enabled: true
    ai_providers:
      openai:
        enabled: true
  silo:
    connectors:
      slack:
        enabled: true

env:
  pgdb_host: <rds-endpoint>
  pgdb_name: plane
  rabbitmq_host: <amazon-mq-endpoint>
  rabbitmq_port: "5671"
  rabbitmq_ssl: true
  redis_host: <elasticache-endpoint>
  redis_ssl: true
  docstore_bucket: <bucket>
  aws_region: <region>

external_secrets:
  app_keys_existingSecret: plane-app-keys
  database:
    secretName: plane-database
  rabbitmq:
    secretName: plane-rabbitmq
  redis:
    secretName: plane-redis
  ai_providers_existingSecret: plane-ai-providers
  silo_connectors_existingSecret: plane-silo-connectors

Leave every password, key and *_remote_url out of this file. S3 access comes from the pod's IAM role; see IRSA and EKS Pod Identity for the serviceAccount settings.

If you deliver Plane with a GitOps tool that applies one directory as one unit, you can put the SecretStore and ExternalSecrets in the chart's extraObjects list instead of applying them separately. Annotate them as Helm hooks so they're created before the workloads:

yaml
extraObjects:
  - apiVersion: external-secrets.io/v1
    kind: ExternalSecret
    metadata:
      name: plane-app-keys
      annotations:
        helm.sh/hook: pre-install,pre-upgrade
        helm.sh/hook-weight: "-10"
        helm.sh/hook-delete-policy: before-hook-creation
    spec:
      refreshInterval: 1h
      secretStoreRef:
        name: aws-secrets-manager
        kind: SecretStore
      target:
        name: plane-app-keys
        creationPolicy: Orphan
      # data: ...

Use creationPolicy: Orphan there. before-hook-creation deletes and recreates the ExternalSecret on every upgrade, and with Owner Kubernetes would delete the synced Secret along with it. Helm doesn't wait for an ExternalSecret to sync, so on the first install some pods may sit in CreateContainerConfigError for a few seconds until it does.

4. Install or upgrade, then check ​

bash
helm upgrade --install plane plane/plane-enterprise -n plane -f values.yaml
kubectl get pods -n plane

Then confirm the variables reached the pods. List names only, never values:

bash
# The synced Secret holds the keys you expect
kubectl get secret plane-app-keys -n plane \
  -o go-template='{{range $k, $v := .data}}{{$k}}{{"\n"}}{{end}}'

# The API pod sees them
kubectl exec -n plane deploy/plane-api-wl -- sh -c 'env | cut -d= -f1 | sort' \
  | grep -E '^(SECRET_KEY|AES_SECRET_KEY|LIVE_SERVER_SECRET_KEY|PI_INTERNAL_SECRET|SILO_HMAC_SECRET_KEY|CURSOR_WEBHOOK_SECRET|POSTGRES_USER|POSTGRES_PASSWORD)$'

Every name in the pattern should be printed. Finally, sign in to Plane and open a page that uses stored credentials, such as God mode > Email, to confirm the API still decrypts what it stored.

Operate ​

Rotate a value ​

When you change a value in the store, ESO picks it up at the next refreshInterval and updates the Kubernetes Secret. To pick it up straight away, annotate the ExternalSecret:

bash
kubectl annotate externalsecret plane-database -n plane force-sync=$(date +%s) --overwrite

refreshInterval: 1h is a reasonable default. Each refresh is one read from your store per ExternalSecret, so a shorter interval costs more calls for a faster pickup.

Updating the Secret doesn't change running pods: a pod reads its environment once, when it starts. Restart the workloads that read the Secret:

bash
kubectl rollout restart deployment -n plane plane-api-wl plane-worker-wl plane-beat-worker-wl

Or install Reloader and annotate those workloads, so a changed Secret rolls them automatically:

yaml
services:
  api:
    annotations:
      reloader.stakater.com/auto: "true"
  worker:
    annotations:
      reloader.stakater.com/auto: "true"
  beatworker:
    annotations:
      reloader.stakater.com/auto: "true"
  live:
    annotations:
      reloader.stakater.com/auto: "true"
  silo:
    annotations:
      reloader.stakater.com/auto: "true"

The chart puts services.<name>.annotations on the Deployment, which is where Reloader looks. Add every workload that reads the Secret you're changing; the map above lists them. When a value is shared by several services, such as LIVE_SERVER_SECRET_KEY, restart all of them together.

Reloader doesn't restart Jobs, so avoid changing credentials while a helm upgrade is running its migrations.

Values Plane reads from its database rather than its environment, such as the SMTP password, don't change on a restart unless env.skip_env_var is '0'. See Secrets the chart doesn't handle.

Migrate an existing installation ​

To move a running installation to an external store without changing any value, copy the values it uses today into the store first. These commands read from the chart's Secrets without printing anything:

bash
NS=plane
REL=plane
get() { kubectl get secret "$REL-$1" -n "$NS" -o go-template="{{index .data \"$2\"}}" | base64 -d; }

umask 077
jq -n \
  --arg SECRET_KEY "$(get app-secrets SECRET_KEY)" \
  --arg AES_SECRET_KEY "$(get app-secrets AES_SECRET_KEY)" \
  --arg LIVE_SERVER_SECRET_KEY "$(get app-secrets LIVE_SERVER_SECRET_KEY)" \
  --arg PI_INTERNAL_SECRET "$(get app-secrets PI_INTERNAL_SECRET)" \
  --arg SILO_HMAC_SECRET_KEY "$(get silo-secrets SILO_HMAC_SECRET_KEY)" \
  --arg CURSOR_WEBHOOK_SECRET "$(get silo-secrets CURSOR_WEBHOOK_SECRET)" \
  '$ARGS.named' > app-keys.json
jq -r 'to_entries[] | "\(.key) \(.value | length)"' app-keys.json   # names and lengths only

aws secretsmanager create-secret --name plane/prod/app-keys --secret-string file://app-keys.json
rm -f app-keys.json

Every length should be greater than zero. Add RUNNER_HMAC_SECRET_KEY from runner-secrets if the runner is enabled, and AES_SALT from app-secrets if you set one. If silo is disabled, <release>-silo-secrets doesn't exist; generate those two keys instead.

Then:

  1. Create the ExternalSecrets and wait for SecretSynced.
  2. Add the external_secrets values and remove the corresponding secrets from values.yaml.
  3. Run helm upgrade with the same values files as before plus the new ones. The pods restart onto the synced Secrets, holding the same values as before.
  4. Check that stored settings still decrypt, for example that God mode > Email still shows the SMTP configuration.

Stale keys in chart-managed Secrets ​

Most of the chart's Secrets are written with stringData. When the chart stops rendering a key, Helm doesn't remove it from the live Secret, so the old value stays there. After you switch on app_keys_existingSecret, for example, <release>-app-secrets, -live-secrets and -silo-secrets still hold the keys they used to.

On workloads that also mount your Secret, yours wins, because it comes later (see When a key appears twice). The stale copy only matters where your Secret isn't mounted, such as live-exporter, and after a rotation, when it holds the old value.

To clear them once the migration is done, delete the chart's Secret and let Helm render it again in the same step:

bash
kubectl delete secret plane-app-secrets plane-live-secrets plane-silo-secrets -n plane
helm upgrade plane plane/plane-enterprise -n plane -f values.yaml

Apply the live-exporter workaround first, or the exporter loses its only copy of LIVE_SERVER_SECRET_KEY.

The object-storage Secret is the exception: from chart 3.6.0 it's written as data, so Helm removes keys from it itself. If an older release ever ran the bundled MinIO, though, its access key may still be there and override the pod's IAM role. The chart prints a warning with the fix when this applies.

When a key appears twice ​

A container can get the same variable from several Secrets. Kubernetes resolves it like this:

  1. An explicit env entry beats every envFrom source. The infrastructure credentials are explicit env entries, so they always win.
  2. Among envFrom sources, the one listed later wins.

In 3.10.7 the chart lists the sources in this order (ConfigMaps omitted).

WorkloadsenvFrom order, first to last
API workloadsapp-secrets / app_env → doc-store → opensearch → silo-secrets / silo_env → runner-secrets / runner_env → app_keys → silo_connectors → otel
liveai_providers → live-secrets / live_env → otel → app_keys
live-exporterlive-secrets / live_env → otel
silosilo-secrets / silo_env → doc-store → otel → app_keys → silo_connectors
Plane AI workloadsai_providers → pi-api-secrets / pi_api_env → doc-store → opensearch → otel → app_keys
runnerrunner-secrets / runner_env → app_keys

On some API workloads (beat-worker, automation-consumer, outbox-poller and the consumers) the otel Secret comes before app_keys instead of after it.

So in practice:

  • app_keys_existingSecret and silo_connectors_existingSecret win over a duplicate key in any of the whole-Secret replacements, wherever both are mounted. Either way, don't put the shared keys in both: live-exporter doesn't mount app_keys, so if the two copies ever differ it ends up on a different value from everything else, and helm upgrade prints a warning when it sees both set.
  • ai_providers_existingSecret loses to the same key in pi_api_env_existingSecret or live_env_existingSecret.
  • silo_env_existingSecret beats app_env_existingSecret on the API workloads, for DATABASE_URL, REDIS_URL and AMQP_URL.
  • A URL beats the parts. The services prefer a connection URL when one is set. If you use the infrastructure credentials, make sure no Secret you supply carries a non-empty DATABASE_URL, AMQP_URL or REDIS_URL, or it silently replaces the credential you rotate.

Troubleshoot ​

SymptomCause and fix
SecretStore not ReadyESO can't authenticate. Run kubectl describe secretstore -n plane <name>. With Pod Identity, check that the association names namespace external-secrets and ServiceAccount external-secrets, and that the controller restarted after you created it. With a chained role, both sides need sts:TagSession.
ExternalSecret shows SecretSyncedErrorRun kubectl describe externalsecret -n plane <name> and read the event. AccessDeniedException: the IAM policy doesn't cover that secret's ARN, or a KMS key needs kms:Decrypt. ResourceNotFoundException: wrong secret name or region. A missing property: the JSON in the store lacks a key you mapped; add it there.
Pod in CreateContainerConfigError, secret "<name>" not foundThe Secret named in values.yaml doesn't exist yet. Check the name matches the ExternalSecret's target.name, and that it synced. Pods start on their own once it exists.
Pod in CreateContainerConfigError, couldn't find key <key> in SecretAn infrastructure credential names a key your Secret doesn't have. Check usernameKey, passwordKey and the other *Key values against the key names the Secret actually has, listed with the go-template command in Install or upgrade, then check.
Users signed out at random, or "invalid token" errors, with several API podsThe API pods have different SECRET_KEY values, or none (each pod then generates its own). Check SECRET_KEY is in the Secret, then compare a hash across pods without printing the value: kubectl exec -n plane <pod> -- sh -c 'printf %s "$SECRET_KEY" | sha256sum'. Restart every pod still on the old value.
SMTP, OAuth or MCP integrations stopped working after the switchThe app keys in the store aren't the ones the installation used before, so data it stored earlier can't be read. Put the original values in the store, let ESO sync and restart the pods. See Migrate an existing installation.
Live editing, Plane AI, silo or runner calls fail with 401 or 403The two sides of a shared key disagree: LIVE_SERVER_SECRET_KEY (API and live), PI_INTERNAL_SECRET (API, live and Plane AI), SILO_HMAC_SECRET_KEY (API and silo) or RUNNER_HMAC_SECRET_KEY (API and runner). Check the key is in app_keys_existingSecret and not also in a whole-Secret replacement, compare hashes across the pods as above, and restart the pods still on the old value.
A rotated credential isn't usedThe pods haven't restarted. Run kubectl rollout restart, or set up Reloader. For SMTP, OAuth, LLM and LDAP settings, env.skip_env_var must also be '0'.