Deploy Claude apps gateway on AWS changedclaude-apps-gateway-on-aws
Nearest release: v2.1.307, published 3 hours before upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.
Upstream edited this page at 21 Oct 2026 23:58 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 30 Oct 2026 18:07 UTC.
Upstream edited
Recorded here
Lines+140added
Lines−140removed
From line
321
where the diff opens
First seen
14 Aug 2026
this site's first read of the page
Recorded edits17to this page, all time
The whole hunk
from line 321, old and new numbered
/
from line 321
321321
322322**7. Deploy**
323323
324**ECS Fargate**
324> [tab] ECS Fargate
325>
326> Create the cluster and a log group for the gateway's stderr, which carries both its audit events and operational logs. Retention is a separate call, and without one CloudWatch keeps the logs forever; align the 90 days with your audit retention policy:
327>
328> ```bash theme={null}
329> aws ecs create-cluster --cluster-name claude-gateway
330> aws logs create-log-group --log-group-name /ecs/claude-gateway
331> aws logs put-retention-policy --log-group-name /ecs/claude-gateway \
332> --retention-in-days 90
333> ```
334>
335> Write the task definition. The task role carries the Bedrock permission and the execution role injects the secrets; use the secret ARNs from the Secrets Manager step:
336>
337> ```json claude-gateway-task.json theme={null}
338> {
339> "family": "claude-gateway",
340> "networkMode": "awsvpc",
341> "requiresCompatibilities": ["FARGATE"],
342> "cpu": "1024",
343> "memory": "2048",
344> "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
345> "executionRoleArn": "arn:aws:iam::<account-id>:role/claude-gateway-execution",
346> "taskRoleArn": "arn:aws:iam::<account-id>:role/claude-gateway-task",
347> "containerDefinitions": [
348> {
349> "name": "gateway",
350> "image": "<account-id>.dkr.ecr.<region>.amazonaws.com/claude-gateway:<version>",
351> "portMappings": [{ "containerPort": 8080 }],
352> "secrets": [
353> { "name": "GATEWAY_JWT_SECRET", "valueFrom": "<gateway-jwt-secret ARN>" },
354> { "name": "OIDC_CLIENT_SECRET", "valueFrom": "<gateway-oidc-client-secret ARN>" },
355> { "name": "GATEWAY_POSTGRES_URL", "valueFrom": "<gateway-postgres-url ARN>" }
356> ],
357> "logConfiguration": {
358> "logDriver": "awslogs",
359> "options": {
360> "awslogs-group": "/ecs/claude-gateway",
361> "awslogs-region": "<region>",
362> "awslogs-stream-prefix": "gateway"
363> }
364> }
365> }
366> ]
367> }
368> ```
369>
370> Register it:
371>
372> ```bash theme={null}
373> aws ecs register-task-definition --cli-input-json file://claude-gateway-task.json
374> ```
375>
376> Put an internal ALB in front with a target group that health-checks the gateway. `--ip-address-type ipv4` matters: an internal dual-stack ALB publishes public-range AAAA records, which the `/login` private-network check rejects:
377>
378> ```bash theme={null}
379> ALB_ARN="$(aws elbv2 create-load-balancer --name claude-gateway \
380> --scheme internal --type application --ip-address-type ipv4 \
381> --subnets $PRIVATE_SUBNETS --security-groups "$ALB_SG" \
382> --query 'LoadBalancers[0].LoadBalancerArn' --output text)"
383>
384> TG_ARN="$(aws elbv2 create-target-group --name claude-gateway \
385> --protocol HTTP --port 8080 --vpc-id "$VPC_ID" --target-type ip \
386> --health-check-path /readyz \
387> --query 'TargetGroups[0].TargetGroupArn' --output text)"
388> ```
389>
390> Add the HTTPS listener. `--ssl-policy` pins a modern TLS floor, since omitting it falls back to the legacy `ELBSecurityPolicy-2016-08` default, which still accepts TLS 1.0/1.1.
391>
392> The ALB closes a connection after 60 seconds with no data by default. The gateway's keepalive pings keep streams inside that default, so raising the timeout adds margin above the ping cadence; the [Troubleshooting](#troubleshooting) row on dropped streams covers the mechanism and older gateways. The commands below add the listener and raise the timeout:
393>
394> ```bash theme={null}
395> aws elbv2 create-listener --load-balancer-arn "$ALB_ARN" \
396> --protocol HTTPS --port 443 \
397> --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
398> --certificates CertificateArn=<your-acm-certificate-arn> \
399> --default-actions Type=forward,TargetGroupArn="$TG_ARN"
400>
401> aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \
402> --attributes Key=idle_timeout.timeout_seconds,Value=3600
403> ```
404>
405> Create the service. The deployment circuit breaker rolls a deployment whose tasks keep failing, from a bad image or an unbootable config, back to the last steady state instead of relaunching failing tasks forever:
406>
407> ```bash theme={null}
408> aws ecs create-service --cluster claude-gateway --service-name claude-gateway \
409> --task-definition claude-gateway --desired-count 1 --launch-type FARGATE \
410> --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
411> --health-check-grace-period-seconds 60 \
412> --network-configuration "awsvpcConfiguration={subnets=[$(echo $PRIVATE_SUBNETS | tr ' ' ',')],securityGroups=[$GW_SG],assignPublicIp=DISABLED}" \
413> --load-balancers "targetGroupArn=$TG_ARN,containerName=gateway,containerPort=8080"
414> ```
415>
416> The 60-second grace period gives a cold task time to pull the image, connect to the store, and answer its first health check before ECS starts counting failures against the deployment.
417>
418> The target group's health check on `GET /readyz` verifies the store is reachable, so a task that can't reach Postgres never enters rotation. To keep tasks passing the check through a short database outage such as an RDS failover, set `store.readiness_grace_seconds` as described in [Outage behavior](https://code.claude.com/docs/en/claude-apps-gateway-deploy.md#outage-behavior), which also covers the `/healthz` alternative.
419>
420> The tasks run in private subnets with no public IP, so all egress (to Bedrock, your IdP, Secrets Manager, ECR, and CloudWatch Logs) goes through the NAT gateway. To keep Bedrock traffic off the public path, create a `bedrock-runtime` interface VPC endpoint and point the upstream's `base_url` at it, as shown in the [Bedrock upstream reference](https://code.claude.com/docs/en/claude-apps-gateway-config.md#amazon-bedrock); the IdP still needs internet egress.
421>
422> Finish by giving developers a privately resolvable hostname: in a Route 53 private hosted zone, alias the gateway's internal DNS name to the ALB, and set `listen.public_url` to that hostname. The ALB's own `*.elb.amazonaws.com` name resolves to private addresses on an internal ALB, but it can't carry your ACM certificate, so use your own name.
423>
424> Update the OAuth client's authorized redirect URI to `<public_url>/oauth/callback` before the first sign-in. After changing `public_url`, rebuild and push the image under a new tag, register a new task definition revision, and redeploy. On ECS the setting lives in the image's embedded `gateway.yaml`, and the gateway builds its public origin only from that setting, ignoring `X-Forwarded-Host` and `X-Forwarded-Proto`. `X-Forwarded-For` is honored for client IPs only when `listen.trusted_proxies` is set.
325425
326Create the cluster and a log group for the gateway's stderr, which carries both its audit events and operational logs. Retention is a separate call, and without one CloudWatch keeps the logs forever; align the 90 days with your audit retention policy:
327
328```bash theme={null}
329aws ecs create-cluster --cluster-name claude-gateway
330aws logs create-log-group --log-group-name /ecs/claude-gateway
331aws logs put-retention-policy --log-group-name /ecs/claude-gateway \
332 --retention-in-days 90
333```
334
335Write the task definition. The task role carries the Bedrock permission and the execution role injects the secrets; use the secret ARNs from the Secrets Manager step:
336
337```json claude-gateway-task.json theme={null}
338{
339 "family": "claude-gateway",
340 "networkMode": "awsvpc",
341 "requiresCompatibilities": ["FARGATE"],
342 "cpu": "1024",
343 "memory": "2048",
344 "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
345 "executionRoleArn": "arn:aws:iam::<account-id>:role/claude-gateway-execution",
346 "taskRoleArn": "arn:aws:iam::<account-id>:role/claude-gateway-task",
347 "containerDefinitions": [
348 {
349 "name": "gateway",
350 "image": "<account-id>.dkr.ecr.<region>.amazonaws.com/claude-gateway:<version>",
351 "portMappings": [{ "containerPort": 8080 }],
352 "secrets": [
353 { "name": "GATEWAY_JWT_SECRET", "valueFrom": "<gateway-jwt-secret ARN>" },
354 { "name": "OIDC_CLIENT_SECRET", "valueFrom": "<gateway-oidc-client-secret ARN>" },
355 { "name": "GATEWAY_POSTGRES_URL", "valueFrom": "<gateway-postgres-url ARN>" }
356 ],
357 "logConfiguration": {
358 "logDriver": "awslogs",
359 "options": {
360 "awslogs-group": "/ecs/claude-gateway",
361 "awslogs-region": "<region>",
362 "awslogs-stream-prefix": "gateway"
363 }
364 }
365 }
366 ]
367}
368```
369
370Register it:
371
372```bash theme={null}
373aws ecs register-task-definition --cli-input-json file://claude-gateway-task.json
374```
375
376Put an internal ALB in front with a target group that health-checks the gateway. `--ip-address-type ipv4` matters: an internal dual-stack ALB publishes public-range AAAA records, which the `/login` private-network check rejects:
377
378```bash theme={null}
379ALB_ARN="$(aws elbv2 create-load-balancer --name claude-gateway \
380 --scheme internal --type application --ip-address-type ipv4 \
381 --subnets $PRIVATE_SUBNETS --security-groups "$ALB_SG" \
382 --query 'LoadBalancers[0].LoadBalancerArn' --output text)"
383
384TG_ARN="$(aws elbv2 create-target-group --name claude-gateway \
385 --protocol HTTP --port 8080 --vpc-id "$VPC_ID" --target-type ip \
386 --health-check-path /readyz \
387 --query 'TargetGroups[0].TargetGroupArn' --output text)"
388```
389
390Add the HTTPS listener. `--ssl-policy` pins a modern TLS floor, since omitting it falls back to the legacy `ELBSecurityPolicy-2016-08` default, which still accepts TLS 1.0/1.1.
391
392The ALB closes a connection after 60 seconds with no data by default. The gateway's keepalive pings keep streams inside that default, so raising the timeout adds margin above the ping cadence; the [Troubleshooting](#troubleshooting) row on dropped streams covers the mechanism and older gateways. The commands below add the listener and raise the timeout:
393
394```bash theme={null}
395aws elbv2 create-listener --load-balancer-arn "$ALB_ARN" \
396 --protocol HTTPS --port 443 \
397 --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
398 --certificates CertificateArn=<your-acm-certificate-arn> \
399 --default-actions Type=forward,TargetGroupArn="$TG_ARN"
400
401aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \
402 --attributes Key=idle_timeout.timeout_seconds,Value=3600
403```
404
405Create the service. The deployment circuit breaker rolls a deployment whose tasks keep failing, from a bad image or an unbootable config, back to the last steady state instead of relaunching failing tasks forever:
406
407```bash theme={null}
408aws ecs create-service --cluster claude-gateway --service-name claude-gateway \
409 --task-definition claude-gateway --desired-count 1 --launch-type FARGATE \
410 --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
411 --health-check-grace-period-seconds 60 \
412 --network-configuration "awsvpcConfiguration={subnets=[$(echo $PRIVATE_SUBNETS | tr ' ' ',')],securityGroups=[$GW_SG],assignPublicIp=DISABLED}" \
413 --load-balancers "targetGroupArn=$TG_ARN,containerName=gateway,containerPort=8080"
414```
415
416The 60-second grace period gives a cold task time to pull the image, connect to the store, and answer its first health check before ECS starts counting failures against the deployment.
417
418The target group's health check on `GET /readyz` verifies the store is reachable, so a task that can't reach Postgres never enters rotation. To keep tasks passing the check through a short database outage such as an RDS failover, set `store.readiness_grace_seconds` as described in [Outage behavior](https://code.claude.com/docs/en/claude-apps-gateway-deploy.md#outage-behavior), which also covers the `/healthz` alternative.
419
420The tasks run in private subnets with no public IP, so all egress (to Bedrock, your IdP, Secrets Manager, ECR, and CloudWatch Logs) goes through the NAT gateway. To keep Bedrock traffic off the public path, create a `bedrock-runtime` interface VPC endpoint and point the upstream's `base_url` at it, as shown in the [Bedrock upstream reference](https://code.claude.com/docs/en/claude-apps-gateway-config.md#amazon-bedrock); the IdP still needs internet egress.
421
422Finish by giving developers a privately resolvable hostname: in a Route 53 private hosted zone, alias the gateway's internal DNS name to the ALB, and set `listen.public_url` to that hostname. The ALB's own `*.elb.amazonaws.com` name resolves to private addresses on an internal ALB, but it can't carry your ACM certificate, so use your own name.
423
424Update the OAuth client's authorized redirect URI to `<public_url>/oauth/callback` before the first sign-in. After changing `public_url`, rebuild and push the image under a new tag, register a new task definition revision, and redeploy. On ECS the setting lives in the image's embedded `gateway.yaml`, and the gateway builds its public origin only from that setting, ignoring `X-Forwarded-Host` and `X-Forwarded-Proto`. `X-Forwarded-For` is honored for client IPs only when `listen.trusted_proxies` is set.
425
426**EKS**
427
428This track needs `kubectl` and `eksctl` installed locally, and an existing EKS cluster with an IAM OIDC provider and the AWS Load Balancer Controller installed. The cluster must be on `$VPC_ID` so pods can reach the RDS private endpoint, and the `claude-gateway-db` security group must admit the cluster's pod or node security group in place of `$GW_SG`.
429
430On EKS the gateway gets its Bedrock credentials through IRSA rather than the ECS roles. The `ecs-tasks.amazonaws.com` trust policy from the IAM step does not apply here; IRSA needs a role whose trust policy federates on the cluster's OIDC provider, scoped to `system:serviceaccount:claude-gateway:gateway`. `eksctl create iamserviceaccount` creates that role, attaches the policies, and annotates the Kubernetes service account with the role ARN in one step. Turn the two policy documents from the IAM step into managed policies it can attach:
431
432```bash theme={null}
433BEDROCK_POLICY_ARN="$(aws iam create-policy --policy-name claude-gateway-bedrock-invoke \
434 --policy-document file://bedrock-invoke.json --query Policy.Arn --output text)"
435SECRETS_POLICY_ARN="$(aws iam create-policy --policy-name claude-gateway-secrets-read \
436 --policy-document file://secrets-read.json --query Policy.Arn --output text)"
437
438kubectl create namespace claude-gateway
439eksctl create iamserviceaccount --cluster <your-cluster> --region "$AWS_REGION" \
440 --namespace claude-gateway --name gateway --role-name claude-gateway \
441 --attach-policy-arn "$BEDROCK_POLICY_ARN" \
442 --attach-policy-arn "$SECRETS_POLICY_ARN" \
443 --approve
444```
445
446The secrets policy is needed only when the pods read Secrets Manager themselves, as the Secrets Store CSI driver's AWS provider does using the mounting pod's service account; drop it if you create the Kubernetes Secrets another way. The provider needs both of the policy's actions: it calls `DescribeSecret` when it reconciles rotated secrets, so a `GetSecretValue`-only grant mounts on the first deploy but stops picking up rotations.
447
448Deploy the gateway as a standard Deployment plus a Service and an Ingress, as described in [Kubernetes deployment](https://code.claude.com/docs/en/claude-apps-gateway-deploy.md#kubernetes), with:
449
450* `serviceAccountName: gateway`
451* `gateway.yaml` mounted from a ConfigMap and the secrets mounted at `/secrets`
452* the readiness probe pointed at `GET /readyz`
453
454For the front end, an Ingress managed by the AWS Load Balancer Controller provisions the internal ALB. Annotate it with:
455
456* `alb.ingress.kubernetes.io/scheme: internal` and `alb.ingress.kubernetes.io/target-type: ip`
457* `alb.ingress.kubernetes.io/ip-address-type: ipv4`, so no public-range AAAA records are published for the `/login` [private-network check](https://code.claude.com/docs/en/claude-apps-gateway#prerequisites) to reject
458* `alb.ingress.kubernetes.io/inbound-cidrs: <your-corporate-cidr>`, so the controller-managed frontend security group admits only your corporate network in place of its `0.0.0.0/0` default
459* `alb.ingress.kubernetes.io/certificate-arn` with the ACM certificate
460* `alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06`, so the listener doesn't fall back to the legacy default policy that accepts TLS 1.0 and 1.1
461* `alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600`, a margin above the gateway's streaming keepalive; see [Troubleshooting](#troubleshooting)
462
463With IRSA, the AWS SDK reads a projected service-account token and exchanges it with AWS STS, so the pod never needs the EC2 instance metadata service; an egress NetworkPolicy may block `169.254.169.254` for gateway pods. The node hop-limit problem in [Troubleshooting](#troubleshooting) below applies only to clusters that skip IRSA and rely on node instance roles.
426> [tab] EKS
427>
428> This track needs `kubectl` and `eksctl` installed locally, and an existing EKS cluster with an IAM OIDC provider and the AWS Load Balancer Controller installed. The cluster must be on `$VPC_ID` so pods can reach the RDS private endpoint, and the `claude-gateway-db` security group must admit the cluster's pod or node security group in place of `$GW_SG`.
429>
430> On EKS the gateway gets its Bedrock credentials through IRSA rather than the ECS roles. The `ecs-tasks.amazonaws.com` trust policy from the IAM step does not apply here; IRSA needs a role whose trust policy federates on the cluster's OIDC provider, scoped to `system:serviceaccount:claude-gateway:gateway`. `eksctl create iamserviceaccount` creates that role, attaches the policies, and annotates the Kubernetes service account with the role ARN in one step. Turn the two policy documents from the IAM step into managed policies it can attach:
431>
432> ```bash theme={null}
433> BEDROCK_POLICY_ARN="$(aws iam create-policy --policy-name claude-gateway-bedrock-invoke \
434> --policy-document file://bedrock-invoke.json --query Policy.Arn --output text)"
435> SECRETS_POLICY_ARN="$(aws iam create-policy --policy-name claude-gateway-secrets-read \
436> --policy-document file://secrets-read.json --query Policy.Arn --output text)"
437>
438> kubectl create namespace claude-gateway
439> eksctl create iamserviceaccount --cluster <your-cluster> --region "$AWS_REGION" \
440> --namespace claude-gateway --name gateway --role-name claude-gateway \
441> --attach-policy-arn "$BEDROCK_POLICY_ARN" \
442> --attach-policy-arn "$SECRETS_POLICY_ARN" \
443> --approve
444> ```
445>
446> The secrets policy is needed only when the pods read Secrets Manager themselves, as the Secrets Store CSI driver's AWS provider does using the mounting pod's service account; drop it if you create the Kubernetes Secrets another way. The provider needs both of the policy's actions: it calls `DescribeSecret` when it reconciles rotated secrets, so a `GetSecretValue`-only grant mounts on the first deploy but stops picking up rotations.
447>
448> Deploy the gateway as a standard Deployment plus a Service and an Ingress, as described in [Kubernetes deployment](https://code.claude.com/docs/en/claude-apps-gateway-deploy.md#kubernetes), with:
449>
450> * `serviceAccountName: gateway`
451> * `gateway.yaml` mounted from a ConfigMap and the secrets mounted at `/secrets`
452> * the readiness probe pointed at `GET /readyz`
453>
454> For the front end, an Ingress managed by the AWS Load Balancer Controller provisions the internal ALB. Annotate it with:
455>
456> * `alb.ingress.kubernetes.io/scheme: internal` and `alb.ingress.kubernetes.io/target-type: ip`
457> * `alb.ingress.kubernetes.io/ip-address-type: ipv4`, so no public-range AAAA records are published for the `/login` [private-network check](https://code.claude.com/docs/en/claude-apps-gateway#prerequisites) to reject
458> * `alb.ingress.kubernetes.io/inbound-cidrs: <your-corporate-cidr>`, so the controller-managed frontend security group admits only your corporate network in place of its `0.0.0.0/0` default
459> * `alb.ingress.kubernetes.io/certificate-arn` with the ACM certificate
460> * `alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06`, so the listener doesn't fall back to the legacy default policy that accepts TLS 1.0 and 1.1
461> * `alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600`, a margin above the gateway's streaming keepalive; see [Troubleshooting](#troubleshooting)
462>
463> With IRSA, the AWS SDK reads a projected service-account token and exchanges it with AWS STS, so the pod never needs the EC2 instance metadata service; an egress NetworkPolicy may block `169.254.169.254` for gateway pods. The node hop-limit problem in [Troubleshooting](#troubleshooting) below applies only to clusters that skip IRSA and rely on node instance roles.
464464
465465**8. Push the gateway URL to developer machines**
466466
No line in this hunk matches that.