Self-hosting

Install on EKS

Install Agent Relay on an existing Amazon EKS cluster in 16 numbered steps, each with a check before you move on.

Agent version of this guide · Markdown

Run the steps in order. Copy each block, replace every <...>, and check the Done when line before you move on. Expect about two hours, most of it waiting on AWS.

Release: agent-relay-2026.11.0 (infra commit 3bb127d1, chart 0.6.46), tested end to end on EKS. To run a newer release, install this one and then follow Upgrades.

You need:

  • AWS admin in the target account, and kubectl admin on the EKS cluster;
  • whoever manages DNS for your domain (steps 5 and 15);
  • the person who will be the first admin (step 16).

Namespace: use agent-relay exactly. The commands, values.yaml and the Pod Identity associations all assume it.

Network:

  • Steps 1, 4 and 5 need only AWS access, not kubectl or VPN. Start them while cluster access is being sorted out.
  • With your own RDS, the database check in Use an existing or shared RDS is the only step that connects to the database directly. Every later database step runs inside the cluster.

1. Get the files

mkdir agent-relay-install && cd agent-relay-install
base=https://agentrelay.com/self-hosting/files/eks
curl -fsSLO "$base/SHA256SUMS"
for f in $(awk '{print $2}' SHA256SUMS); do curl -fsSLO "$base/$f"; done
shasum -a 256 -c SHA256SUMS
git clone https://github.com/AgentWorkforce/infra.git
git -C infra checkout agent-relay-2026.11.0
test "$(git -C infra rev-parse HEAD)" = 3bb127d1e1bde86fdb28fb15b7fad065c815779d && echo "release ok"

Tools: terraform 1.11 or later, helm 3.10 or later, kubectl, aws (CLI v2), jq, yq, openssl, python3 and curl 7.75 or later. On a Mac:

brew install hashicorp/tap/terraform helm kubectl awscli jq yq

rds-setup.sh uses sha256sum, which macOS has from version 14. On older macOS, also run brew install coreutils.

Done when: every shasum line ends in OK, and release ok prints.

2. Connect to AWS and the cluster

aws sts get-caller-identity
aws eks update-kubeconfig --name <cluster name> --region <region> --profile <your AWS profile>
kubectl config current-context
kubectl auth can-i '*' '*' --all-namespaces
kubectl get storageclass
aws eks list-addons --cluster-name <cluster name> --region <region>
kubectl -n kube-system get deploy aws-load-balancer-controller

Done when:

  • current-context names your cluster, and can-i prints yes;
  • one StorageClass is marked (default);
  • the add-ons include the EKS Pod Identity Agent and the EBS CSI driver, and the AWS Load Balancer Controller is running (as an add-on or with Helm).

No (default) StorageClass? Mark your gp3 class as the default:

kubectl patch storageclass gp3 -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Can't pull images from ghcr.io? Mirror them first: see Mirror the images to ECR at the end of this page.

3. Reusing an existing RDS instance? (skip if Terraform should create one)

Do every step of Use an existing or shared RDS now, then come back to step 4.

Your team creates the S3 bucket, OpenSearch, IAM roles and certificate itself? Skip steps 4 and 5. Follow Bring your own AWS resources instead, then continue at step 6.

4. Create the AWS resources (Terraform, about 20 minutes)

This creates an S3 bucket, a small OpenSearch domain, the HTTPS certificate and the IAM roles with their Pod Identity associations, plus the database unless you reuse your own.

4a. Make OpenSearch a single small node for a first install: one data node, no dedicated master nodes and 20 GB of disk, instead of the production default of three data nodes and three master nodes.

cd infra/terraform/examples/aws-existing-cluster
python3 - <<'EOF'
m=open('main.tf').read()
old='  instance_type      = var.search_instance_type\n'
new=old+'  instance_count           = 1\n  dedicated_master_enabled = false\n  ebs_volume_size          = 20\n'
assert m.count(old)==1; open('main.tf','w').write(m.replace(old,new)); print('search: 1 node')
EOF

Done when: search: 1 node prints.

4b. Find your own role's ARN. It becomes the OpenSearch admin, so step 7 needs no role switch:

aws iam get-role --role-name "$(aws sts get-caller-identity --query Arn --output text | cut -d/ -f2)" --query Role.Arn --output text

4c. Write terraform.tfvars, replacing every <...>:

cat > terraform.tfvars <<'EOF'
account_id       = "<12-digit account of the EKS cluster>"
region           = "<cluster region, e.g. us-west-2>"
eks_cluster_name = "<cluster name>"
domain           = "<app hostname, e.g. ar.example.com>"

sessions_bucket_name = "<globally unique bucket name, e.g. acme-agent-relay-sessions>"

# Search (one node, see 4a): one private subnet in the cluster's VPC, a security
# group allowing TCP 443 from the cluster, and the role ARN from 4b.
search_subnet_ids        = ["<private subnet id>"]
search_security_group_id = "<security group id>"
search_admin_role_arn    = "<role ARN from 4b>"
search_instance_type     = "m7g.medium.search"

# Database. With your own RDS (step 3) these are required but unused: leave
# them as they are. Otherwise: two private subnets in different Availability
# Zones, and a security group allowing TCP 5432 from the cluster.
database_subnet_ids        = ["<subnet id>", "<subnet id>"]
database_security_group_id = "<security group id>"
database_instance_class    = "db.t4g.medium"
EOF
grep -n '<' terraform.tfvars

Done when: the grep prints nothing. With your own RDS it may print only the two database lines.

To keep Terraform state in S3, add a backend "s3" {} block to the terraform block in main.tf and pass your settings with -backend-config. Without one, state stays in a local file: keep it safe.

4d. Plan and apply:

terraform init
terraform plan -out tfplan
terraform apply tfplan
cd -

Done when: the apply completes. The plan creates one aws_opensearch_domain, and no aws_db_instance if you reuse your own RDS.

  • An OpenSearch "service-linked role" error? Run aws iam create-service-linked-role --aws-service-name opensearchservice.amazonaws.com, then apply again.
  • AWS rejects m7g.medium.search? Use m6g.large.search.

5. Validate the certificate (DNS owner)

terraform -chdir=infra/terraform/examples/aws-existing-cluster output certificate_dns_records

Add those CNAME records in DNS.

Done when: the certificate check in step 6 prints ISSUED. That takes a few minutes once the records exist.

6. Create the settings file

cat > settings.sh <<'EOF'
export DOMAIN=<app hostname, e.g. ar.example.com>
export ORG_ID=<stable id, e.g. org_acme: never change it>
export ORG_NAME='<organization name>'
export ADMIN_EMAIL=<first admin's email>
export AWS_REGION=<region>
export ALB_SUBNET_CIDR_A=<CIDR of load balancer subnet 1>
export ALB_SUBNET_CIDR_B=<CIDR of load balancer subnet 2>
export IMAGE_REPO_PREFIX=ghcr.io/agentworkforce
export SERVICES_IMAGE=ghcr.io/agentworkforce/sessions-pipeline@sha256:77022c5b6a1d30f96f921d703eb27a1acada8ec273ad84aebdde24f8eddce305
export PSQL_IMAGE=ghcr.io/cloudnative-pg/postgresql:17-standard-trixie@sha256:ed6923f9ba2399cf9070379a353c710c29ced8a294be73986a2c3dae7e599ce3
export NAMESPACE=agent-relay
export INSTALL_DIR="$PWD"   # run "source settings.sh" from the agent-relay-install folder
export INFRA="$INSTALL_DIR/infra"
TF="$INFRA/terraform/examples/aws-existing-cluster"
export RDS_HOST="$(terraform -chdir="$TF" output -raw database_host)"
export RDS_ADMIN_SECRET_ARN="$(terraform -chdir="$TF" output -raw database_admin_secret_arn)"
export SESSIONS_BUCKET="$(terraform -chdir="$TF" output -raw sessions_bucket)"
export OPENSEARCH_URL="$(terraform -chdir="$TF" output -raw search_url)"
export CERT_ARN="$(terraform -chdir="$TF" output -raw certificate_arn)"
export INDEXER_ROLE_ARN="$(terraform -chdir="$TF" output -json search_roles | jq -r .indexer)"
export READER_ROLE_ARN="$(terraform -chdir="$TF" output -json search_roles | jq -r .reader)"
EOF

Load balancer subnets: the subnets your AWS Load Balancer Controller uses, tagged kubernetes.io/role/elb (internet-facing) or kubernetes.io/role/internal-elb (internal). This lists the cluster VPC's subnets with their CIDRs:

VPC="$(aws eks describe-cluster --name <cluster name> --query cluster.resourcesVpcConfig.vpcId --output text)"
aws ec2 describe-subnets --filters Name=vpc-id,Values=$VPC Name=tag-key,Values=kubernetes.io/role/elb \
  --query 'Subnets[].[SubnetId,CidrBlock,AvailabilityZone]' --output table

Reusing your own RDS? In settings.sh, replace the two RDS_ lines with export RDS_HOST=<rds endpoint> and export RDS_ADMIN_SECRET_ARN=<secret ARN from the existing-RDS page>.

Brought your own AWS resources? Replace the Terraform lines as Bring your own AWS resources shows.

Then load and check it:

source settings.sh
bash -c 'source ./settings.sh; for v in DOMAIN ORG_ID ORG_NAME ADMIN_EMAIL AWS_REGION ALB_SUBNET_CIDR_A ALB_SUBNET_CIDR_B RDS_HOST RDS_ADMIN_SECRET_ARN SESSIONS_BUCKET OPENSEARCH_URL CERT_ARN INDEXER_ROLE_ARN READER_ROLE_ARN; do [ -n "${!v}" ] && [[ "${!v}" != *"<"* ]] || echo "MISSING: $v"; done; echo "settings checked"'
aws acm describe-certificate --certificate-arn "$CERT_ARN" --query Certificate.Status --output text

Done when: only settings checked prints, and the certificate status is ISSUED.

In every new terminal, first run cd agent-relay-install && source settings.sh.

Run this as the role that is the OpenSearch admin (search_admin_role_arn). If that is your own role from 4b:

bash opensearch-roles-via-cluster.sh

It maps the indexer and reader roles inside OpenSearch, through a short-lived relay pod in the cluster, so you need no VPC access. The pod pulls alpine/socat from Docker Hub; mirror it first if the cluster can't reach Docker Hub.

Done when: four responses show CREATED or OK, then search roles done.

A 403? You aren't using the OpenSearch admin role.

8. Set up the databases

bash rds-setup.sh pre

It creates the relaycontrol, hoopdb and relaycast databases and their roles in short-lived Jobs inside the cluster, plus the rds-ca ConfigMap that verified TLS needs. It writes every credential to ~/.agent-relay-install, which only you can read, and prints none of them.

Done when: it ends with pre done.

Copy ~/.agent-relay-install to your secret store now: it holds the database passwords. Never commit it.

"already belongs to another HoopDb install"? One RDS instance holds only one Agent Relay install, because one of its roles is server-wide. Use a separate instance.

9. Fill in values.yaml

export RDS_CA_SHA256="$(cat ~/.agent-relay-install/rds-ca.sha256)"
sed -i.orig -e "s|__IMAGE_REPO_PREFIX__|$IMAGE_REPO_PREFIX|g" -e "s|__ORG_ID__|$ORG_ID|g" \
  -e "s|__ORG_NAME__|$ORG_NAME|g" -e "s|__ADMIN_EMAIL__|$ADMIN_EMAIL|g" -e "s|__DOMAIN__|$DOMAIN|g" \
  -e "s|__REGION__|$AWS_REGION|g" -e "s|__SESSIONS_BUCKET__|$SESSIONS_BUCKET|g" \
  -e "s|__OPENSEARCH_URL__|$OPENSEARCH_URL|g" -e "s|__RDS_CA_SHA256__|$RDS_CA_SHA256|g" \
  -e "s|__ALB_SUBNET_CIDR_A__|$ALB_SUBNET_CIDR_A|g" -e "s|__ALB_SUBNET_CIDR_B__|$ALB_SUBNET_CIDR_B|g" \
  values.yaml
grep -n '__[A-Z_]*__' values.yaml

Done when: the only line printed is __LLM_MODEL__. You set that one later, when you turn on titles and summaries.

values.yaml holds no secret values, only the names of Secrets and keys, so it is safe to keep in version control.

10. Create the secrets

This generates the remaining keys and creates the five Kubernetes Secrets. Nothing is printed.

S=~/.agent-relay-install/secret-files
( umask 077
for k in selfhost-bootstrap-token selfhost-setup-key relaycast-internal-secret \
         relaycast-workspace-bootstrap-secret relaycontrol-introspect-secret session-read-api-secret; do
  [ -s "$S/agent-relay-secrets/$k" ] || openssl rand -hex 32 | tr -d '\n' > "$S/agent-relay-secrets/$k"
done
[ -s "$S/agent-relay-secrets/selfhost-encryption-key" ] || openssl rand -hex 32 | tr -d '\n' > "$S/agent-relay-secrets/selfhost-encryption-key"
[ -s "$S/agent-relay-secrets/SUMMARY_MASTER_KEYS" ] || printf 'k1:%s' "$(openssl rand -hex 32)" > "$S/agent-relay-secrets/SUMMARY_MASTER_KEYS"
cp "$S/agent-relay-secrets/relaycontrol-introspect-secret" "$S/agent-relay-ingest-edge-runtime/introspect-secret" )
for name in agent-relay-secrets agent-relay-read-api agent-relay-ingest-edge-runtime \
            search-indexer-runtime search-indexer-owner; do
  kubectl -n agent-relay create secret generic "$name" --from-file="$S/$name" \
    --dry-run=client -o yaml | kubectl apply -f -
done

Done when: five lines end in created.

Copy ~/.agent-relay-install to your secret store again: it now holds the encryption keys. Data encrypted with selfhost-encryption-key and SUMMARY_MASTER_KEYS can't be recovered without them.

Keep your secrets in SOPS, SSM Parameter Store or another store? Use one of the options in Secrets instead of this block.

11. Install with Helm

cd "$INFRA"
helm dependency build charts/sessions-pipeline && helm dependency build charts/agent-relay
helm upgrade --install agent-relay charts/agent-relay --namespace agent-relay \
  -f charts/agent-relay/values-eks.yaml -f charts/agent-relay/values-eks-native.yaml \
  -f "$INSTALL_DIR/values.yaml" --timeout 25m
cd "$INSTALL_DIR"

Done when: it prints STATUS: deployed.

Don't add --wait: the read API, ingest edge and search indexer can't become ready until step 13, so --wait would stall, and it wouldn't report a failed migration anyway.

12. Wait for the database migrations

for j in $(kubectl -n agent-relay get jobs -o name | grep -E 'relay-control-migrate|relaycast-migrate|agent-relay-worker-migrate'); do
  kubectl -n agent-relay wait --for=condition=complete "$j" --timeout=20m
done

Done when: three lines say condition met.

Expected at this point: the search-indexer-bootstrap Job fails, and some pods restart. Step 13 fixes both.

A migration Job failed? Read its log with kubectl -n agent-relay logs job/<job name> --tail=100, fix the cause, delete the failed Job, and run step 11 again, which recreates it.

13. Switch on the service logins

bash rds-setup.sh post
kubectl -n agent-relay rollout restart deploy/agent-relay-read-api deploy/agent-relay-ingest-edge deploy/agent-relay-search-indexer
for j in $(kubectl -n agent-relay get jobs -o name | grep search-indexer-bootstrap); do kubectl -n agent-relay delete "$j"; done
cd "$INFRA"
helm upgrade --install agent-relay charts/agent-relay --namespace agent-relay \
  -f charts/agent-relay/values-eks.yaml -f charts/agent-relay/values-eks-native.yaml \
  -f "$INSTALL_DIR/values.yaml" --timeout 25m
cd "$INSTALL_DIR"
kubectl -n agent-relay wait --for=condition=complete "$(kubectl -n agent-relay get jobs -o name | grep search-indexer-bootstrap)" --timeout=10m
kubectl -n agent-relay rollout status deploy --timeout=10m
kubectl -n agent-relay get pods

Done when:

  • post done prints after three login ok ... tls=true lines;
  • the bootstrap Job shows condition met;
  • every pod is Running and ready.

The search indexer restarts until the bootstrap Job finishes. That's expected, which is why the rollout status check comes last.

A 403 from the bootstrap Job? Redo step 7.

14. Create the load balancer

cd "$INFRA"
EKS_ALB_DOMAIN="$DOMAIN" EKS_ALB_CERT_ARN="$CERT_ARN" EKS_ALB_INGEST=true \
  scripts/selfhost/eks-alb-ingress.sh agent-relay > "$INSTALL_DIR/ingress.yaml"
cd "$INSTALL_DIR"
kubectl apply -f ingress.yaml
sleep 60; kubectl -n agent-relay get ingress agent-relay -o jsonpath='{.status.loadBalancer.ingress[0].hostname}{"\n"}'

For an internal load balancer, change internet-facing to internal in ingress.yaml before kubectl apply.

Done when: it prints a hostname ending in elb.amazonaws.com. If it's empty, run the last line again.

15. Point DNS at it and check (DNS owner)

Create CNAME records for <domain>, api.<domain>, cast.<domain> and history.<domain>, all pointing at that hostname. After a minute or two:

curl -fsS "https://api.$DOMAIN/readyz" && echo " api ok"
curl -fsS "https://cast.$DOMAIN/health" && echo " cast ok"
curl -fsS -o /dev/null -w "dashboard %{http_code}\n" "https://$DOMAIN/"
curl -s -o /dev/null -w "history %{http_code}\n" "https://history.$DOMAIN/v1/sessions"

Done when: api ok, cast ok, dashboard 200 and history 401. The history host answers 401 without a token, by design.

  • Could not resolve host: wait, then run it again. New records can take a couple of minutes.
  • 504: a load balancer subnet is missing from values.yaml. Fix step 9, then run step 11 again.

16. First sign-in, then add users

The first admin does this before signing in any other way, including from the desktop app. The setup key works only while the admin has never signed in, and it expires 24 hours after the API first starts.

kubectl -n agent-relay get secret agent-relay-secrets -o jsonpath='{.data.selfhost-setup-key}' | base64 -d; echo
  1. Open https://<domain>/login and choose First-time setup: enter setup key.
  2. Paste the key and choose a password (12 to 128 characters).

Then add users:

bash add-user.sh "$DOMAIN"

It signs you in as the owner, invites each email you type, and prints a single-use temporary password for each. They sign in at https://<domain>/login and choose their own. With single sign-on, people don't need to be added at all.

Done when: the admin is signed in to the dashboard.

Key lost or expired? Mint a new one with the bootstrap token:

BOOTSTRAP_TOKEN="$(cat ~/.agent-relay-install/secret-files/agent-relay-secrets/selfhost-bootstrap-token)"
curl -fsS -X POST -H "Authorization: Bearer $BOOTSTRAP_TOKEN" \
  "https://api.$DOMAIN/api/v1/installation/setup-key"; unset BOOTSTRAP_TOKEN

Next

Images

Everything the install runs, pinned by digest. A render of the chart pulls the first four; rds-setup.sh runs the services image and the PostgreSQL client.

ImageVersionimage@digest
Control API0.5.16ghcr.io/agentworkforce/relay-control@sha256:0b9c5a077b49b31178cc669e7c1d532f39e64c840087ca41636bb84a6c62631c
Dashboard0a4dc037ghcr.io/agentworkforce/relay-webapp@sha256:dfdf6db66808d8fa6a2921e8bfcea2dfd6b0cd78fb1bb636fbb4bd3302bc21fa
Relaycast12.8.0-collector.1ghcr.io/agentworkforce/relaycast-self-host@sha256:ab9dd6781e68f09d5e2e686a17088f9ad533978ef13d974a47d6c212d8eedbde
Sessions servicesalpha.5 (7e9b2f0f)ghcr.io/agentworkforce/sessions-pipeline@sha256:77022c5b6a1d30f96f921d703eb27a1acada8ec273ad84aebdde24f8eddce305
PostgreSQL client17-standard-trixieghcr.io/cloudnative-pg/postgresql:17-standard-trixie@sha256:ed6923f9ba2399cf9070379a353c710c29ced8a294be73986a2c3dae7e599ce3

Each image line in values.yaml sits under x-pins. Don't swap an image on its own: move to a new release instead (see Upgrades).

Mirror the images to ECR

Skip this if the cluster can pull from ghcr.io. Otherwise mirror the five images into ECR before step 8 (which already runs two of them). Each image is copied by digest with its whole multi-architecture index, so the digests stay the same. It needs aws and crane, and runs much faster from AWS CloudShell or an EC2 instance in the same region than from a laptop.

export AWS_REGION=<region>
export ECR=<account>.dkr.ecr.<region>.amazonaws.com
aws ecr get-login-password --region "$AWS_REGION" | crane auth login "$ECR" -u AWS --password-stdin
copied=0
for src in \
  ghcr.io/agentworkforce/relay-control@sha256:0b9c5a077b49b31178cc669e7c1d532f39e64c840087ca41636bb84a6c62631c \
  ghcr.io/agentworkforce/relay-webapp@sha256:dfdf6db66808d8fa6a2921e8bfcea2dfd6b0cd78fb1bb636fbb4bd3302bc21fa \
  ghcr.io/agentworkforce/relaycast-self-host@sha256:ab9dd6781e68f09d5e2e686a17088f9ad533978ef13d974a47d6c212d8eedbde \
  ghcr.io/agentworkforce/sessions-pipeline@sha256:77022c5b6a1d30f96f921d703eb27a1acada8ec273ad84aebdde24f8eddce305 \
  ghcr.io/cloudnative-pg/postgresql@sha256:ed6923f9ba2399cf9070379a353c710c29ced8a294be73986a2c3dae7e599ce3
do
  repo="agent-relay/${src%@*}"; digest="${src#*@}"
  aws ecr describe-repositories --region "$AWS_REGION" --repository-names "$repo" >/dev/null 2>&1 \
    || aws ecr create-repository --region "$AWS_REGION" --repository-name "$repo" >/dev/null
  if crane copy "$src" "$ECR/$repo:${digest/:/-}" && crane digest "$ECR/$repo@$digest" >/dev/null; then
    echo "ok $repo"; copied=$((copied+1))
  else
    echo "FAILED $src" >&2
  fi
done
echo "$copied of 5 copied"

Done when: it ends with 5 of 5 copied. If any line says FAILED, run the whole block again; images already copied are quick.

Then, in settings.sh (step 6):

  • IMAGE_REPO_PREFIX=$ECR/agent-relay/ghcr.io/agentworkforce;
  • SERVICES_IMAGE=$ECR/agent-relay/ghcr.io/agentworkforce/sessions-pipeline@sha256:77022c5b… and PSQL_IMAGE=$ECR/agent-relay/ghcr.io/cloudnative-pg/postgresql@sha256:ed6923f9…, with the full digests from the loop.

Stuck? See troubleshooting or support.