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
kubectladmin 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
kubectlor 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.
Agent: Before step 1, ask the human for, and do not guess: the AWS profile and region, the EKS cluster name, the app hostname (), whether to reuse an existing RDS instance, whether their team creates S3/OpenSearch/IAM/ACM themselves, the organization ID and display name, and the first admin's email. Record the answers in a plain notes file in the install folder. Never record passwords, keys or tokens there.
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 yqrds-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.
Agent: STOP if any checksum line is not OK or "release ok" does not print. Do not edit the downloaded files to make a check pass.
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-controllerDone when:
current-contextnames your cluster, andcan-iprintsyes;- 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.
Agent: STOP and ask the human if: can-i is not "yes", the context names a different cluster, a required add-on is missing, or there is no default StorageClass. Patching the StorageClass changes the cluster: ask before running it.
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')
EOFDone 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 text4c. 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.tfvarsDone 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? Usem6g.large.search.
Agent: Show the human the plan summary line ("Plan: N to add, M to change, K to destroy") and the resource types it creates. ASK THE HUMAN for approval before "terraform apply". STOP if the plan changes or destroys anything that already existed, or creates an aws_db_instance when the human chose an existing RDS.
5. Validate the certificate (DNS owner)
terraform -chdir=infra/terraform/examples/aws-existing-cluster output certificate_dns_recordsAdd those CNAME records in DNS.
Done when: the certificate check in step 6 prints ISSUED. That takes a few
minutes once the records exist.
Agent: ASK THE HUMAN (DNS owner) to create these CNAME records. You do not change DNS. Continue with step 6 while they do it.
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)"
EOFLoad 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 tableReusing 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 textDone when: only settings checked prints, and the certificate status is
ISSUED.
In every new terminal, first run cd agent-relay-install && source settings.sh.
Agent: Fill DOMAIN, ORG_ID, ORG_NAME and ADMIN_EMAIL only with the human's answers. If more than two load balancer subnets are listed, ASK THE HUMAN which two (or more) the load balancer uses. A third subnet needs one more ipBlock in each networkPolicy block of values.yaml. If the certificate is not ISSUED yet, wait and re-check; do not continue past step 13 without it.
7. Give the app access to search
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.shIt 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 preIt 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.
Agent: Never print, cat, copy into chat or summarize any file under ~/.agent-relay-install. ASK THE HUMAN to copy that folder to their secret store; do not do it yourself unless they name the exact destination.
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.yamlDone 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 -
doneDone 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.
Agent: ASK THE HUMAN for approval before this helm command. This command is for a fresh install on an empty database only. STOP if "helm list -n agent-relay" already shows a release: that is an upgrade, see the Upgrades guide.
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
doneDone 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.
Agent: STOP if any of the three migration Jobs fails. Show the human the last 100 log lines (check them for secrets first) and do not delete Jobs without approval.
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 podsDone when:
post doneprints after threelogin ok ... tls=truelines;- the bootstrap Job shows
condition met; - every pod is
Runningand 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.
Agent: ASK THE HUMAN whether the load balancer is internet-facing or internal before kubectl apply.
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.
Agent: ASK THE HUMAN (DNS owner) to create the four CNAME records and tell you when done. Then poll the checks every 30 seconds for up to 10 minutes.
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- Open
https://<domain>/loginand choose First-time setup: enter setup key. - 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_TOKENAgent: Do NOT run the setup-key command, the bootstrap-token command or add-user.sh yourself: each prints a credential. ASK THE HUMAN (the first admin) to run them in their own terminal and to tell you only "signed in" when done.
Next
- Before people start: in the dashboard, under Settings › Policies, set what the desktop app uploads.
- Desktop app: roll it out with Desktop app rollout, run one agent session, and check it appears under Sessions.
- Then: After the install (titles and summaries), single sign-on, and backups.
- Problems: Troubleshooting, then Support.
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.
| Image | Version | image@digest |
|---|---|---|
| Control API | 0.5.16 | ghcr.io/agentworkforce/relay-control@sha256:0b9c5a077b49b31178cc669e7c1d532f39e64c840087ca41636bb84a6c62631c |
| Dashboard | 0a4dc037 | ghcr.io/agentworkforce/relay-webapp@sha256:dfdf6db66808d8fa6a2921e8bfcea2dfd6b0cd78fb1bb636fbb4bd3302bc21fa |
| Relaycast | 12.8.0-collector.1 | ghcr.io/agentworkforce/relaycast-self-host@sha256:ab9dd6781e68f09d5e2e686a17088f9ad533978ef13d974a47d6c212d8eedbde |
| Sessions services | alpha.5 (7e9b2f0f) | ghcr.io/agentworkforce/sessions-pipeline@sha256:77022c5b6a1d30f96f921d703eb27a1acada8ec273ad84aebdde24f8eddce305 |
| PostgreSQL client | 17-standard-trixie | ghcr.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…andPSQL_IMAGE=$ECR/agent-relay/ghcr.io/cloudnative-pg/postgresql@sha256:ed6923f9…, with the full digests from the loop.