Skip to main content

AWS Deployment

This guide walks through deploying a Catalyst Enterprise Self-Hosted region into a private AWS VPC. It provisions an EKS cluster, RDS PostgreSQL, and a Bastion host, then installs the Catalyst Helm chart against the resulting cluster.

Demonstration only

This guide is intended as a reference walkthrough for demonstration and proof-of-concept deployments. It is not a production-ready deployment recipe — use the Production Planning guidance and adapt this Terraform to your organization's networking, security, and operational requirements before going to production.

Operating system

The commands in this guide are written for Linux or macOS. Adaptations may be required on Windows.

Architecture overview

The Terraform in this guide provisions a private VPC and deploys the Catalyst data plane to an EKS cluster inside it. Diagrid Cloud manages the Catalyst control plane over an mTLS connection from inside the VPC.

Prerequisites

  • Diagrid CLI
  • AWS CLI, configured with credentials for your AWS account
  • Terraform and make (used to provision the AWS infrastructure)
  • Helm
  • jq
  • An AWS account with permissions to create VPC, EKS, EC2, IAM, and related resources

The Terraform that provisions the AWS infrastructure used below is available in the guides/aws directory of the diagridio/charts repository. Clone the repository and run make from inside that directory:

git clone https://github.com/diagridio/charts.git
cd charts/guides/aws

1. Create a Catalyst region

Use the Diagrid CLI to register a new region. The wildcard ingress domain is configured later, once the Catalyst gateway Load Balancer is available.

diagrid login

# Create a new region (the ingress domain is updated later)
export JOIN_TOKEN=$(diagrid region create my-aws-region \
--ingress placeholder.example.com | jq -r .joinToken)

# Create an API key the Diagrid CLI can use later from inside the VPC
# (--duration is in seconds; 604800 = 7 days)
export API_KEY=$(diagrid apikey create \
--name aws-key \
--role cra.diagrid:editor \
--duration 604800 | jq -r .token)
note

Keep the values of JOIN_TOKEN and API_KEY at hand — steps 8 and 11 use them on the Bastion host, where these environment variables are not set. Print them with echo $JOIN_TOKEN / echo $API_KEY when you need to copy them over.

If diagrid apikey create fails with Maximum number of apikeys limit reached, list and remove unused keys with diagrid apikey list and diagrid apikey delete <id>. Similarly, diagrid region create fails if a region named my-aws-region already exists.

2. Deploy AWS resources

Use the provided Terraform to deploy the required infrastructure to your AWS account. This provisions an EKS cluster in a private VPC alongside a Bastion host that you can use to securely access the cluster for admin operations.

# Authenticate with AWS and confirm the active profile
aws sts get-caller-identity

# Initialize Terraform
make init

# Show the Terraform plan
make plan

# Apply the Terraform plan
make apply
tip

If make plan fails with Your query returned no results for the bastion AMI, the pinned Amazon Linux 2023 AMI has been deregistered by AWS. Override it with a currently available AMI without editing the Terraform:

TF_VAR_bastion_specific_ami_name=$(aws ec2 describe-images --owners amazon \
--filters "Name=name,Values=al2023-ami-2023.*-kernel-6.*-x86_64" \
--query 'reverse(sort_by(Images,&CreationDate))[0].Name' --output text) make plan

3. Connect to the Bastion host

Connect to the Bastion host via EC2 Instance Connect:

EC2_CONNECT_CMD=$(make output bastion_ec2_instance_connect_command)

# Execute the EC2 connect command
eval $EC2_CONNECT_CMD

The Bastion host resides within the private VPC and can communicate with the EKS cluster. It comes with kubectl, Helm, the AWS CLI, and the Diagrid CLI pre-installed — the later steps rely on these.

note

By default the bastion accepts SSH from anywhere (0.0.0.0/0). Restrict it to your network with make apply BASTION_ALLOWED_CIDR=<your-cidr>.

4. Configure Kubernetes access

Configure the pre-installed kubectl on the Bastion host to talk to your EKS cluster.

On your local machine, read the EKS cluster name and region from the Terraform outputs:

make output eks_cluster_name
make output eks_cluster_region

On the Bastion host SSH session, configure kubectl and set the default storage class:

export EKS_CLUSTER_NAME="<value-from-eks_cluster_name-output>"
export EKS_CLUSTER_REGION="<value-from-eks_cluster_region-output>"

aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION

kubectl config current-context

# Set the default storage class to gp2
kubectl patch storageclass gp2 \
-p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Optional: Install the krew kubectl plugin manager

Install krew on the Bastion host for quality-of-life utilities like kubectx and kubens:

sudo yum install git --assumeyes
(
set -x; cd "$(mktemp -d)" &&
OS="$(uname | tr '[:upper:]' '[:lower:]')" &&
ARCH="$(uname -m | sed -e 's/x86_64/amd64/' -e 's/\(arm\)\(64\)\?.*/\1\2/' -e 's/aarch64$/arm64/')" &&
KREW="krew-${OS}_${ARCH}" &&
curl -fsSLO "https://github.com/kubernetes-sigs/krew/releases/latest/download/${KREW}.tar.gz" &&
tar zxvf "${KREW}.tar.gz" &&
./"${KREW}" install krew
)

cat >> ~/.bashrc << EOF
export PATH="\${KREW_ROOT:-\$HOME/.krew}/bin:\$PATH"
EOF

Restart your shell so the PATH change takes effect, then install kubectx, kubens, and a small alias file:

kubectl krew install ctx
kubectl krew install ns

cat >> ~/.bashrc << EOF
source ~/.alias
EOF

cat > ~/.alias << EOF
alias k='kubectl'
alias kp='kubectl get pods'
alias kpa='kubectl get pods --all-namespaces'
alias kf='kubectl logs -f'
alias kx='k ctx'
alias kns='k ns'
EOF

5. Install the AWS Load Balancer controller

On your local machine, read the cluster name and the controller IAM role ARN from the Terraform outputs:

make output eks_cluster_name
make output aws_load_balancer_controller_role_arn

On the Bastion host SSH session, install the controller via Helm:

helm repo add eks https://aws.github.io/eks-charts
helm repo update eks

helm upgrade --install aws-load-balancer-controller eks/aws-load-balancer-controller \
--namespace kube-system \
--set clusterName=<value-from-eks_cluster_name> \
--set serviceAccount.create=true \
--set serviceAccount.name=aws-load-balancer-controller \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=<value-from-aws_load_balancer_controller_role_arn-output>

6. Install cert-manager

On your local machine, read the cert-manager IAM role ARN from the Terraform outputs:

make output cert_manager_role_arn

On the Bastion host SSH session, install cert-manager via Helm:

helm repo add jetstack https://charts.jetstack.io --force-update

helm upgrade --install \
cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version 1.21.0 \
--set crds.enabled=true \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=<value-from-cert_manager_role_arn-output>

Install a Let's Encrypt ClusterIssuer

Create a ClusterIssuer that uses the DNS-01 challenge via Route53. Pass the cert-manager IAM role ARN read above, use your own email address (Let's Encrypt sends expiry notices there), and make sure region matches the AWS region you deployed to (make output eks_cluster_region):

cat > ~/lets-encrypt-cluster-issuer.yaml << EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
email: <your-email-address>
privateKeySecretRef:
name: letsencrypt
server: https://acme-v02.api.letsencrypt.org/directory
solvers:
- dns01:
route53:
region: us-west-2
role: <value-from-cert_manager_role_arn-output>
auth:
kubernetes:
serviceAccountRef:
name: cert-manager
EOF

kubectl apply -f ~/lets-encrypt-cluster-issuer.yaml

Optional: provision an ECDSA CA for Dapr Sentry

Dapr Sentry generates its own Ed25519 root CA by default, and AWS IAM Roles Anywhere only accepts RSA or ECDSA trust anchors. If you plan to authenticate AWS components with IAM Roles Anywhere, give Sentry an ECDSA CA instead — cert-manager is already installed, so this is a good place to do it.

Do this before the region joins

The agent submits the Dapr CA to Diagrid Cloud when the region first joins, in step 8. Changing the CA afterwards makes the agent's next join fail with join token ... was already consumed ... with a different CA trust anchor, and refreshing the published trust anchors requires a region rejoin from Diagrid support.

Install trust-manager alongside cert-manager on the Bastion host SSH session:

helm upgrade trust-manager oci://quay.io/jetstack/charts/trust-manager \
--install \
--namespace cert-manager

Create a self-signed ECDSA root, an issuing CA for Sentry, and a Bundle that publishes the root:

cat > ~/dapr-pki.yaml << EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-ca
namespace: cert-manager
spec:
isCA: true
commonName: internal-ca
secretName: internal-ca
privateKey:
algorithm: ECDSA
# cert-manager would otherwise default this root to 90 days. This is the
# certificate you register as the Roles Anywhere trust anchor, and AWS stores
# a copy of it, so give it a long lifetime. The Sentry intermediate below
# rotates yearly underneath it without invalidating the anchor.
duration: 87600h # 10 years
renewBefore: 720h # renew 30 days before
issuerRef:
name: selfsigned
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: internal-ca-issuer
namespace: cert-manager
spec:
ca:
secretName: internal-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dapr-trust-bundle
namespace: cert-manager
spec:
# The issuer's subject must be non-empty. Workload leaf certificates take
# their Issuer DN from this certificate's subject, and IAM Roles Anywhere
# identifies a certificate by issuer + serial — it rejects a leaf whose
# issuer DN is empty with "Unable to form certificate ID".
commonName: dapr-sentry
uris:
- "spiffe://prj-root.trust.diagrid.io/ns/root-dapr-system/dapr-sentry"
isCA: true
usages:
- digital signature
- key encipherment
- cert sign
- crl sign
privateKey:
algorithm: ECDSA
size: 256
duration: 8760h # 1 year
renewBefore: 720h # renew 30 days before
secretName: dapr-trust-bundle
issuerRef:
name: internal-ca-issuer
kind: Issuer
---
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
name: dapr-trust-bundle
spec:
sources:
- secret:
name: dapr-trust-bundle
key: ca.crt
target:
configMap:
key: ca.crt
namespaceSelector:
matchLabels:
name: cert-manager
EOF

kubectl apply -f ~/dapr-pki.yaml

The bundle you publish must contain only self-signed root certificates — Diagrid Cloud rejects a trust anchor that contains an intermediate. Here ca.crt is the self-signed internal-ca root, and Sentry's own dapr-trust-bundle certificate is the intermediate that signs workload identities, so the chain validates against the root you register with AWS.

You wire these secrets into Catalyst in step 8. See the Dapr PKI guide and the Helm chart reference for the full set of options.

7. Install monitoring tools

These are recommended utilities for observing the cluster and Catalyst components. They are not strictly required for Catalyst to function.

Metrics server

helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/

helm upgrade --install metrics-server metrics-server/metrics-server

Prometheus node exporter

cat > ~/prometheus-node-exporter-config.yaml << EOF
podAnnotations:
prometheus.io/path: /metrics
prometheus.io/port: "9100"
prometheus.io/scrape: "true"
rbac:
pspEnabled: false
resources:
limits:
memory: 50Mi
requests:
cpu: 10m
memory: 25Mi
EOF

helm upgrade --install prometheus-node-exporter \
oci://ghcr.io/prometheus-community/charts/prometheus-node-exporter \
--values prometheus-node-exporter-config.yaml

kube-state-metrics

cat > ~/kube-state-metrics-config.yaml << EOF
podAnnotations:
prometheus.io/port: "8080"
prometheus.io/scrape: "true"
EOF

helm upgrade --install kube-state-metrics \
oci://ghcr.io/prometheus-community/charts/kube-state-metrics \
--values kube-state-metrics-config.yaml

8. Configure and install Catalyst

Secrets backend

This example uses the default Kubernetes Secrets backend. Catalyst can also be configured to use AWS Secrets Manager — see the Helm Chart Reference for the full set of options.

Read the RDS PostgreSQL connection details from the Terraform outputs:

make output postgresql_endpoint
make output postgresql_master_user_secret_arn
make output eks_cluster_region

# Fetch the master user password from AWS Secrets Manager
aws --region $(make output eks_cluster_region) secretsmanager get-secret-value \
--secret-id $(make output postgresql_master_user_secret_arn) \
--query SecretString --output text | jq -r '.password'

# Print the join token from step 1 so you can copy it to the Bastion host
echo $JOIN_TOKEN

On the Bastion host SSH session, create a Helm values file that wires Catalyst up to RDS and exposes the gateway via an AWS Network Load Balancer (NLB). The JOIN_TOKEN from step 1 is not set in the Bastion shell — export it with the value printed above:

export RDS_POSTGRESQL_ENDPOINT="<value-from-postgresql_endpoint-output>"
export RDS_POSTGRESQL_PASSWORD="<value-from-aws-cli>"
export JOIN_TOKEN="<value-from-step-1>"

cat > catalyst-values.yaml << EOF
agent:
config:
project:
default_managed_state_store_type: postgresql-shared-external
external_postgresql:
enabled: true
auth_type: connectionString
namespace: postgresql
connection_string_host: $RDS_POSTGRESQL_ENDPOINT
connection_string_port: 5432
connection_string_username: postgres
connection_string_password: "$RDS_POSTGRESQL_PASSWORD"
connection_string_database: catalyst
gateway:
tls:
enabled: true
secretName: "cert-wildcard"
envoy:
service:
type: LoadBalancer
httpsPort: 443
httpsTargetPort: 8443
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: "tcp"
service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "instance"
EOF

helm install catalyst oci://public.ecr.aws/diagrid/catalyst \
-n cra-agent \
--create-namespace \
-f catalyst-values.yaml \
--set join_token="${JOIN_TOKEN}" \
--version 1.80.0
Using your own Dapr CA

If you provisioned an ECDSA CA in step 6, point Sentry at it by adding this stanza under agent.config in catalyst-values.yaml before installing — the CA is submitted to Diagrid Cloud during this install and cannot be swapped afterwards without a region rejoin:

agent:
config:
internal_dapr:
pki:
issuer:
secret:
name: dapr-trust-bundle
namespace: cert-manager
cert: tls.crt
key: tls.key
trust:
config_map:
name: dapr-trust-bundle
namespace: cert-manager
chain: ca.crt

9. Configure ingress and a wildcard TLS certificate

Create a Route53 hosted zone and wildcard TLS certificate for a domain you own. This allows applications to reach the Catalyst gateway over a friendly DNS name and TLS.

note

This step is required for the rest of the guide: the Catalyst gateway pod stays unready until the certificate below is issued, and the Diagrid CLI commands in step 11 (diagrid listen, diagrid call) connect through the region's ingress domain, so they only work once DNS delegation and the certificate are in place.

REGION_INGRESS_ENDPOINT="<somename>.<domain that you own>" make apply

make output region_ingress_endpoint

# Update the region with the wildcard ingress domain
diagrid region update my-aws-region \
--ingress "<value-from-region_ingress_endpoint-output>"

Delegate the subdomain to Route53 by adding NS records in your domain registrar's DNS settings that point to the Route53 hosted zone's nameservers. Once delegation propagates, dig should resolve the NLB:

dig whatever.<somename>.<domain that you own>

Issue a Let's Encrypt wildcard certificate

On your local machine:

make output region_wildcard_domain

On the Bastion host SSH session:

cat > ~/wildcard-certificate.yaml << EOF
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: cert-wildcard
namespace: cra-agent
spec:
dnsNames:
- "<value-from-region_wildcard_domain-output>"
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: letsencrypt
secretName: cert-wildcard
usages:
- digital signature
- key encipherment
EOF

kubectl apply -f ~/wildcard-certificate.yaml

10. Verify the installation

Wait for all Catalyst pods to become ready. This can take several minutes.

kubectl -n cra-agent wait --for=condition=ready pod --all --timeout=5m
note

Until the wildcard certificate from step 9 is issued, the catalyst-gateway pod stays in Init with FailedMount warnings for the cert-wildcard secret — this is expected and resolves on its own once the certificate is ready. Also note that kubectl wait reports pods sequentially: a single unready pod makes it print timed out for every pod listed after it, even ones that are already ready. Use kubectl -n cra-agent get pods to see the actual per-pod state.

11. Create a project and deploy apps

On your local machine, create a project in the new region and a couple of apps to test connectivity:

diagrid project create aws-project --region my-aws-region --use

diagrid app create app1
diagrid app create app2

# Wait until the apps are ready
diagrid app list

Because the Catalyst gateway is internal to the VPC, test invocation from within the VPC. Open a new terminal window on your local machine and connect to the Bastion host again to start a listener:

# Print the API key from step 1 so you can copy it to the Bastion host
echo $API_KEY

EC2_CONNECT_CMD=$(make output bastion_ec2_instance_connect_command)
eval $EC2_CONNECT_CMD

# On the new Bastion host SSH session, log in with the API key from step 1
# (the API_KEY environment variable is not set in the Bastion shell)
diagrid login --api-key="<value-from-step-1>"
diagrid project use aws-project

# The Diagrid CLI can create a listener for an app and print any requests
# sent to it. Wait until you see:
# ✅ Connected App ID "app1" to http://localhost:<port> ⚡️
diagrid listen -a app1

From the original Bastion session, send a service-invocation request between apps:

# Call app1 from app2 via the internal gateway using the wildcard domain
diagrid call invoke get app1.hello -a app2

You should see the request received ('method': 'GET', 'url': '/hello') on the listener. This proves that you can use Dapr's service invocation API by calling an app via the internal NLB using the Catalyst region's wildcard domain.

To open the Catalyst web console for the project:

diagrid web

12. Write your applications

Now that the Catalyst region is up and a project with two apps is in place, head over to the Connect to Catalyst guide to start building applications that use these apps. Applications running inside the VPC can reach the internal gateway directly at http://<GATEWAY_HOSTNAME>:8080.

For applications running outside the VPC, you must own a domain that can route traffic to your Catalyst gateway, configure TLS on the gateway (already done in step 8: gateway.tls.enabled: true with the cert-wildcard secret issued in step 9), and ensure your applications trust the root CA for the certificate you use. Then point your applications at the Catalyst region with the standard Dapr environment variables:

  • DAPR_GRPC_ENDPOINT=<your-project-grpc-url>
  • DAPR_HTTP_ENDPOINT=<your-project-http-url>
  • DAPR_API_TOKEN=<your-app-api-token>

Authenticate AWS components with IAM Roles Anywhere

Catalyst apps can authenticate to AWS services — such as DynamoDB, S3, Secrets Manager, SNS/SQS, or RDS — using AWS IAM Roles Anywhere instead of storing long-lived AWS access keys in component metadata. Each app already has a SPIFFE X.509 identity issued by the region's Dapr Sentry; IAM Roles Anywhere lets that certificate be exchanged for short-lived AWS credentials at runtime.

Prerequisites

You need the AWS CLI configured with permission to manage IAM and Roles Anywhere resources, the Diagrid CLI, and an app in a Catalyst project. The examples also use jq.

Your region must also be running an RSA or ECDSA Dapr CA — see step 6. AWS rejects the Ed25519 CA that Dapr Sentry generates by default, so on a region left on that default create-trust-anchor fails with Unsupported key type and the CA cannot be changed without a region rejoin.

How it works

  • Each app is issued a SPIFFE X.509 SVID by the region's Dapr Sentry, of the form spiffe://<trust-domain>/ns/prj-<project-uid>/<app-name>.
  • You register the region's certificate authority (CA) as a Roles Anywhere trust anchor in your AWS account, so AWS trusts certificates issued by Sentry.
  • A Roles Anywhere profile links the trust anchor to an IAM role.
  • The role's trust policy allows the rolesanywhere.amazonaws.com service principal to assume it only for that app's SPIFFE ID, matched on the aws:PrincipalTag/x509SAN/URI condition. This is what binds a specific app to a specific AWS role.
  • At runtime the app's sidecar presents its SVID to IAM Roles Anywhere (CreateSession) and exchanges it for temporary STS credentials. No static keys are stored anywhere.

The setup-catalyst-iamra.sh helper script in guides/aws automates the trust anchor, role, and profile for a single app. Run it with --project and --app, or follow the manual steps below.

./setup-catalyst-iamra.sh --project <your-project> --app <your-app>

It resolves the app's SPIFFE ID, registers the region's CA as a trust anchor, creates the IAM role with the correct trust policy, and creates the profile — then prints the three ARNs you wire into the component.

Set up IAM Roles Anywhere manually

The following steps reproduce the helper script for one app. The trust anchor (step 3) is created once per region; repeat steps 4–5 for each app that needs its own role.

1. Set environment variables

Use the same AWS region you deployed the infrastructure to (make output eks_cluster_region), so the Roles Anywhere resources live alongside the rest of the deployment:

export CATALYST_PROJECT="aws-project"
export CATALYST_APP="app1"
export AWS_REGION="us-west-2"

2. Resolve the app's SPIFFE ID

The role's trust policy is scoped to this exact value:

SPIFFE_ID=$(diagrid app get "$CATALYST_APP" --project "$CATALYST_PROJECT" -o json | jq -r '.status.appIds[0].status.spiffeId')
echo "$SPIFFE_ID"
# spiffe://<trust-domain>/ns/prj-<project-uid>/app1

3. Register the trust anchor

The region object returned by the Diagrid CLI includes the region's CA bundle. Read it and register it as a Roles Anywhere trust anchor:

# Resolve the region for your project
CATALYST_REGION=$(diagrid project get "$CATALYST_PROJECT" -o json | jq -r '.spec.region')

# Read the region's CA bundle and register it as the trust anchor
diagrid region get "$CATALYST_REGION" -o json | jq -r '.status.trustAnchors' > diagrid-ca.pem

jq -n --arg name "catalyst-${CATALYST_REGION}" --rawfile pem diagrid-ca.pem \
'{name:$name, source:{sourceType:"CERTIFICATE_BUNDLE", sourceData:{x509CertificateData:$pem}}, enabled:true}' \
> trust-anchor.json

TRUST_ANCHOR_ARN=$(aws rolesanywhere create-trust-anchor --region "$AWS_REGION" \
--cli-input-json file://trust-anchor.json \
--query 'trustAnchor.trustAnchorArn' --output text)

The trust anchor holds a copy of the CA certificate, not a reference to it. If the Dapr CA is renewed or replaced, refresh the anchor with the new bundle — otherwise CreateSession starts failing once the registered certificate expires:

diagrid region get "$CATALYST_REGION" -o json | jq -r '.status.trustAnchors' > diagrid-ca.pem

aws rolesanywhere update-trust-anchor --region "$AWS_REGION" \
--trust-anchor-id "$(aws rolesanywhere list-trust-anchors --region "$AWS_REGION" \
--query "trustAnchors[?name=='catalyst-${CATALYST_REGION}'].trustAnchorId | [0]" --output text)" \
--source "$(jq -n --rawfile pem diagrid-ca.pem \
'{sourceType:"CERTIFICATE_BUNDLE", sourceData:{x509CertificateData:$pem}}')"
tip

The same CA bundle is also served unauthenticated over HTTPS from the region's trust endpoint, exposed on the region object at .status.endpoints.trustAnchors. Resolve the URL once, then any automation can fetch the bundle without the Diagrid CLI. Note that the endpoint is served by the region's own ingress, so whatever fetches it needs network connectivity to the private region, and it only points at the region once the wildcard ingress domain from step 9 is set — before that it falls back to Diagrid's control plane endpoint, which serves a different CA:

TRUST_ENDPOINT=$(diagrid region get "$CATALYST_REGION" -o json | jq -r '.status.endpoints.trustAnchors')
curl -fsS "$TRUST_ENDPOINT" -o diagrid-ca.pem

4. Create the IAM role and trust policy

The trust policy lets Roles Anywhere assume the role only for this app's SPIFFE ID:

cat > role-trust.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "rolesanywhere.amazonaws.com" },
"Action": ["sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"],
"Condition": {
"ArnEquals": { "aws:SourceArn": "${TRUST_ANCHOR_ARN}" },
"StringEquals": { "aws:PrincipalTag/x509SAN/URI": "${SPIFFE_ID}" }
}
}]
}
EOF

ROLE_ARN=$(aws iam create-role \
--role-name "catalyst-${CATALYST_APP}" \
--assume-role-policy-document file://role-trust.json \
--query 'Role.Arn' --output text)

5. Create the Roles Anywhere profile

PROFILE_ARN=$(aws rolesanywhere create-profile --region "$AWS_REGION" \
--name "catalyst-${CATALYST_APP}" \
--role-arns "$ROLE_ARN" \
--enabled \
--query 'profile.profileArn' --output text)

You now have the three ARNs every IAM Roles Anywhere component needs: TRUST_ANCHOR_ARN, PROFILE_ARN, and ROLE_ARN. The role grants no access yet — the example below attaches a permissions policy.

Example: AWS DynamoDB state store

This wires the app up to a DynamoDB table as a Dapr state store.

1. Create the DynamoDB table

The Dapr DynamoDB state store requires a partition key named key:

aws dynamodb create-table --region "$AWS_REGION" \
--table-name Contracts \
--attribute-definitions AttributeName=key,AttributeType=S \
--key-schema AttributeName=key,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
Partition key

The partition key must be named key (type S), with no sort key — this is what the Dapr DynamoDB state store expects. To use a different name, set the component's partitionKey metadata field.

2. Grant the role access to the table

Attach a read/write permissions policy to the role created above:

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

cat > perms.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan",
"dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:BatchWriteItem",
"dynamodb:TransactGetItems", "dynamodb:TransactWriteItems"
],
"Resource": [
"arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/Contracts",
"arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/Contracts/index/*"
]
}]
}
EOF

aws iam put-role-policy --role-name "catalyst-${CATALYST_APP}" \
--policy-name catalyst-access --policy-document file://perms.json

3. Create the Catalyst component

Create the component with the AWS: IAM Roles Anywhere authentication profile — the three ARNs from the setup steps — and scope it to the app:

diagrid component create dynamodb \
--type state.aws.dynamodb \
--project "$CATALYST_PROJECT" \
--metadata region="$AWS_REGION" \
--metadata table=Contracts \
--metadata assumeRoleArn="$ROLE_ARN" \
--metadata trustAnchorArn="$TRUST_ANCHOR_ARN" \
--metadata trustProfileArn="$PROFILE_ARN" \
--scopes "$CATALYST_APP" \
--wait

Once the component is ready, the app can read and write state through the Dapr state API, and Catalyst authenticates every DynamoDB call with short-lived credentials minted from the app's identity. The same assumeRoleArn / trustAnchorArn / trustProfileArn profile is available on the other AWS components (Secrets Manager, SNS/SQS, S3, RDS/PostgreSQL, and more) — swap the --type and the IAM permissions policy for the target service.