Use IRSA or EKS Pod Identity for S3 storage
When you run Plane on Amazon EKS with an external S3 bucket, you don't have to store an access key and secret in your cluster. Plane can assume an IAM role instead, using either of the two mechanisms AWS provides:
- IRSA (IAM Roles for Service Accounts) — the role is bound to a Kubernetes ServiceAccount through the cluster's OIDC provider, and requested by an annotation on that ServiceAccount.
- EKS Pod Identity — the role is bound to a ServiceAccount through an EKS API object called a pod identity association. No annotation is involved.
Either way there is no long-lived credential to rotate, leak, or commit, and access is scoped by IAM policy rather than by whoever holds the key.
Requirements
Both mechanisms are supported by both editions, but only from these chart versions onward:
| Edition | Chart | Minimum version |
|---|---|---|
| Commercial | plane-enterprise | 3.6.0 |
| Community | plane-ce | 1.8.0 |
Check your chart version first
Earlier charts render the ServiceAccount with no annotations block, and Helm silently ignores values keys a chart doesn't define. On those versions a serviceAccount.annotations block is a no-op: the annotation never reaches the ServiceAccount, the IRSA webhook never fires, and uploads fail after deploy while your values.yaml looks correct.
helm repo update plane
helm search repo plane/plane-enterprise --versions | head -5
helm search repo plane/plane-ce --versions | head -5How Plane picks up the role
Plane's API builds its S3 client without passing credentials of its own, so the AWS SDK walks its default credential chain. On EKS that chain finds whatever the pod was given:
| Mechanism | What lands in the pod |
|---|---|
| IRSA | A projected web-identity token, plus AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE |
| EKS Pod Identity | AWS_CONTAINER_CREDENTIALS_FULL_URI, pointing at the node's Pod Identity Agent |
Both resolve to short-lived credentials that the SDK refreshes on its own.
An empty access key is worse than no access key
The chain is only consulted when AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are absent from the environment entirely. An empty value is still a value: the SDK treats it as an explicit credential, signs every request with an empty access key, and never falls back to the role. Uploads then fail with InvalidClientTokenId or InvalidAccessKeyId while the configuration looks correct.
The chart handles this for you — it omits both variables when env.aws_access_key and env.aws_secret_access_key are empty. Just don't set them to "" expecting them to be ignored, and don't set them alongside a role: a static key always wins over the pod's identity.
Choose a mechanism
| EKS Pod Identity | IRSA | |
|---|---|---|
| Cluster prerequisite | Pod Identity Agent add-on | IAM OIDC provider for the cluster |
| How the role is bound | An EKS association (cluster + namespace + ServiceAccount) | An annotation on the ServiceAccount |
| Role trust policy | One policy, reusable for every cluster | References one cluster's OIDC issuer |
| Reusable across clusters | Yes | No — one trust entry per cluster |
| Requires EKS | Yes | No — works on any OIDC-capable cluster |
| Visible in the chart's output | No, it is configured out of band | Yes, as an annotation |
Prefer Pod Identity on new EKS clusters: the trust policy is simpler and portable. Use IRSA if you are on an older cluster, already standardized on it, or running Kubernetes somewhere other than EKS.
Step 1 — Create the IAM policy
Both mechanisms need the same permissions on the bucket. Plane generates presigned URLs for browser uploads and downloads, and copies, inspects, and deletes objects as assets change.
Create a policy — for example plane-s3-access — replacing <BUCKET-NAME>:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PlaneBucketLevel",
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": "arn:aws:s3:::<BUCKET-NAME>"
},
{
"Sid": "PlaneObjectLevel",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::<BUCKET-NAME>/*"
}
]
}INFO
If you plan to run the update_bucket command to switch to private buckets, add s3:PutBucketPolicy and s3:GetBucketPolicy on the bucket ARN. You can remove them again once the migration is done.
Step 2 — Create the role and bind it
Pick the tab that matches the mechanism you chose. In both cases the ServiceAccount name is the one the chart uses — by default <release-name>-srv-account, so plane-app-srv-account for a release named plane-app.
# 1. Install the Pod Identity Agent add-on (once per cluster).
aws eks create-addon \
--cluster-name <CLUSTER-NAME> \
--addon-name eks-pod-identity-agent
# 2. Create the role with the Pod Identity trust policy (see below), then attach the policy.
aws iam create-role \
--role-name plane-s3-role \
--assume-role-policy-document file://pod-identity-trust.json
aws iam attach-role-policy \
--role-name plane-s3-role \
--policy-arn arn:aws:iam::<ACCOUNT-ID>:policy/plane-s3-access
# 3. Associate the role with Plane's ServiceAccount.
aws eks create-pod-identity-association \
--cluster-name <CLUSTER-NAME> \
--namespace plane \
--service-account plane-app-srv-account \
--role-arn arn:aws:iam::<ACCOUNT-ID>:role/plane-s3-role# 1. Make sure the cluster has an IAM OIDC provider (once per cluster).
eksctl utils associate-iam-oidc-provider \
--cluster <CLUSTER-NAME> \
--approve
# 2. Create the role with the IRSA trust policy (see below), then attach the policy.
aws iam create-role \
--role-name plane-s3-role \
--assume-role-policy-document file://irsa-trust.json
aws iam attach-role-policy \
--role-name plane-s3-role \
--policy-arn arn:aws:iam::<ACCOUNT-ID>:policy/plane-s3-accessTrust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "pods.eks.amazonaws.com" },
"Action": ["sts:AssumeRole", "sts:TagSession"]
}
]
}{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<ACCOUNT-ID>:oidc-provider/oidc.eks.<REGION>.amazonaws.com/id/<OIDC-ID>"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.<REGION>.amazonaws.com/id/<OIDC-ID>:aud": "sts.amazonaws.com",
"oidc.eks.<REGION>.amazonaws.com/id/<OIDC-ID>:sub": "system:serviceaccount:plane:plane-app-srv-account"
}
}
}
]
}WARNING
The Pod Identity trust policy needs both sts:AssumeRole and sts:TagSession — EKS tags the session with the cluster and ServiceAccount, and the association fails to deliver credentials without it.
For IRSA, the sub condition must match the namespace and ServiceAccount exactly. Get your cluster's <OIDC-ID> with:
aws eks describe-cluster --name <CLUSTER-NAME> \
--query "cluster.identity.oidc.issuer" --output textTIP
eksctl can create the role, attach the policy, and bind it in one step:
eksctl create podidentityassociation \
--cluster <CLUSTER-NAME> \
--namespace plane \
--service-account-name plane-app-srv-account \
--permission-policy-arns arn:aws:iam::<ACCOUNT-ID>:policy/plane-s3-accessStep 3 — Configure the chart
Point Plane at the external bucket and leave the access key and secret unset. The two charts differ only in where the MinIO toggle lives — services.minio.local_setup on Commercial, minio.local_setup on Community.
services:
minio:
local_setup: false
serviceAccount:
create: true
# Must match the association or the trust policy. Defaults to <release>-srv-account.
name: ""
annotations:
# IRSA only. For EKS Pod Identity, remove this block entirely.
eks.amazonaws.com/role-arn: arn:aws:iam::<ACCOUNT-ID>:role/plane-s3-role
# Advisory: turns on upgrade warnings about an attached identity.
cloudIdentity: true
env:
docstore_bucket: <BUCKET-NAME>
aws_region: <REGION>
# Leave all three empty so the chart omits them and the SDK uses the pod's role.
aws_access_key: ""
aws_secret_access_key: ""
aws_s3_endpoint_url: ""minio:
local_setup: false
serviceAccount:
create: true
# Must match the association or the trust policy. Defaults to <release>-srv-account.
name: ""
annotations:
# IRSA only. For EKS Pod Identity, remove this block entirely.
eks.amazonaws.com/role-arn: arn:aws:iam::<ACCOUNT-ID>:role/plane-s3-role
# Advisory: turns on upgrade warnings about an attached identity.
cloudIdentity: true
env:
docstore_bucket: <BUCKET-NAME>
aws_region: <REGION>
# Leave all three empty so the chart omits them and the SDK uses the pod's role.
aws_access_key: ""
aws_secret_access_key: ""
aws_s3_endpoint_url: ""Then upgrade:
helm upgrade --install plane-app plane/plane-enterprise \
--create-namespace \
--namespace plane \
-f values.yaml \
--timeout 10m \
--wait \
--wait-for-jobsSet env.aws_region
Leave aws_s3_endpoint_url empty for AWS S3 so the SDK derives the correct regional endpoint, but do set aws_region. Without a region, requests are signed for the wrong scope and S3 rejects them.
Settings reference
| Setting | Default | Description |
|---|---|---|
serviceAccount.create | true | Set to false to run as a ServiceAccount you manage outside the chart — for example one created by eksctl or Terraform along with the role. |
serviceAccount.name | "" | Name of the ServiceAccount every workload runs as. Defaults to <release-name>-srv-account. This is the name the association or trust policy must match. |
serviceAccount.annotations | {} | Annotations on the ServiceAccount. Where the IRSA eks.amazonaws.com/role-arn binding goes. Helm values only — this is a map, so it is not offered in the Rancher UI. |
serviceAccount.podLabels | {} | Extra pod-template labels. Not needed for IRSA or Pod Identity; Azure Workload Identity requires one. |
serviceAccount.cloudIdentity | false | Advisory only, and changes nothing in the rendered output. Declares that this ServiceAccount is bound to an identity configured out of band, which lets helm upgrade warn about an identity's reach. |
One ServiceAccount for every workload
Both charts run all workloads under this single ServiceAccount — Postgres, Redis, RabbitMQ, MinIO, and OpenSearch included, not just the API and workers. A role attached here is reachable from all of them.
If least privilege matters, create your own ServiceAccount for the role, set serviceAccount.create: false, and point serviceAccount.name at it.
Migrating from static access keys
If this instance previously ran with env.aws_access_key set, check that the old value is really gone after the upgrade:
kubectl get secret plane-app-doc-store-secrets -n plane \
-o jsonpath='{.data.AWS_ACCESS_KEY_ID}'WARNING
On plane-ce, that command can still return a value. Helm cannot always remove a Secret key it has stopped rendering, and a leftover AWS_ACCESS_KEY_ID is found first by the credential chain — the exact failure described above, with nothing in values.yaml to explain it.
If a value comes back, delete the Secret and re-run the upgrade so the chart recreates it:
kubectl delete secret plane-app-doc-store-secrets -n planeplane-enterprise 3.6.0 and later write this Secret in a way that removes the key for you, so no manual step is needed there.
Step 4 — Verify
Confirm the binding reached the ServiceAccount and the pods.
bashaws eks list-pod-identity-associations \ --cluster-name <CLUSTER-NAME> \ --namespace plane # The agent injects this into the pod: kubectl exec -n plane deploy/plane-app-api -- \ printenv AWS_CONTAINER_CREDENTIALS_FULL_URIbashkubectl get sa plane-app-srv-account -n plane \ -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}' # The webhook injects these into the pod: kubectl exec -n plane deploy/plane-app-api -- \ printenv AWS_ROLE_ARN AWS_WEB_IDENTITY_TOKEN_FILEINFO
The IRSA environment variables are injected by a mutating webhook when the pod is created. Annotating the ServiceAccount on an already-running deployment changes nothing until the pods restart, so roll them:
kubectl rollout restart deploy -n plane.Confirm no static key is shadowing the role. Both commands should return nothing:
bashkubectl exec -n plane deploy/plane-app-api -- printenv AWS_ACCESS_KEY_ID kubectl exec -n plane deploy/plane-app-api -- printenv AWS_SECRET_ACCESS_KEYConfirm the role is actually assumed and the bucket is reachable:
bashkubectl exec -n plane deploy/plane-app-api -- \ python -c "import boto3; print(boto3.client('sts').get_caller_identity()['Arn'])"The output should name
plane-s3-role. If it names a user or a different role, the pod is picking up credentials from somewhere else — most often the node's instance profile.Upload an attachment to a work item in the UI, then confirm the object exists:
bashaws s3 ls s3://<BUCKET-NAME>/ --recursive | tail
CORS and private buckets
A role changes only how Plane authenticates. It doesn't change how the browser reaches S3: uploads and downloads still use presigned URLs, so the bucket still needs a CORS policy. See Switch from public to private buckets for the policy and for migrating existing objects to private storage.
Troubleshoot
| Symptom | Cause and fix |
|---|---|
The role-arn annotation never appears on the ServiceAccount | The chart predates this feature and Helm ignored the values key. Upgrade to plane-enterprise 3.6.0 or plane-ce 1.8.0 or later. |
Uploads fail with InvalidClientTokenId or InvalidAccessKeyId | An empty or leftover AWS_ACCESS_KEY_ID is being used as a real credential. Clear env.aws_access_key and env.aws_secret_access_key, check no external_secrets.storage.secretName supplies them, then see Migrating from static access keys. |
NoCredentialsError / Unable to locate credentials | Nothing was injected. For IRSA the pods predate the annotation — restart them. For Pod Identity the agent add-on is missing, or the association's namespace or ServiceAccount name doesn't match. |
AccessDenied on sts:AssumeRoleWithWebIdentity | The IRSA trust policy's sub doesn't match. It must be exactly system:serviceaccount:<namespace>:<serviceaccount>, and the OIDC issuer must be this cluster's. |
| Pod Identity association created, but no credentials arrive | The trust policy is missing sts:TagSession, or the Pod Identity Agent isn't running: kubectl get pods -n kube-system -l app.kubernetes.io/name=eks-pod-identity-agent. |
get_caller_identity returns the node's role | The role wasn't bound, so the SDK fell through to the node instance profile. Re-check the association or the annotation, then restart the pods. |
| Presigned URLs expire earlier than expected | A presigned URL can't outlive the temporary credentials that signed it. Keep SIGNED_URL_EXPIRATION below the role's maximum session duration, or raise that duration on the role. |
IllegalLocationConstraintException or signature errors | env.aws_region is unset or doesn't match the bucket's region. |

