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).
This guide is EKS-specific. For non-EKS Kubernetes clusters, use static credentials as described in the Infrastructure Requirements.
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+).
kubectlconfigured against the cluster.- AWS CLI with permissions to create IAM OIDC providers, IAM policies, IAM roles, and S3 buckets.
eksctlinstalled (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": "*"
}
]
}
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 asPrincipal.Federated. - Bare-issuer form — used as the prefix of both the
:audand:subcondition 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
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:
- AWS Console → Bedrock → Test → Playground
- Select any Anthropic Claude model.
- Fill out the short usage form (company name, intended use case, etc.).
- 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
- When
irsa.enabled: true,roleArnis required. Whenfalse(or unset),roleArnis ignored. - When IRSA is enabled,
PERMANENT_REMOTE_STORAGE.credentialsmust not includekey/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:
| Pattern | Example | Routing |
|---|---|---|
us.anthropic.claude-<family> | us.anthropic.claude-sonnet-4-6 | US regions |
eu.anthropic.claude-<family> | eu.anthropic.claude-sonnet-4-6 | EU regions |
apac.anthropic.claude-<family> | apac.anthropic.claude-sonnet-4-6 | APAC regions |
global.anthropic.claude-<family> | global.anthropic.claude-opus-4-7 | All commercial regions |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
STS get_caller_identity shows node role ARN, not UnstractIRSARole | SA missing the eks.amazonaws.com/role-arn annotation, or pod was scheduled before annotation existed | Verify with kubectl get sa -o yaml; restart pods after annotation is in place |
AccessDenied: not authorized to perform sts:AssumeRoleWithWebIdentity | Trust policy :sub condition does not match the actual service account | Compare system:serviceaccount:<ns>:<sa> against the trust policy exactly — namespace and SA name must match character-for-character |
InvalidIdentityToken: No OpenIDConnect provider found | OIDC provider is not registered in IAM | Re-run Step 3.3; verify with aws iam list-open-id-connect-providers |
Pod has neither AWS_ROLE_ARN env var nor any AWS access | Pod-identity webhook did not mutate the pod | Confirm SA annotation exists before pod creation; restart the pod |
| Trust policy keys reference the IAM ARN instead of issuer URL | Common mix-up — condition keys must be the bare issuer + :aud/:sub, not the IAM provider ARN | Rebuild the policy from the script in Step 4.2 |
Bedrock returns "This model version has reached the end of its life" | Model ID is deprecated | Use Step 8.6 to discover current IDs |
Bedrock returns on-demand throughput isn't supported | Used a bare foundation model ID instead of an inference profile | Use the prefixed form (us., eu., apac., global.) |
Bedrock returns AccessDeniedException for Anthropic models | Anthropic FTU form not submitted | Submit form via Bedrock Playground (Step 5) |
| S3 connectivity check fails in Unstract UI | s3:ListAllMyBuckets missing from policy | Verify 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-policyusing 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.