Agent Router data plane installation for AWS
This guide installs the Agent Router data plane on AWS Elastic Kubernetes Service (EKS).
Overview
Agent Router uses a split-plane model. A Management Plane hosted by Tetrate holds configuration and exposes the web applications. A Data Plane runs in the target AWS account and handles all AI traffic. The two planes communicate over one outbound HTTPS connection initiated by the data plane; no inbound internet connections are required.
By the end of this guide:
- An EKS cluster with the EBS CSI driver installed
- An ECR registry mirroring the Agent Router images
- The Agent Router data plane running in the
tars-systemandtars-dataplanenamespaces - An Application Load Balancer fronting the data plane at a DNS name the organization owns
- An OpenTelemetry pipeline forwarding metrics to the observability stack
Plan for 30 to 45 minutes of installation time, plus DNS propagation.
Table of contents
- Prepare for the installation: obtain the data plane credential and install the CLI
- Cluster setup: create or reuse a Kubernetes cluster
- Registry setup: mirror Agent Router images so the cluster can pull them
- Data Plane installation: deploy the data plane
- Ingress setup: expose the data plane externally
- DNS configuration: wire the hostname to the ingress and register the URL on the management plane
- Testing the installation: verify the install works end-to-end
- Operations
- Appendices
Prerequisites
Cluster requirements
The data plane installs a GatewayClass named tars-egress whose controllerName is the Envoy Gateway default, gateway.envoyproxy.io/gatewayclass-controller. An Envoy Gateway that already watches the whole cluster claims tars-egress too, and the two controllers contend over it. There is no override: install the data plane in a cluster of its own. See A cluster already running Envoy Gateway.
Dashboard and Router app access
Agent Router has two web surfaces. Both URLs are provided during onboarding:
- Dashboard (admin):
https://dashboard.<your-tenant>.tetrate.ai. Used in Step 1 and Step 12. - Router app (end-user):
https://router.<your-tenant>.tetrate.ai. Used in Step 14 to create API keys for callers.
Tools
Install the following on the host that will run the guide:
| Tool | Install |
|---|---|
| aws CLI v2 | https://docs.aws.amazon.com/cli/latest/userguide/install.html |
| eksctl ≥ 0.220 | https://eksctl.io/installation/ (or brew install eksctl). Older versions do not recognize current EKS K8s versions. |
| kubectl | https://kubernetes.io/docs/tasks/tools/ |
| helm (3+) | https://helm.sh/docs/intro/install/ |
| docker | https://docs.docker.com/get-docker/ |
| curl | preinstalled on macOS and most Linux distros |
| tare CLI | covered in Step 2 |
A data-plane-credentials.json file is also required; see Step 1.
Infrastructure
A dedicated workload cluster must be provisioned before starting the installation. The cluster should consist of at least three (3) nodes. See Cluster sizing for more details.
Tetrate support does not cover client-side infrastructure provisioning or Kubernetes issues. The instructions for creating clusters and related infrastructure components are provided as a courtesy and should be carefully evaluated before executing them.
AWS permissions
The operator needs permission to manage these AWS services on the target account:
| Service | Used for |
|---|---|
| EKS (AmazonEKSClusterPolicy, AmazonEKSWorkerNodePolicy) | Cluster creation and node groups |
| EC2 + VPC | Cluster networking, EBS volumes, ELB provisioning |
| IAM | OIDC provider, IRSA roles for EBS CSI driver and AWS Load Balancer Controller |
| CloudFormation | eksctl deploys everything via CloudFormation stacks |
| ECR | Create repositories, push/pull images |
| ELB (Elastic Load Balancing) | ALB created by the AWS Load Balancer Controller |
The simplest setup is an admin role on a sandbox account. For a constrained role, AWS publishes the minimum policy eksctl needs at https://eksctl.io/usage/minimum-iam-policies/.
Cluster sizing
The chart's default install runs multiple always-on components, including the Agent Router egress proxy (Envoy-based; referred to as egress envoy in manifests and logs) with a minimum of 2 replicas, AI gateway controller and ext_proc, controller / worker, Redis / rate-limit. This is not a single-node footprint.
The egress envoy is the dominant resource consumer. Both CPU and memory usage grow with the configuration size the proxy holds in memory: the number of AIGatewayRoute and AIServiceBackend resources, header-mutation rules, and other per-route features. The AI gateway team's control-plane scaling benchmark shows roughly linear CPU and memory growth from adding routes, with memory staying elevated to keep the xDS state available to serve traffic. Plan for routes to scale up as the data plane adds providers, models, and projects. General-purpose EC2 families (m5.* or m6i.*, balanced CPU/RAM) are the right default.
| Size | Use case | Recommended node pool | Approx allocatable target |
|---|---|---|---|
| Small | dev / test / low traffic | 3 × m5.large | ≥ 6 vCPU, ≥ 20 GiB RAM |
| Medium | staging / light production | 3 × m5.xlarge | ≥ 12 vCPU, ≥ 40 GiB RAM |
| High | production with burst headroom | 3 × m5.2xlarge (or split into system + data plane node groups) | ≥ 24 vCPU, ≥ 80 GiB RAM |
Practical floor: 3 nodes minimum to survive a node drain or upgrade. Demo installs can start at Small; production should start at Medium.
Conventions
The steps below export environment variables (AWS_REGION, EKS_CLUSTER_NAME, AWS_ACCOUNT, ECR_HOST, CREDENTIAL_FILE, SERVE_URL, and others) as they become needed. Each later step assumes the variables defined earlier are still exported in the current shell. If a new shell is started mid-install, re-export them before continuing.
Step 1: obtain the data plane credential
In the dashboard, go to System → Settings → Data plane credentials → + Generate Data plane credential.
Save the downloaded file as data-plane-credentials.json on the host where the install runs. This file is the long-lived identity the data plane uses to authenticate to the management plane.
Some parts of the product still use older "service account" naming for this file. CLI output and in-cluster paths may reference it as a service account; it is the same file. The dashboard is in the process of standardizing on "data plane credential".
Each data plane uses its own credential. Revoke a credential from the dashboard or generate additional ones (for example, one per environment) at any time.
Step 2: install the tare CLI
curl -sSL https://tare.tetrate.ai/tools/install.sh | bash
Output:
==> tare installer
==> channel: stable
==> Detected platform: darwin-arm64
==> Installing tare for darwin-arm64...
==> Downloading from: https://tare.tetrate.ai/tools/tags/v0.1.0-beta.2/tare-darwin-arm64.tar.gz
ok Installed tare to /Users/johndoe/.tare/bin/tare
==> tare version: tare version v0.1.0-beta.2
ok Installation directory is already in your PATH
==> Get started:
tare install identity.json --serve-url https://proxy.acme.com
tare install --help
The installer prints the install path (typically ~/.tare/bin/tare). Add it to PATH and verify the version:
export PATH="$PATH:$HOME/.tare/bin"
echo 'export PATH="$PATH:$HOME/.tare/bin"' >> ~/.zshrc # or ~/.bashrc
$ tare --version
tare version v0.1.0-beta.2
Step 3: provision the eks cluster
Step 3.1: set environment variables
export AWS_REGION=<region> # e.g. us-east-1
export EKS_CLUSTER_NAME=<cluster-name>
export AWS_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
# Each tare release supports a fixed Kubernetes range and rejects anything
# outside it. The EKS regional default runs ahead of that range: it is 1.36 in
# us-east-1 today, which current releases reject. Pin the version instead.
export K8S_VERSION=1.35
# Optional resource tags: adapt or drop these for your organization.
TAGS="Owner=<your-name>,Team=<your-team>,Purpose=development"
The supported Kubernetes range is fixed per tare release and enforced when the data plane is installed, not when the cluster is created. A cluster outside it fails in Step 9 with Kubernetes 1.36.2-eks-... is outside the supported range 1.30.0-1.35.x for this tare release, which names the range the release accepts. That failure arrives after the cluster has been built and the images synced, and EKS cannot be downgraded in place, so the cluster has to be rebuilt. The range for every supported release is listed on Release compatibility.
Confirm eksctl version reports ≥ 0.220 before continuing. Older binaries reject the K8s version EKS now defaults to and the cluster create call fails with invalid version, supported values: 1.23, …, 1.31.
Step 3.2: log in to AWS
aws sts get-caller-identity
Step 3.3: create the eks cluster
eksctl create cluster \
--name "${EKS_CLUSTER_NAME}" \
--region "${AWS_REGION}" \
--version "${K8S_VERSION}" \
--nodes 3 \
--nodes-min 3 \
--nodes-max 3 \
--node-type m5.xlarge \
--with-oidc \
--tags "${TAGS}"
Provisioning takes ~15 minutes. --with-oidc enables IAM Roles for Service Accounts (IRSA), which the EBS CSI driver and AWS Load Balancer Controller depend on.
The --nodes 3 and --node-type m5.xlarge values above match the Medium tier in Cluster sizing. Adjust for the target environment.
Step 3.4: verify
kubectl get nodes
Expected:
NAME STATUS ROLES AGE VERSION
ip-192-168-14-164.ec2.internal Ready <none> 2m v1.35.x-eks-...
ip-192-168-25-245.ec2.internal Ready <none> 2m v1.35.x-eks-...
ip-192-168-40-57.ec2.internal Ready <none> 2m v1.35.x-eks-...
Reuse it after confirming that:
- OIDC provider is associated:
aws eks describe-cluster --name <name> --region <region> --query "cluster.identity.oidc.issuer" - EBS CSI driver is installed:
aws eks list-addons --cluster-name <name> --region <region> --query 'addons' | grep aws-ebs-csi-driver - K8s version is supported (
aws eks describe-cluster-versions --region <region>shows the cluster's version is not deprecated)
Step 4: install the ebs csi driver addon
The Agent Router egress proxy and Redis use persistent volumes, and EKS does not install a CSI driver by default.
# IAM role for the addon (one-shot, keeps the role even after cluster recreation)
eksctl create iamserviceaccount \
--name ebs-csi-controller-sa \
--namespace kube-system \
--cluster "${EKS_CLUSTER_NAME}" \
--region "${AWS_REGION}" \
--attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy \
--approve --role-only \
--role-name "${EKS_CLUSTER_NAME}-AmazonEKS_EBS_CSI_DriverRole"
# Install the addon itself
eksctl create addon \
--cluster "${EKS_CLUSTER_NAME}" \
--name aws-ebs-csi-driver \
--region "${AWS_REGION}" \
--service-account-role-arn "arn:aws:iam::${AWS_ACCOUNT}:role/${EKS_CLUSTER_NAME}-AmazonEKS_EBS_CSI_DriverRole" \
--force
Wait for it to become ACTIVE (~1 min):
aws eks describe-addon --cluster-name "${EKS_CLUSTER_NAME}" \
--addon-name aws-ebs-csi-driver --region "${AWS_REGION}" \
--query 'addon.status' --output text
Step 5: create ecr repositories
Agent Router images must live in the target registry. Unlike most cloud registries, ECR requires each repository to be pre-created; tare install --image-sync does not auto-create them, and a single missing repository aborts the entire sync rather than skipping one image.
The set of repositories changes across tare releases. Rather than hand-maintaining a list, this guide uses a helper that asks tare which images it will sync, creates a repository for each, and then runs the sync. A repository name keeps the full path after the registry host, so an image published as otel/opentelemetry-collector-contrib needs <prefix>/otel/opentelemetry-collector-contrib and not <prefix>/opentelemetry-collector-contrib. Anything the image list does not predict is created from the error tare reports, and the sync is retried.
export ECR_HOST="${AWS_ACCOUNT}.dkr.ecr.${AWS_REGION}.amazonaws.com"
export ECR_PREFIX="${EKS_CLUSTER_NAME}" # repos are created as ${EKS_CLUSTER_NAME}/<image>
Save the helper as sync-images.sh in the current directory. It is run in Step 7, once Docker is authenticated to ECR.
cat > sync-images.sh <<'SCRIPT'
#!/usr/bin/env bash
# Sync Agent Router images to ECR, creating the repositories they need.
#
# tare install --image-sync requires every destination repository to exist
# before it pushes, and one missing repository aborts the whole sync. So this
# script works in two phases:
# 1. ask tare which images it will sync and create a repository for each.
# The repository name keeps the full path after the registry host, so
# registry.tetrate.ai/otel/opentelemetry-collector-contrib:<tag> becomes
# <prefix>/otel/opentelemetry-collector-contrib
# 2. run the sync. If tare still reports NAME_UNKNOWN for a repository the
# list did not predict, create that one and retry; tare resumes from
# where it stopped. The same retry covers transient registry errors.
#
# Requires: aws, tare, and AWS_REGION, ECR_HOST, ECR_PREFIX, CREDENTIAL_FILE
# exported in the environment.
set -uo pipefail
: "${AWS_REGION:?set AWS_REGION}"
: "${ECR_HOST:?set ECR_HOST}"
: "${ECR_PREFIX:?set ECR_PREFIX}"
: "${CREDENTIAL_FILE:?set CREDENTIAL_FILE}"
OUT="${OUT:-$(mktemp -t tare-sync.XXXXXX)}"
MAX_ITER="${MAX_ITER:-20}"
create_repo() {
if aws ecr describe-repositories --repository-names "$1" \
--region "${AWS_REGION}" >/dev/null 2>&1; then
echo " exists: $1"
return 0
fi
aws ecr create-repository --repository-name "$1" --region "${AWS_REGION}" \
--query 'repository.repositoryName' --output text | sed 's/^/ created: /'
}
echo "=== creating a repository for every image tare will sync ==="
IMAGES=$(tare install "${CREDENTIAL_FILE}" --print-images) || {
echo "=== could not list images; check the credential file ==="
exit 1
}
echo "${IMAGES}" | sed -E 's#^[^/]+/##; s#:[^:/]+$##' | sort -u | while read -r img; do
[[ -n "${img}" ]] && create_repo "${ECR_PREFIX}/${img}"
done
for i in $(seq 1 "${MAX_ITER}"); do
echo "=== sync attempt ${i}/${MAX_ITER} ==="
tare install "${CREDENTIAL_FILE}" \
--image-sync "${ECR_HOST}/${ECR_PREFIX}" \
--sync-only 2>&1 | tee "${OUT}"
RC=${PIPESTATUS[0]}
if [[ ${RC} -eq 0 ]]; then
echo "=== sync complete ==="
exit 0
fi
# A repository the image list did not predict:
# "with name 'my-cluster/envoy-tars' does not exist"
MISSING=$(grep -oE "name '[^']+' does not exist" "${OUT}" | head -1 \
| sed -E "s/name '([^']+)' does not exist/\1/")
if [[ -n "${MISSING}" ]]; then
echo "=== creating missing repository: ${MISSING} ==="
create_repo "${MISSING}"
continue
fi
# Transient registry errors: back off and retry.
if grep -qE "i/o timeout|context deadline exceeded|EOF|connection reset|TLS handshake" "${OUT}"; then
echo "=== transient registry error, retrying in 15s ==="
sleep 15
continue
fi
echo "=== unrecoverable failure (rc=${RC}), see ${OUT} ==="
exit "${RC}"
done
echo "=== gave up after ${MAX_ITER} attempts ==="
exit 1
SCRIPT
chmod +x sync-images.sh
Step 6: authenticate Docker to ecr
aws ecr get-login-password --region "${AWS_REGION}" | docker login --username AWS --password-stdin "${ECR_HOST}"
ECR tokens expire after 12 hours. Re-run this command if subsequent steps fail with unauthorized: authentication required.
Step 7: sync Agent Router images to ecr
Save data-plane-credentials.json to a known path, then run:
export CREDENTIAL_FILE=/path/to/data-plane-credentials.json
bash sync-images.sh
The helper creates any missing ECR repos as tare reports them. Expect ~3 to 5 minutes on the first run. On success it prints:
✓ Image sync done in 2m22s
=== sync complete ===
Step 8: create the image-pull secret
kubectl create ns tars-system --dry-run=client -o yaml | kubectl apply -f -
kubectl create ns tars-dataplane --dry-run=client -o yaml | kubectl apply -f -
ECR_TOKEN=$(aws ecr get-login-password --region "${AWS_REGION}")
for NS in tars-system tars-dataplane; do
kubectl create secret docker-registry registry-secret \
--docker-server="${ECR_HOST}" \
--docker-username=AWS \
--docker-password="${ECR_TOKEN}" \
-n "${NS}" \
--dry-run=client -o yaml | kubectl apply -f -
done
ECR tokens expire every 12 hours, so this secret stops working after that window. For production deployments, use IAM Roles for Service Accounts (IRSA) or EKS Pod Identity to mint tokens on demand instead of a static secret; see AWS docs.
Step 9: install the data plane
Step 9.1: apply the CRDs
The chart references Envoy Gateway custom resources, so the CRDs are applied first. On a cluster that has never run Agent Router this pass is required; without it the install stops during preflight with no matches for kind "BackendTrafficPolicy" in version "gateway.envoyproxy.io/v1alpha1".
tare install "${CREDENTIAL_FILE}" --crds-only
This applies 28 CRDs and takes around four minutes. It creates no namespaces, no pull secret, and no workloads.
Step 9.2: install
tare install "${CREDENTIAL_FILE}" \
--image-sync "${ECR_HOST}/${ECR_PREFIX}" \
--image-pull-secret-name registry-secret
The gateway URL is not part of the install: it is registered on the management plane in Step 12.2, once the hostname is known. Since 0.3.0 the management plane owns that value and pushes it to the running data plane; the old --serve-url flag is deprecated and ignored.
The command:
- Re-syncs any images (idempotent after the previous step).
- Installs the helm chart into
tars-systemandtars-dataplane.
Watch the pods come up:
kubectl get pods -n tars-system
kubectl get pods -n tars-dataplane
Expected output (all Running, 1/1 or 3/3):
NAME READY STATUS RESTARTS AGE
ai-gateway-controller-... 1/1 Running 0 2m
controller-... 1/1 Running 0 2m
controller-worker-... 1/1 Running 0 2m
envoy-gateway-... 1/1 Running 0 2m
envoy-ratelimit-... 1/1 Running 0 1m
tars-redis-master-... 1/1 Running 0 2m
tars-tare-doctor-... 1/1 Running 0 1m
NAME READY STATUS RESTARTS AGE
egress-... 3/3 Running 0 1m
The install command also prints a Dataplane unreachable warning at this point because DNS does not resolve the data-plane hostname yet. This is expected; DNS is wired in Step 12 below.
Step 10: install the AWS load balancer controller
The data plane needs an L7 load balancer to terminate inbound HTTP and forward to the egress Service in tars-dataplane. The AWS Load Balancer Controller provisions an ALB from Kubernetes Ingress resources.
Step 10.1: iam policy
The latest controller version requires permissions beyond what the v2.7.1 reference policy covered. Fetch the current policy from main:
curl -sSL -o /tmp/iam-policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/main/docs/install/iam_policy.json
aws iam create-policy \
--policy-name AWSLoadBalancerControllerIAMPolicy \
--policy-document file:///tmp/iam-policy.json
If the policy already exists from a prior run, update it instead:
POLICY_ARN=arn:aws:iam::${AWS_ACCOUNT}:policy/AWSLoadBalancerControllerIAMPolicy
aws iam create-policy-version \
--policy-arn "${POLICY_ARN}" \
--policy-document file:///tmp/iam-policy.json \
--set-as-default
Step 10.2: iam service account
eksctl create iamserviceaccount \
--cluster="${EKS_CLUSTER_NAME}" \
--region="${AWS_REGION}" \
--namespace=kube-system \
--name=aws-load-balancer-controller \
--attach-policy-arn="arn:aws:iam::${AWS_ACCOUNT}:policy/AWSLoadBalancerControllerIAMPolicy" \
--approve --override-existing-serviceaccounts
Step 10.3: Helm install
helm repo add eks https://aws.github.io/eks-charts
helm repo update
VPC_ID=$(aws eks describe-cluster --name "${EKS_CLUSTER_NAME}" \
--region "${AWS_REGION}" \
--query "cluster.resourcesVpcConfig.vpcId" --output text)
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName="${EKS_CLUSTER_NAME}" \
--set serviceAccount.create=false \
--set serviceAccount.name=aws-load-balancer-controller \
--set region="${AWS_REGION}" \
--set vpcId="${VPC_ID}"
Wait for the controller pods to roll out:
kubectl rollout status -n kube-system deployment/aws-load-balancer-controller --timeout=180s
Step 11: create the alb ingress
All data-plane traffic (/v1/*, /mcp/*, /.well-known/*) is served by the egress Service in tars-dataplane on port 10080. A single Ingress is enough.
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tars-ingress
namespace: tars-dataplane
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]'
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: egress
port:
number: 10080
EOF
Wait for the ALB to provision (~1 to 2 min) and capture its hostname:
kubectl get ingress tars-ingress -n tars-dataplane
The ADDRESS column populates with something like k8s-tarsdata-tarsingr-xxxxxxxxxx-yyyyyyyyy.us-east-1.elb.amazonaws.com.
If ADDRESS stays empty for more than a few minutes, check the controller for FailedDeployModel events:
kubectl get events -n tars-dataplane --sort-by=.lastTimestamp | tail -5
kubectl logs -n kube-system deployment/aws-load-balancer-controller --tail=30 | grep -iE "error|fail"
The most common cause is an outdated IAM policy missing a permission such as elasticloadbalancing:DescribeListenerAttributes. Re-run the IAM policy update above and restart the controller.
The Ingress persists across tare install reinstalls; re-applying it during upgrades is not required.
Step 12: wire DNS and register the URL
Step 12.1: DNS
Point the data-plane hostname at the ALB via a CNAME record at the DNS provider:
proxy.example.com CNAME k8s-tarsdata-tarsingr-xxxxxxxxxx-yyyyyyyyy.us-east-1.elb.amazonaws.com
Verify it resolves (propagation may take a couple of minutes):
dig +short proxy.example.com
Step 12.2: register the URL on the management plane
In the Admin Console: System → Settings → Data planes → edit the data plane → URL. Set it to the hostname just wired in DNS. The management plane pushes the value to the running data plane; no re-install is needed.
Step 13: verify the install
Run tare doctor to check pod, CRD, and policy state:
tare doctor "${CREDENTIAL_FILE}"
A healthy install reports its components in Accepted state.
A data plane that has just been installed has no routes delivered to it yet, so the report also carries warnings for EnvoyPatchPolicy, RouteDeployment and AIGatewayRoute ("none found"). Those clear once the management plane delivers configuration for a project assigned to this data plane.
Read the component states rather than the exit code: tare doctor exits non-zero on any error, and on a freshly installed data plane it can report forward/check failed (apiserver pod-proxy) for the egress pod even when the control plane can reach it. Check that case before escalating, by requesting the pod's health endpoint over the same path the doctor uses:
POD=$(kubectl get pods -n tars-dataplane -l app=egress -o jsonpath='{.items[0].metadata.name}')
kubectl get --raw "/api/v1/namespaces/tars-dataplane/pods/${POD}:10080/proxy/healthz"
{"status":"ok"} means the pod-proxy path works and the report is not describing a network fault. If any component is not Accepted, or the health endpoint does not answer, contact Tetrate Support.
Step 14: smoke tests
Once tare doctor reports clean, validate end-to-end traffic.
Create an API key from the router app (Router → API Keys → + Create):
export DP_HOST=proxy.example.com
export TARS_API_KEY=<your-api-key>
Save the suite as smoke.sh. Each endpoint is called twice, once with the key and once without, and a 200 on an unauthenticated call is reported as an auth bypass:
cat > smoke.sh <<'SCRIPT'
#!/usr/bin/env bash
# Smoke test the Agent Router data plane. Every endpoint is called twice: once
# with an API key (expect 200) and once without one (expect 401). A 200 on an
# unauthenticated call is an auth bypass and fails the run.
#
# Requires: curl, and DP_HOST and TARS_API_KEY exported in the environment.
# Set DP_SCHEME=https once TLS is terminated at the load balancer.
#
# The model names are the defaults from onboarding. Override any of them to
# match the models enabled in the project.
set -uo pipefail
: "${DP_HOST:?set DP_HOST}"
: "${TARS_API_KEY:?set TARS_API_KEY}"
BASE="${DP_SCHEME:-http}://${DP_HOST}"
CHAT_MODEL="${CHAT_MODEL:-gpt-5-mini}"
ANTHROPIC_MODEL="${ANTHROPIC_MODEL:-claude-haiku-4-5}"
EMBEDDING_MODEL="${EMBEDDING_MODEL:-text-embedding-3-small}"
IMAGE_MODEL="${IMAGE_MODEL:-gpt-image-1.5}"
JSON="Content-Type: application/json"
AUTH="Authorization: Bearer ${TARS_API_KEY}"
PASS=0; FAIL=0; BYPASS=0
call() { # call <label> <expected-status> <curl args...>
local label=$1 expected=$2; shift 2
local body status mark
body=$(mktemp)
status=$(curl -s -o "${body}" -w '%{http_code}' "$@")
if [[ "${status}" == "${expected}" ]]; then
mark=ok; PASS=$((PASS+1))
elif [[ "${expected}" == "401" && "${status}" == "200" ]]; then
mark="AUTH BYPASS"; BYPASS=$((BYPASS+1))
else
mark=fail; FAIL=$((FAIL+1))
fi
printf '%-12s %-34s http=%-4s expected=%s\n' "${mark}" "${label}" "${status}" "${expected}"
[[ "${mark}" == ok ]] || printf ' body: %s\n' "$(head -c 200 "${body}" | tr '\n' ' ')"
rm -f "${body}"
}
echo "==== list models (GET /v1/models) ===="
call "models auth" 200 "${BASE}/v1/models" -H "${AUTH}"
call "models no-auth" 401 "${BASE}/v1/models"
echo
echo "==== chat completions (POST /v1/chat/completions) ===="
DATA="{\"model\":\"${CHAT_MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"reply with one word\"}]}"
call "chat auth" 200 "${BASE}/v1/chat/completions" -X POST -H "${JSON}" -H "${AUTH}" -d "${DATA}"
call "chat no-auth" 401 "${BASE}/v1/chat/completions" -X POST -H "${JSON}" -d "${DATA}"
echo
echo "==== responses (POST /v1/responses) ===="
DATA="{\"model\":\"${CHAT_MODEL}\",\"input\":\"reply with one word\"}"
call "responses auth" 200 "${BASE}/v1/responses" -X POST -H "${JSON}" -H "${AUTH}" -d "${DATA}"
call "responses no-auth" 401 "${BASE}/v1/responses" -X POST -H "${JSON}" -d "${DATA}"
echo
echo "==== anthropic messages (POST /v1/messages) ===="
DATA="{\"model\":\"${ANTHROPIC_MODEL}\",\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"reply with one word\"}]}"
AV="anthropic-version: 2023-06-01"
call "messages auth" 200 "${BASE}/v1/messages" -X POST -H "${JSON}" -H "${AV}" -H "${AUTH}" -d "${DATA}"
call "messages no-auth" 401 "${BASE}/v1/messages" -X POST -H "${JSON}" -H "${AV}" -d "${DATA}"
echo
echo "==== embeddings (POST /v1/embeddings) ===="
DATA="{\"model\":\"${EMBEDDING_MODEL}\",\"input\":\"hello world\"}"
call "embeddings auth" 200 "${BASE}/v1/embeddings" -X POST -H "${JSON}" -H "${AUTH}" -d "${DATA}"
call "embeddings no-auth" 401 "${BASE}/v1/embeddings" -X POST -H "${JSON}" -d "${DATA}"
echo
echo "==== image generation (POST /v1/images/generations) ===="
DATA="{\"model\":\"${IMAGE_MODEL}\",\"prompt\":\"a red apple on a white background\",\"size\":\"1024x1024\",\"n\":1}"
call "images auth" 200 "${BASE}/v1/images/generations" -X POST -H "${JSON}" -H "${AUTH}" -d "${DATA}"
call "images no-auth" 401 "${BASE}/v1/images/generations" -X POST -H "${JSON}" -d "${DATA}"
echo
echo "----"
printf 'pass: %d fail: %d auth-bypass: %d\n' "${PASS}" "${FAIL}" "${BYPASS}"
[[ "${FAIL}" -eq 0 && "${BYPASS}" -eq 0 ]]
SCRIPT
chmod +x smoke.sh
Run it:
bash smoke.sh
Expected output:
==== list models (GET /v1/models) ====
ok models auth http=200 expected=200
ok models no-auth http=401 expected=401
==== chat completions (POST /v1/chat/completions) ====
ok chat auth http=200 expected=200
ok chat no-auth http=401 expected=401
==== responses (POST /v1/responses) ====
ok responses auth http=200 expected=200
ok responses no-auth http=401 expected=401
==== anthropic messages (POST /v1/messages) ====
ok messages auth http=200 expected=200
ok messages no-auth http=401 expected=401
==== embeddings (POST /v1/embeddings) ====
ok embeddings auth http=200 expected=200
ok embeddings no-auth http=401 expected=401
==== image generation (POST /v1/images/generations) ====
ok images auth http=200 expected=200
ok images no-auth http=401 expected=401
----
pass: 12 fail: 0 auth-bypass: 0
If any row fails, and in particular if a no-auth row returns 200 instead of 401, contact Tetrate Support.
Once TLS is terminated at the load balancer (Appendix A), re-run the suite with DP_SCHEME=https.
Upgrading
An upgrade re-runs the install command. The helm chart, CRDs, and config in tars-config are upgraded; persistent state (Redis-backed rate-limit counters, accumulated audit data) survives. The ALB Ingress persists, the IAM policy does not change, and the EBS CSI addon remains installed.
tare install "${CREDENTIAL_FILE}" \
--image-sync "${ECR_HOST}/${ECR_PREFIX}" \
--image-pull-secret-name registry-secret
The gateway URL is already registered on the management plane and does not need to be passed again.
Some runtime patches (notably observability env vars on the egress deployment) are reset on every install. Re-apply the patches from Appendix B after each upgrade.
Cleanup
To remove everything created by this guide:
# 1. Ingress (releases the ALB)
kubectl delete ingress tars-ingress -n tars-dataplane --ignore-not-found
# 2. AWS Load Balancer Controller
helm uninstall aws-load-balancer-controller -n kube-system 2>/dev/null || true
eksctl delete iamserviceaccount \
--name aws-load-balancer-controller \
--namespace kube-system \
--cluster "${EKS_CLUSTER_NAME}" \
--region "${AWS_REGION}" 2>/dev/null || true
ALB_POLICY_ARN=$(aws iam list-policies \
--query "Policies[?PolicyName=='AWSLoadBalancerControllerIAMPolicy'].Arn" \
--output text 2>/dev/null)
if [ -n "${ALB_POLICY_ARN}" ] && [ "${ALB_POLICY_ARN}" != "None" ]; then
# Delete non-default versions first (IAM rule)
for V in $(aws iam list-policy-versions --policy-arn "${ALB_POLICY_ARN}" \
--query 'Versions[?IsDefaultVersion==`false`].VersionId' --output text); do
aws iam delete-policy-version --policy-arn "${ALB_POLICY_ARN}" --version-id "${V}"
done
aws iam delete-policy --policy-arn "${ALB_POLICY_ARN}"
fi
# 3. ECR repositories (the cluster prefix matches what was used at create time)
for REPO in $(aws ecr describe-repositories --region "${AWS_REGION}" \
--query "repositories[?starts_with(repositoryName, '${EKS_CLUSTER_NAME}/')].repositoryName" \
--output text); do
aws ecr delete-repository --repository-name "${REPO}" \
--region "${AWS_REGION}" --force
done
# 4. EKS cluster (also tears down the EBS CSI addon and its IAM role via
# CloudFormation, takes ~10 min)
eksctl delete cluster --name "${EKS_CLUSTER_NAME}" --region "${AWS_REGION}"
# 5. Local kubeconfig file
rm -f "${HOME}/kubeconfig-${EKS_CLUSTER_NAME}"
DNS records (CNAMEs at the DNS provider) must be removed manually.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| eksctl create cluster errors with invalid version, supported values: 1.23, …, 1.31 | eksctl is older than the K8s version EKS currently defaults to | brew upgrade eksctl (or re-download), then retry |
| tare install fails at preflight with Kubernetes ... is outside the supported range | The cluster was created on a version newer than the tare release supports, commonly because the EKS regional default moved ahead | Recreate the cluster on a version inside the range (Step 3.1); an EKS cluster cannot be downgraded in place |
| tare install fails at Register data plane URL with permission_denied ... requires scopes [dataplane_operator] | The install was run with the deprecated --serve-url flag on an older CLI. Nothing has been deployed | Re-run without --serve-url (or upgrade tare, which ignores it), and register the URL in Step 12.2. See the error reference |
| tare install --sync-only errors with NAME_UNKNOWN: The repository with name '...' does not exist | ECR repo for that image was never created | The sync-images.sh helper from Step 5 handles this; re-run it |
| Ingress ADDRESS stays empty for more than 2 min | LB Controller IAM policy is missing newer permissions | Update the IAM policy from main, restart the controller |
| Pods stuck ImagePullBackOff with no basic auth credentials | ECR token in registry-secret expired (12h lifetime) | Re-run the image-pull-secret creation step (Step 8) |
| egress pods restart loop with connection refused to redis | EBS CSI driver missing, so Redis PV never binds | Install the EBS CSI addon (Step 4) |
Appendix a: TLS via acm + HTTPS listener
The default flow uses HTTP on port 80. For production, terminate TLS at the ALB using an AWS Certificate Manager (ACM) certificate.
-
Request the certificate in ACM for the data-plane hostname. Validate via DNS (CNAME) or email per the domain control method. If this gateway serves a multi-gateway project fronted by DNS failover, the certificate must also carry the project's client-facing name as a SAN. A name missing here stays invisible until the first failover. See the multi-SAN worked example in the failover guide.
-
Get the certificate ARN:
CERT_ARN=$(aws acm list-certificates --region "${AWS_REGION}" \--query "CertificateSummaryList[?DomainName=='proxy.example.com'].CertificateArn" \--output text) -
Update the Ingress to listen on HTTPS:443 and redirect HTTP → HTTPS:
metadata:annotations:alb.ingress.kubernetes.io/scheme: internet-facingalb.ingress.kubernetes.io/target-type: ipalb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'alb.ingress.kubernetes.io/ssl-redirect: '443'alb.ingress.kubernetes.io/certificate-arn: <CERT_ARN>alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06 -
Update the gateway URL on the management plane to
https://..., in the Admin Console under System → Settings → Data planes → edit the data plane → URL.
The ALB picks up the annotation change without redeploying. Verify with:
curl -I https://proxy.example.com/v1/models -H "Authorization: Bearer ${TARS_API_KEY}"
Appendix b: observability with OpenTelemetry
The data plane emits OTLP metrics (router_* family). To forward them to an observability backend, deploy an OpenTelemetry Collector in-cluster.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
name: otel-system
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-collector-config
namespace: otel-system
data:
config.yaml: |
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
processors:
batch:
timeout: 5s
transform/strip_scope:
metric_statements:
- context: metric
statements:
- replace_pattern(name, "^dynamicmodulescustom\\.", "router_")
exporters:
debug:
verbosity: detailed
# Replace this with your real backend exporter (Datadog, Grafana Cloud,
# SigNoz, CloudWatch via the awsemf exporter, etc.).
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch, transform/strip_scope]
exporters: [debug]
apiVersion: apps/v1
kind: Deployment
metadata:
name: otel-collector
namespace: otel-system
spec:
replicas: 1
selector:
matchLabels:
app: otel-collector
template:
metadata:
labels:
app: otel-collector
spec:
containers:
- name: collector
image: otel/opentelemetry-collector-contrib:0.98.0
ports:
- containerPort: 4317
- containerPort: 4318
volumeMounts:
- name: config
mountPath: /etc/otelcol-contrib
volumes:
- name: config
configMap:
name: otel-collector-config
apiVersion: v1
kind: Service
metadata:
name: otel-collector
namespace: otel-system
spec:
selector:
app: otel-collector
ports:
- name: otlp-http
port: 4318
targetPort: 4318
- name: otlp-grpc
port: 4317
targetPort: 4317
EOF
Point the Agent Router egress proxy at the collector:
kubectl set env deploy/egress -n tars-dataplane \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.otel-system.svc.cluster.local:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
Generate traffic via the smoke tests, then verify metrics are flowing:
kubectl logs -n otel-system deployment/otel-collector --tail=50 | grep "Name:"
Expected metrics include router_requests_total, router_auth_attempts_total, and similar.
The kubectl set env patch is reset by tare install re-runs. Re-apply after every upgrade until the chart accepts these settings via Helm values.
Where to go next