Skip to content

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:

EditionChartMinimum version
Commercialplane-enterprise3.6.0
Communityplane-ce1.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.

bash
helm repo update plane
helm search repo plane/plane-enterprise --versions | head -5
helm search repo plane/plane-ce --versions | head -5

How 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:

MechanismWhat lands in the pod
IRSAA projected web-identity token, plus AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE
EKS Pod IdentityAWS_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 IdentityIRSA
Cluster prerequisitePod Identity Agent add-onIAM OIDC provider for the cluster
How the role is boundAn EKS association (cluster + namespace + ServiceAccount)An annotation on the ServiceAccount
Role trust policyOne policy, reusable for every clusterReferences one cluster's OIDC issuer
Reusable across clustersYesNo — one trust entry per cluster
Requires EKSYesNo — works on any OIDC-capable cluster
Visible in the chart's outputNo, it is configured out of bandYes, 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>:

json
{
  "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.

bash
# 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
bash
# 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-access

Trust policy

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "pods.eks.amazonaws.com" },
      "Action": ["sts:AssumeRole", "sts:TagSession"]
    }
  ]
}
json
{
  "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:

bash
aws eks describe-cluster --name <CLUSTER-NAME> \
  --query "cluster.identity.oidc.issuer" --output text

TIP

eksctl can create the role, attach the policy, and bind it in one step:

bash
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-access

Step 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.

yaml
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: ""
yaml
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:

bash
helm upgrade --install plane-app plane/plane-enterprise \
    --create-namespace \
    --namespace plane \
    -f values.yaml \
    --timeout 10m \
    --wait \
    --wait-for-jobs

Set 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

SettingDefaultDescription
serviceAccount.createtrueSet 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.cloudIdentityfalseAdvisory 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:

bash
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:

bash
kubectl delete secret plane-app-doc-store-secrets -n plane

plane-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

  1. Confirm the binding reached the ServiceAccount and the pods.

    bash
    aws 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_URI
    bash
    kubectl 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_FILE

    INFO

    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.

  2. Confirm no static key is shadowing the role. Both commands should return nothing:

    bash
    kubectl exec -n plane deploy/plane-app-api -- printenv AWS_ACCESS_KEY_ID
    kubectl exec -n plane deploy/plane-app-api -- printenv AWS_SECRET_ACCESS_KEY
  3. Confirm the role is actually assumed and the bucket is reachable:

    bash
    kubectl 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.

  4. Upload an attachment to a work item in the UI, then confirm the object exists:

    bash
    aws 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

SymptomCause and fix
The role-arn annotation never appears on the ServiceAccountThe 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 InvalidAccessKeyIdAn 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 credentialsNothing 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:AssumeRoleWithWebIdentityThe 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 arriveThe 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 roleThe 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 expectedA 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 errorsenv.aws_region is unset or doesn't match the bucket's region.