Skip to main content

AWS IRSA Setup for EKS Deployments

This guide walks through setting up an Unstract on-prem deployment on Amazon EKS to access Amazon S3 (for storage) and Amazon Bedrock (for LLM inference) using IAM Roles for Service Accounts (IRSA).

Scope

This guide is EKS-specific. For non-EKS Kubernetes clusters, use static credentials as described in the Infrastructure Requirements.

Compatibility

IRSA for S3 storage is available from Unstract v0.158.4 onwards. Bedrock IAM Role / Instance Profile authentication (Step 6.4) requires v0.159.3 or later.

Why IRSA

With IRSA, each Kubernetes service account is bound to a dedicated IAM role via OIDC federation. Pods get short-lived AWS credentials that are scoped to that role.

What this gives you:

  • Pod-scoped permissions — only Unstract pods get S3 and Bedrock access; other workloads on the same cluster are unaffected.
  • Per-workload audit trail — CloudTrail attributes calls to the IRSA role, so you can trace AWS activity directly to Unstract.
  • No static keys, no rotation — credentials come from a JWT token that the EKS Pod Identity Webhook injects and rotates automatically.
  • Workload isolation — different services on the same cluster can have different AWS permissions.

The setup is a one-time effort: an OIDC provider, an IAM role with a trust policy, and a service account annotation. After that, it is hands-off.

Prerequisites

  • An Amazon EKS cluster (Kubernetes 1.29+ to match the Unstract platform requirement; IRSA itself requires only 1.14+).
  • kubectl configured against the cluster.
  • AWS CLI with permissions to create IAM OIDC providers, IAM policies, IAM roles, and S3 buckets.
  • eksctl installed (recommended for OIDC provider setup).
  • Unstract Helm chart available locally or from your registry.
  • A Kubernetes namespace created in the cluster where Unstract will be deployed.

Set these environment variables — they are used throughout the guide:

export CLUSTER_NAME=<your-eks-cluster-name>
export AWS_REGION=<your-region> # e.g., ap-south-1
export AWS_ACCOUNT_ID=<your-account-id>
export NAMESPACE=<your-namespace>
export SERVICE_ACCOUNT=unstract-irsa # fixed by the Unstract Helm chart
export S3_BUCKET=<your-s3-bucket-name>

Step 1 — Create the S3 bucket

aws s3 mb s3://${S3_BUCKET} --region ${AWS_REGION}

S3 bucket names are globally unique. Pick a region close to your EKS cluster.

Step 2 — Define and create the IAM policies

Both policies are reusable — you create them once and attach them to the IRSA role.

2.1 S3 policy

Save as unstract-s3-policy.json:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListBucket",
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::<s3-bucket-name>"
]
},
{
"Sid": "ObjectAccess",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::<s3-bucket-name>/*"
]
},
{
"Sid": "ListAllBuckets",
"Effect": "Allow",
"Action": [
"s3:ListAllMyBuckets"
],
"Resource": "*"
}
]
}
note

s3:ListAllMyBuckets is required by Unstract's storage adapter for connectivity validation.

Replace <s3-bucket-name> with your actual bucket name before running:

aws iam create-policy \
--policy-name UnstractS3Access \
--policy-document file://unstract-s3-policy.json

2.2 Bedrock policy

Save as unstract-bedrock-policy.json (replace <aws-account-id>):

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockInvoke",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:*:<aws-account-id>:inference-profile/*"
]
},
{
"Sid": "BedrockList",
"Effect": "Allow",
"Action": [
"bedrock:ListFoundationModels",
"bedrock:GetFoundationModel",
"bedrock:ListInferenceProfiles"
],
"Resource": "*"
}
]
}

The wildcard region in arn:aws:bedrock:*::foundation-model/* and the inference-profile/* resource are required for cross-region inference profiles — model IDs prefixed with us., eu., apac., or global. (the global. prefix is supported for select models only). These route across multiple regions and break with single-region scoping.

aws iam create-policy \
--policy-name UnstractBedrockAccess \
--policy-document file://unstract-bedrock-policy.json

Step 3 — Associate an OIDC provider with the cluster

IRSA uses OIDC federation. Each EKS cluster has a unique OIDC issuer URL that must be registered as an IAM identity provider.

3.1 Get the cluster's OIDC issuer URL

OIDC_URL=$(aws eks describe-cluster \
--name "$CLUSTER_NAME" \
--region "$AWS_REGION" \
--query 'cluster.identity.oidc.issuer' \
--output text)

echo "$OIDC_URL"
# Example: https://oidc.eks.<aws-region>.amazonaws.com/id/<oidc-id>

# Strip the https:// prefix for use in trust policies
OIDC_ISSUER=$(echo "$OIDC_URL" | sed 's|^https://||')
echo "$OIDC_ISSUER"

3.2 Check whether an OIDC provider already exists

: "${OIDC_ISSUER:?run Step 3.1 first to set OIDC_ISSUER}"
OIDC_ID=$(echo "$OIDC_ISSUER" | awk -F'/' '{print $NF}')

if ! aws iam list-open-id-connect-providers \
--query "OpenIDConnectProviderList[?contains(Arn, '$OIDC_ID')].Arn" \
--output text; then
echo "AWS call failed — check credentials and IAM permissions" >&2
exit 1
fi

If the command prints an ARN, an OIDC provider is already registered for this cluster — skip to Step 4. If the output is empty (and the command itself succeeded), no provider is registered yet — continue to Step 3.3.

3.3 Create the OIDC provider

eksctl utils associate-iam-oidc-provider \
--cluster "$CLUSTER_NAME" \
--region "$AWS_REGION" \
--approve

If you do not have eksctl, you can use the AWS CLI directly — but eksctl handles thumbprint discovery automatically and is the recommended approach.

Step 4 — Create the IAM role for Unstract

This is the role that Unstract pods will assume.

4.1 Service account name

The Unstract Helm chart creates a service account named unstract-irsa in the install namespace. This name is fixed by the chart — the trust policy must reference it exactly.

export SERVICE_ACCOUNT=unstract-irsa

4.2 Build the trust policy

The trust policy binds the IAM role to specific Kubernetes service accounts. AWS will refuse to issue credentials unless the JWT in the pod matches the sub condition.

The OIDC issuer string appears in two distinct forms — the script below assembles both correctly:

  • IAM ARN form (arn:aws:iam::<account>:oidc-provider/<issuer>) — used as Principal.Federated.
  • Bare-issuer form — used as the prefix of both the :aud and :sub condition keys.

EOF is intentionally left unquoted so the placeholders are expanded into the JSON. Run the variable check first to fail fast if any are unset:

: "${AWS_ACCOUNT_ID:?re-export from Prerequisites}"
: "${OIDC_ISSUER:?run Step 3.1 again to set OIDC_ISSUER}"
: "${NAMESPACE:?not set}"
: "${SERVICE_ACCOUNT:?not set}"

Now write the trust policy:

cat > unstract-irsa-trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/${OIDC_ISSUER}"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"${OIDC_ISSUER}:aud": "sts.amazonaws.com",
"${OIDC_ISSUER}:sub": "system:serviceaccount:${NAMESPACE}:${SERVICE_ACCOUNT}"
}
}
}
]
}
EOF

Inspect the rendered file before running the next step:

cat unstract-irsa-trust-policy.json
Required check

Verify no placeholder variables remain literal and no fields are empty before continuing. A malformed JSON here surfaces as a confusing IAM error in Step 4.3 rather than at policy-write time.

4.3 Create the role

aws iam create-role \
--role-name UnstractIRSARole \
--assume-role-policy-document file://unstract-irsa-trust-policy.json \
--description "IRSA role for Unstract pods on EKS"

4.4 Attach the S3 and Bedrock policies

aws iam attach-role-policy \
--role-name UnstractIRSARole \
--policy-arn arn:aws:iam::${AWS_ACCOUNT_ID}:policy/UnstractS3Access

aws iam attach-role-policy \
--role-name UnstractIRSARole \
--policy-arn arn:aws:iam::${AWS_ACCOUNT_ID}:policy/UnstractBedrockAccess

4.5 Capture the role ARN

ROLE_ARN=$(aws iam get-role \
--role-name UnstractIRSARole \
--query 'Role.Arn' \
--output text)

echo "$ROLE_ARN"
# Example: arn:aws:iam::<aws-account-id>:role/UnstractIRSARole

You will plug this ARN into Helm values in the next step.

Step 5 — One-time Bedrock console step (Anthropic models)

AWS auto-enables most serverless foundation models per account; Anthropic models are the exception and still require a one-time usage form. Submit it via:

  1. AWS Console → BedrockTest → Playground
  2. Select any Anthropic Claude model.
  3. Fill out the short usage form (company name, intended use case, etc.).
  4. Submit. Approval is typically immediate.

If submitted from the AWS Organizations management account, all member accounts inherit access. Skip this step if you only need non-Anthropic models.

Step 6 — Configure Unstract Helm values

The Unstract chart exposes IRSA config under global.irsa.

6.1 Enable IRSA in values.yaml

global:
cloud: aws
irsa:
enabled: true
roleArn: "arn:aws:iam::<aws-account-id>:role/UnstractIRSARole"

This causes the chart to render service accounts with the eks.amazonaws.com/role-arn annotation on every Unstract pod's service account:

metadata:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::<aws-account-id>:role/UnstractIRSARole
Helm invariants
  • When irsa.enabled: true, roleArn is required. When false (or unset), roleArn is ignored.
  • When IRSA is enabled, PERMANENT_REMOTE_STORAGE.credentials must not include key/secret. boto3 prefers explicit static credentials over the IRSA token, so any leftover keys in this field will cause IRSA to appear broken.

6.2 Configure S3 storage in secret.yaml

PERMANENT_REMOTE_STORAGE should have no access keys — credentials come from the IRSA token:

PERMANENT_REMOTE_STORAGE: &PERMANENT_REMOTE_STORAGE '{"provider": "s3", "credentials": {"endpoint_url":"https://s3.<aws-region>.amazonaws.com/", "region_name":"<aws-region>"}}'

6.3 Configure S3 paths in values.yaml

backend:
configMap:
REMOTE_SIMPLE_PROMPT_STUDIO_FILE_PATH: <s3-bucket-name>/simple-prompt-studio-data
REMOTE_PROMPT_STUDIO_FILE_PATH: <s3-bucket-name>/prompt-studio-data

platform:
configMap:
MODEL_PRICES_FILE_PATH: <s3-bucket-name>/cost/model_prices.json

prompt:
configMap:
REMOTE_PROMPT_STUDIO_FILE_PATH: <s3-bucket-name>/prompt-studio-data

6.4 Bedrock LLM adapter

When adding Bedrock as an LLM provider in the Unstract UI, leave the AWS Access Key ID and AWS Secret Access Key fields empty and pick IAM Role / Instance Profile as the Authentication Type. Provide only:

  • Model — e.g., us.anthropic.claude-sonnet-4-6
  • Region — e.g., us-east-1

LiteLLM (used internally by Unstract) resolves credentials through boto3's credential chain → IRSA web identity token → STS-issued temporary credentials.

For more on configuring the Bedrock adapter, see Amazon Bedrock.

Step 7 — Install Unstract

Follow the On-Prem Deployment Guide to install the Helm chart with the values configured in Step 6.

Step 8 — Verify IRSA is working

The most reliable check is the assumed-role ARN reported by STS — it confirms the pod is authenticating as the IRSA role.

8.1 Confirm the service account annotation

kubectl get sa ${SERVICE_ACCOUNT} -n ${NAMESPACE} -o yaml

You should see this annotation:

metadata:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::<aws-account-id>:role/UnstractIRSARole

8.2 Confirm IRSA env vars are injected into pods

POD=$(kubectl -n ${NAMESPACE} get pod -l app=unstract-backend -o jsonpath='{.items[0].metadata.name}')

kubectl exec -n ${NAMESPACE} ${POD} -- env | grep -E 'AWS_ROLE_ARN|AWS_WEB_IDENTITY_TOKEN_FILE'

Expected output:

AWS_ROLE_ARN=arn:aws:iam::<aws-account-id>:role/UnstractIRSARole
AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token

If these are missing, the pod was scheduled before the SA annotation existed. Restart the deployment:

kubectl rollout restart deployment -n ${NAMESPACE}

8.3 Confirm STS reports the IRSA role

kubectl exec -n ${NAMESPACE} ${POD} -- .venv/bin/python -c "
import boto3
print(boto3.client('sts').get_caller_identity())
"

Expected output:

{
'UserId': 'AROA...:botocore-session-...',
'Account': '<aws-account-id>',
'Arn': 'arn:aws:sts::<aws-account-id>:assumed-role/UnstractIRSARole/botocore-session-...'
}

The Arn segment must include assumed-role/UnstractIRSARole/.... If it shows something else, IRSA is not active — see Troubleshooting.

8.4 Confirm S3 access

kubectl exec -n ${NAMESPACE} ${POD} -- .venv/bin/python -c "
import boto3
print([b['Name'] for b in boto3.client('s3').list_buckets()['Buckets']])
"

8.5 Confirm Bedrock access via LiteLLM

kubectl exec -n ${NAMESPACE} ${POD} -- .venv/bin/python -c "
from litellm import completion
r = completion(
model='bedrock/us.anthropic.claude-sonnet-4-6',
messages=[{'role': 'user', 'content': 'Say hi in 3 words'}],
aws_region_name='us-east-1',
)
print(r.choices[0].message.content)
"

If this prints a response, the entire chain is working: pod JWT → STS → IRSA role → IAM policies → Bedrock.

8.6 Discover available models

Bedrock model IDs change over time. Discover what is currently available in your region:

kubectl exec -n ${NAMESPACE} ${POD} -- .venv/bin/python -c "
import boto3
client = boto3.client('bedrock', region_name='us-east-1')
for p in client.list_inference_profiles()['inferenceProfileSummaries']:
if 'anthropic' in p['inferenceProfileId'].lower():
print(p['inferenceProfileId'])
"

Typical naming patterns for current Anthropic models on Bedrock:

PatternExampleRouting
us.anthropic.claude-<family>us.anthropic.claude-sonnet-4-6US regions
eu.anthropic.claude-<family>eu.anthropic.claude-sonnet-4-6EU regions
apac.anthropic.claude-<family>apac.anthropic.claude-sonnet-4-6APAC regions
global.anthropic.claude-<family>global.anthropic.claude-opus-4-7All commercial regions

Troubleshooting

SymptomLikely causeFix
STS get_caller_identity shows node role ARN, not UnstractIRSARoleSA missing the eks.amazonaws.com/role-arn annotation, or pod was scheduled before annotation existedVerify with kubectl get sa -o yaml; restart pods after annotation is in place
AccessDenied: not authorized to perform sts:AssumeRoleWithWebIdentityTrust policy :sub condition does not match the actual service accountCompare system:serviceaccount:<ns>:<sa> against the trust policy exactly — namespace and SA name must match character-for-character
InvalidIdentityToken: No OpenIDConnect provider foundOIDC provider is not registered in IAMRe-run Step 3.3; verify with aws iam list-open-id-connect-providers
Pod has neither AWS_ROLE_ARN env var nor any AWS accessPod-identity webhook did not mutate the podConfirm SA annotation exists before pod creation; restart the pod
Trust policy keys reference the IAM ARN instead of issuer URLCommon mix-up — condition keys must be the bare issuer + :aud/:sub, not the IAM provider ARNRebuild the policy from the script in Step 4.2
Bedrock returns "This model version has reached the end of its life"Model ID is deprecatedUse Step 8.6 to discover current IDs
Bedrock returns on-demand throughput isn't supportedUsed a bare foundation model ID instead of an inference profileUse the prefixed form (us., eu., apac., global.)
Bedrock returns AccessDeniedException for Anthropic modelsAnthropic FTU form not submittedSubmit form via Bedrock Playground (Step 5)
S3 connectivity check fails in Unstract UIs3:ListAllMyBuckets missing from policyVerify the policy includes the ListAllBuckets statement

Useful debugging one-liners

# What SA is the pod actually using?
kubectl get pod <pod-name> -n ${NAMESPACE} -o jsonpath='{.spec.serviceAccountName}'

# What annotations does that SA have?
kubectl get sa <sa-name> -n ${NAMESPACE} -o yaml

# What's the JWT subject inside the pod? (must match trust policy)
kubectl exec -n ${NAMESPACE} <pod-name> -- \
cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token | \
cut -d. -f2 | base64 -d 2>/dev/null

# Are the IRSA env vars set?
kubectl exec -n ${NAMESPACE} <pod-name> -- env | grep AWS_

Maintenance notes

  • Adding a region: Both policies use wildcard regions, so no policy update is needed when expanding to new AWS regions. Only the Bedrock inference profile IDs change.
  • Adding a new model: No policy update required as long as it is a serverless foundation model. New Anthropic releases may require the FTU form — check Bedrock Playground if calls fail.
  • Scaling the cluster: New nodes require nothing extra — IRSA works at the pod level, not the node level.
  • Rotating the cluster: If the cluster is recreated, the OIDC issuer URL changes. Update the trust policy with aws iam update-assume-role-policy using the new issuer.
  • Tightening permissions: Resources can be narrowed to specific bucket names, model IDs, or inference profile ARNs. Do this incrementally and re-verify with the end-to-end checks after each change.