Follow Discord
Sweep 22 Oct 2026 · 04:32Z Build v2.1.308 527 read Stable v2.1.308 Latest v2.1.308 Next v2.1.308 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · claude-code

Deploy Claude apps gateway on AWS changedclaude-apps-gateway-on-aws

Nearest release: v2.1.296, published 5 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 9 Oct 2026 22:32 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 20 Oct 2026 18:37 UTC.

Upstream edited
Recorded here
Lines+407added
Lines−413removed
From line 2 where the diff opens
First seen 14 Aug 2026 this site's first read of the page
Recorded edits13to this page, all time

The whole hunk

from line 2, old and new numbered
/
lines

The two sides of this change are more than 400 edits apart, too far apart to line up, so this is the differ's own diff of it and the words inside a line are not marked.

from line 2
22 
33> A worked example of running Claude apps gateway on AWS: ECS Fargate or EKS, Amazon RDS for PostgreSQL, AWS Secrets Manager, and IAM-role auth to Amazon Bedrock.
44 
5<Note>
6 This page walks through one way to run Claude apps gateway on AWS. The configuration is a working example for customer-managed infrastructure rather than a supported production deployment; use it to see how the pieces fit together before adapting it to your own environment. For the platform-agnostic requirements, see the [deployment guide](/docs/en/claude-apps-gateway-deploy).
7</Note>
5> [!NOTE]
6>
7> This page walks through one way to run Claude apps gateway on AWS. The configuration is a working example for customer-managed infrastructure rather than a supported production deployment; use it to see how the pieces fit together before adapting it to your own environment. For the platform-agnostic requirements, see the [deployment guide](/docs/en/claude-apps-gateway-deploy).
88 
99This example provisions Claude apps gateway on AWS with Amazon Bedrock as the model upstream, using either [Amazon ECS](https://aws.amazon.com/ecs/) on [AWS Fargate](https://aws.amazon.com/fargate/) or [Amazon EKS](https://aws.amazon.com/eks/) for compute. [Okta](https://www.okta.com/) is the example identity provider (IdP), but any OpenID Connect (OIDC) compliant IdP works; see [Identity provider setup](/docs/en/claude-apps-gateway-deploy#identity-provider-setup) for per-IdP details.
1010 
11<Note>
12 Bedrock isn't the only Claude upstream on AWS. The gateway also supports Claude Platform on AWS, the Anthropic-operated Claude API with AWS authentication and AWS Marketplace billing, in place of Bedrock or alongside it. Its upstream entry, credentials, and IAM permissions differ from this page's Bedrock-scoped ones; the [Claude Platform on AWS upstream reference](/docs/en/claude-apps-gateway-config#claude-platform-on-aws) covers what changes, and the rest of this page applies unchanged.
13</Note>
11> [!NOTE]
12>
13> Bedrock isn't the only Claude upstream on AWS. The gateway also supports Claude Platform on AWS, the Anthropic-operated Claude API with AWS authentication and AWS Marketplace billing, in place of Bedrock or alongside it. Its upstream entry, credentials, and IAM permissions differ from this page's Bedrock-scoped ones; the [Claude Platform on AWS upstream reference](/docs/en/claude-apps-gateway-config#claude-platform-on-aws) covers what changes, and the rest of this page applies unchanged.
1414 
1515## Architecture
1616 
17<Frame caption="The example architecture, with Amazon Bedrock as the model upstream. A Claude Platform on AWS upstream occupies the same position.">
18 <img src="https://mintcdn.com/claude-code/PHweeRmDUYEKff49/images/claude-gateway-aws-architecture.svg?fit=max&auto=format&n=PHweeRmDUYEKff49&q=85&s=8599cc34aa28522cde208ee831439bb4" alt="Diagram of Claude apps gateway on AWS: Claude Code clients connect over HTTPS to an internal Application Load Balancer fronting the gateway (ECS Fargate or EKS), which runs in private subnets alongside an Amazon RDS for PostgreSQL instance for session state. The gateway signs users in via OIDC against the corporate IdP, reads secrets from AWS Secrets Manager, forwards model requests to Amazon Bedrock using its IAM role, and pulls its image from Amazon ECR at deploy." width="820" height="430" data-path="images/claude-gateway-aws-architecture.svg" />
19</Frame>
17![Diagram of Claude apps gateway on AWS: Claude Code clients connect over HTTPS to an internal Application Load Balancer fronting the gateway (ECS Fargate or EKS), which runs in private subnets alongside an Amazon RDS for PostgreSQL instance for session state. The gateway signs users in via OIDC against the corporate IdP, reads secrets from AWS Secrets Manager, forwards model requests to Amazon Bedrock using its IAM role, and pulls its image from Amazon ECR at deploy.](https://mintcdn.com/claude-code/PHweeRmDUYEKff49/images/claude-gateway-aws-architecture.svg?fit=max\&auto=format\&n=PHweeRmDUYEKff49\&q=85\&s=8599cc34aa28522cde208ee831439bb4)
2018 
2119The gateway runs as a private HTTPS endpoint on your network that developers sign in to through your IdP. Their Claude Code sessions reach Claude models on Amazon Bedrock through the gateway's IAM role, so no model credentials land on developer machines. The reference configuration provisions:
2220 
from line 61
6361 
6462The steps below provision the full deployment with `aws` commands.
6563 
66<Steps>
67 <Step title="Create the security groups">
68 Three security groups chain the traffic path: your corporate network reaches the load balancer on 443, the load balancer reaches the gateway on 8080, and the gateway reaches Postgres on 5432. Nothing else is reachable. How you attach them depends on the compute track:
69 
70 * On ECS Fargate, the deploy step attaches `$ALB_SG` to the load balancer and `$GW_SG` to the service.
71 * On EKS, the AWS Load Balancer Controller creates its own frontend security group for the ALB, so `$ALB_SG` and `$GW_SG` go unused: the deploy step's `inbound-cidrs` annotation restricts the listener to your corporate network, and the database security group admits the cluster's security group instead.
72 
73 ```bash theme={null}
74 ALB_SG="$(aws ec2 create-security-group --group-name claude-gateway-alb \
75 --description "Claude gateway ALB" --vpc-id "$VPC_ID" \
76 --query GroupId --output text)"
77 GW_SG="$(aws ec2 create-security-group --group-name claude-gateway-svc \
78 --description "Claude gateway service" --vpc-id "$VPC_ID" \
79 --query GroupId --output text)"
80 DB_SG="$(aws ec2 create-security-group --group-name claude-gateway-db \
81 --description "Claude gateway Postgres" --vpc-id "$VPC_ID" \
82 --query GroupId --output text)"
83 
84 aws ec2 authorize-security-group-ingress --group-id "$ALB_SG" \
85 --protocol tcp --port 443 --cidr <your-corporate-cidr>
86 aws ec2 authorize-security-group-ingress --group-id "$GW_SG" \
87 --protocol tcp --port 8080 --source-group "$ALB_SG"
88 aws ec2 authorize-security-group-ingress --group-id "$DB_SG" \
89 --protocol tcp --port 5432 --source-group "$GW_SG"
90 ```
91 </Step>
92 
93 <Step title="Create the IAM roles and submit the use case form">
94 The gateway runs with a dedicated task role whose only permission is invoking Claude models on Bedrock. Per the [Bedrock upstream reference](/docs/en/claude-apps-gateway-config#amazon-bedrock), the policy must cover both the cross-region inference-profile ARNs and the underlying foundation-model ARNs:
95 
96 ```bash theme={null}
97 cat > bedrock-invoke.json <<EOF
64**1. Create the security groups**
65 
66Three security groups chain the traffic path: your corporate network reaches the load balancer on 443, the load balancer reaches the gateway on 8080, and the gateway reaches Postgres on 5432. Nothing else is reachable. How you attach them depends on the compute track:
67 
68* On ECS Fargate, the deploy step attaches `$ALB_SG` to the load balancer and `$GW_SG` to the service.
69* On EKS, the AWS Load Balancer Controller creates its own frontend security group for the ALB, so `$ALB_SG` and `$GW_SG` go unused: the deploy step's `inbound-cidrs` annotation restricts the listener to your corporate network, and the database security group admits the cluster's security group instead.
70 
71```bash theme={null}
72ALB_SG="$(aws ec2 create-security-group --group-name claude-gateway-alb \
73 --description "Claude gateway ALB" --vpc-id "$VPC_ID" \
74 --query GroupId --output text)"
75GW_SG="$(aws ec2 create-security-group --group-name claude-gateway-svc \
76 --description "Claude gateway service" --vpc-id "$VPC_ID" \
77 --query GroupId --output text)"
78DB_SG="$(aws ec2 create-security-group --group-name claude-gateway-db \
79 --description "Claude gateway Postgres" --vpc-id "$VPC_ID" \
80 --query GroupId --output text)"
81 
82aws ec2 authorize-security-group-ingress --group-id "$ALB_SG" \
83 --protocol tcp --port 443 --cidr <your-corporate-cidr>
84aws ec2 authorize-security-group-ingress --group-id "$GW_SG" \
85 --protocol tcp --port 8080 --source-group "$ALB_SG"
86aws ec2 authorize-security-group-ingress --group-id "$DB_SG" \
87 --protocol tcp --port 5432 --source-group "$GW_SG"
88```
89 
90**2. Create the IAM roles and submit the use case form**
91 
92The gateway runs with a dedicated task role whose only permission is invoking Claude models on Bedrock. Per the [Bedrock upstream reference](/docs/en/claude-apps-gateway-config#amazon-bedrock), the policy must cover both the cross-region inference-profile ARNs and the underlying foundation-model ARNs:
93 
94```bash theme={null}
95cat > bedrock-invoke.json <<EOF
96{
97 "Version": "2012-10-17",
98 "Statement": [{
99 "Effect": "Allow",
100 "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:CountTokens"],
101 "Resource": [
102 "arn:aws:bedrock:${AWS_REGION}:${ACCOUNT_ID}:inference-profile/us.anthropic.*",
103 "arn:aws:bedrock:*::foundation-model/anthropic.*"
104 ]
105 }]
106}
107EOF
108cat > ecs-trust.json <<'EOF'
109{
110 "Version": "2012-10-17",
111 "Statement": [{
112 "Effect": "Allow",
113 "Principal": { "Service": "ecs-tasks.amazonaws.com" },
114 "Action": "sts:AssumeRole"
115 }]
116}
117EOF
118 
119aws iam create-role --role-name claude-gateway-task \
120 --assume-role-policy-document file://ecs-trust.json
121aws iam put-role-policy --role-name claude-gateway-task \
122 --policy-name bedrock-invoke --policy-document file://bedrock-invoke.json
123```
124 
125ECS also needs an execution role, which the ECS agent itself uses to pull the image from ECR and inject the Secrets Manager values created later. It is separate from the task role the gateway's AWS SDK uses at runtime:
126 
127```bash theme={null}
128aws iam create-role --role-name claude-gateway-execution \
129 --assume-role-policy-document file://ecs-trust.json
130aws iam attach-role-policy --role-name claude-gateway-execution \
131 --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
132cat > secrets-read.json <<EOF
133{
134 "Version": "2012-10-17",
135 "Statement": [{
136 "Effect": "Allow",
137 "Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
138 "Resource": [
139 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:gateway-jwt-secret-??????",
140 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:gateway-oidc-client-secret-??????",
141 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:gateway-postgres-url-??????"
142 ]
143 }]
144}
145EOF
146aws iam put-role-policy --role-name claude-gateway-execution \
147 --policy-name read-gateway-secrets --policy-document file://secrets-read.json
148```
149 
150The policy names one ARN per secret rather than a bare `gateway-*` wildcard, which in a shared account would also match unrelated secrets; the trailing `-??????` matches exactly the random six-character suffix Secrets Manager appends to every secret's ARN. A trailing `-*` would be a plain prefix glob and would also match longer names such as `gateway-postgres-url-prod`.
151 
152The IAM policy grants the gateway permission to call Bedrock, and Bedrock enables model access by default in commercial regions. The remaining account-level gate is Anthropic's one-time use case form: if no one in your account has submitted it, open the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/), select an Anthropic model from the Model catalog, and complete the form. Access is granted immediately after submission; see [Claude Code on Amazon Bedrock](/docs/en/amazon-bedrock#1-submit-use-case-details) for the AWS Organizations form and the IAM permissions the submitter needs.
153 
154The EKS track reuses both policy documents on an IRSA role instead of the two ECS roles; see the deploy step.
155 
156**3. Provision Amazon RDS for PostgreSQL**
157 
158The instance runs Postgres 16 in the private subnets, with no public address and storage encryption on.
159 
160First, create the subnet group that places the database in the private subnets, and a parameter group with `rds.force_ssl=1` so the server rejects plaintext connections. The engine version is pinned once because the parameter group's family must match the engine major version the instance runs:
161 
162```bash theme={null}
163aws rds create-db-subnet-group --db-subnet-group-name claude-gateway-db \
164 --db-subnet-group-description "Claude gateway" --subnet-ids $PRIVATE_SUBNETS
165 
166PG_VERSION=16
167PG_FAMILY="postgres${PG_VERSION}"
168aws rds create-db-parameter-group --db-parameter-group-name claude-gateway-db \
169 --db-parameter-group-family "$PG_FAMILY" \
170 --description "Claude gateway - require TLS on every connection"
171aws rds modify-db-parameter-group --db-parameter-group-name claude-gateway-db \
172 --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=immediate"
173```
174 
175Then create the instance with a generated master password:
176 
177```bash theme={null}
178PGPASS="$(openssl rand -hex 24)"
179aws rds create-db-instance --db-instance-identifier claude-gateway-db \
180 --engine postgres --engine-version "$PG_VERSION" \
181 --db-instance-class db.t4g.micro \
182 --allocated-storage 20 --db-name claude_gateway \
183 --master-username gateway --master-user-password "$PGPASS" \
184 --db-subnet-group-name claude-gateway-db \
185 --db-parameter-group-name claude-gateway-db \
186 --vpc-security-group-ids "$DB_SG" \
187 --no-publicly-accessible --storage-encrypted
188```
189 
190The literal `--master-user-password` argument is visible in the process table and in audit/EDR logs while the command runs, the same exposure the secrets step's note covers. On a shared or monitored host, pass the password via `--cli-input-json` from a `0600` file instead, the way the bundle's `setup.sh` does.
191 
192Wait for the instance to come up, which can take several minutes, then read its private endpoint and assemble the connection string the gateway will use:
193 
194```bash theme={null}
195aws rds wait db-instance-available --db-instance-identifier claude-gateway-db
196DB_HOST="$(aws rds describe-db-instances --db-instance-identifier claude-gateway-db \
197 --query 'DBInstances[0].Endpoint.Address' --output text)"
198GATEWAY_POSTGRES_URL="postgres://gateway:${PGPASS}@${DB_HOST}:5432/claude_gateway?sslmode=verify-full"
199```
200 
201`sslmode=verify-full` makes the gateway verify the RDS server certificate's chain and hostname, not only encrypt. The trust anchor is the [AWS RDS certificate bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem), which the image build step below copies to `/etc/claude/rds-global-bundle.pem` and trusts via `NODE_EXTRA_CA_CERTS`. Don't append a libpq-style `sslrootcert=` parameter to the URL: the gateway's driver reads only `sslmode` from the query string and would forward `sslrootcert` to Postgres as a startup parameter, which the server rejects.
202 
203The ECS service or EKS pods must run in this VPC so they can reach the instance's private endpoint, and the `claude-gateway-db` security group only admits the gateway's security group.
204 
205**4. Write gateway.yaml**
206 
207The `upstreams` block points at Bedrock with `auth: {}`, so the gateway authenticates via the AWS default credential chain from the task role on ECS or the IRSA role on EKS. See the [configuration reference](/docs/en/claude-apps-gateway-config) for every field.
208 
209Two `listen` fields describe what fronts the gateway:
210 
211* `public_url`: the external `https://` origin, required for any non-loopback bind; see the [`listen` reference](/docs/en/claude-apps-gateway-config#listen). The gateway builds the IdP `redirect_uri` and its discovery document only from this value, never from `X-Forwarded-*` headers.
212* `trusted_proxies`: the front end's source ranges. The gateway honors `X-Forwarded-For` only when the TCP peer is in this list, then walks the chain past trusted hops, so per-IP sign-in rate limits and audit events record developer IPs instead of the load balancer's.
213 
214On both tracks the front end is an internal ALB, whether created directly or by the AWS Load Balancer Controller, and an ALB's nodes take addresses from the subnets it is attached to, so set `trusted_proxies` to those subnets' CIDRs. This trusts every host in those subnets as a proxy. Keep the ALB's ingress source, your corporate CIDR, from overlapping them, and don't share the subnets with untrusted workloads that could spoof client IPs via `X-Forwarded-For`.
215 
216The ALB's client port preservation attribute, `routing.http.xff_client_port.enabled`, can stay at either setting: with it on, the ALB writes the client as `203.0.113.7:54321` or `[2001:db8::1]:54321`, and the gateway reads both with the port dropped.
217 
218```yaml gateway.yaml theme={null}
219listen:
220 host: 0.0.0.0
221 port: 8080
222 public_url: https://claude-gateway.internal.example.com
223 trusted_proxies: [<your-alb-subnet-cidrs>]
224 
225oidc:
226 issuer: https://example.okta.com
227 client_id: 0oa1example2
228 client_secret: ${OIDC_CLIENT_SECRET} # EKS: ${file:/secrets/oidc-client-secret}
229 allowed_email_domains: [example.com]
230 # The Okta org authorization server returns a thin id_token that omits
231 # email and groups; the gateway fills them from /userinfo.
232 userinfo_fallback: true
233 # Okta emits groups only when the `groups` scope is requested and the
234 # app's groups claim filter allows them.
235 scopes: [openid, profile, email, offline_access, groups]
236 
237session:
238 jwt_secret: ${GATEWAY_JWT_SECRET} # EKS: ${file:/secrets/jwt-secret}
239 ttl_hours: 8 # bounds deprovision latency; lower
240 # toward 1 for tighter revocation
241 
242store:
243 postgres_url: ${GATEWAY_POSTGRES_URL} # EKS: ${file:/secrets/postgres-url}
244 # readiness_grace_seconds: 300 # keep passing the health check
245 # through an RDS failover
246 
247upstreams:
248 - provider: bedrock
249 region: <your-region> # match $AWS_REGION so the IAM
250 # policy's ARNs cover it
251 auth: {} # AWS default credential chain:
252 # ECS task role, or IRSA on EKS
253```
254 
255> [!NOTE]
256>
257> Only the `oidc` block is Okta-specific. To use Microsoft Entra ID instead, set `issuer` to `https://login.microsoftonline.com/<tenant-id>/v2.0`, drop `userinfo_fallback` and the `groups` scope, and note that Entra emits group Object IDs rather than names, so [`managed.policies`](/docs/en/claude-apps-gateway-config#managed) must match on the GUIDs, or on App Roles with `oidc.groups_claim: roles`. See [Identity provider setup](/docs/en/claude-apps-gateway-deploy#identity-provider-setup).
258 
259**5. Store secrets in AWS Secrets Manager**
260 
261Create three secrets; the execution role from the IAM step can already read them:
262 
263```bash theme={null}
264aws secretsmanager create-secret --name gateway-jwt-secret \
265 --secret-string "$(openssl rand -base64 32)"
266aws secretsmanager create-secret --name gateway-oidc-client-secret \
267 --secret-string '<your-okta-client-secret>'
268aws secretsmanager create-secret --name gateway-postgres-url \
269 --secret-string "$GATEWAY_POSTGRES_URL"
270```
271 
272Note the ARN each call prints; the ECS task definition references secrets by ARN.
273 
274> [!NOTE]
275>
276> Literal `--secret-string` arguments are visible in the process table and in audit/EDR logs while each command runs. On a shared or monitored host, put the value in a `0600` file and pass `--secret-string file://<path>` instead. The bundle's `setup.sh` keeps secret values off process argv the same way, passing `0600` temporary files to `--cli-input-json`.
277 
278Unlike the secrets, `gateway.yaml` itself contains no secret values, because every credential resolves at boot through [`${VAR}` or `${file:...}` expansion](/docs/en/claude-apps-gateway-config#secret-expansion). How everything reaches the container differs by track:
279 
280* On ECS, the next step's build copies `gateway.yaml` into the image at `/etc/claude/gateway.yaml`, and the task definition injects the three secrets as environment variables via its `secrets` field, so the YAML references `${GATEWAY_JWT_SECRET}`, `${OIDC_CLIENT_SECRET}`, and `${GATEWAY_POSTGRES_URL}`.
281* On EKS, mount `gateway.yaml` from a ConfigMap and the secrets as files at `/secrets`, referenced as `${file:/secrets/...}`. Source the Kubernetes Secrets from Secrets Manager with External Secrets Operator or the Secrets Store CSI driver's AWS provider, or create them directly with `kubectl`.
282 
283**6. Build and push the image to Amazon ECR**
284 
285Build the image per the [container image requirements](/docs/en/claude-apps-gateway-deploy#container-image), placing the `linux-x64` glibc binary at `./claude` in the build context. Write your own Dockerfile per those requirements or start from the bundle's [`Dockerfile`](https://github.com/anthropics/claude-code/blob/main/examples/gateway/aws/Dockerfile), which copies the filled-in `gateway.yaml` from the previous steps into the image at `/etc/claude/gateway.yaml`. On ECS that embedded copy is how the configuration reaches the container, which is why the build comes after the file is written. The EKS track instead mounts `gateway.yaml` from a ConfigMap at deploy, so the embedded copy is unused there.
286 
287The image also carries the AWS RDS certificate bundle as the trust anchor for the connection string's `sslmode=verify-full`, so download it into the build context first. AWS rotates the bundle (new regional CAs get appended), so download it per build rather than pinning a checksum or committing it:
288 
289```bash theme={null}
290curl -fL --proto '=https' -o rds-global-bundle.pem \
291 https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
292```
293 
294The container image requirements don't cover the bundle, so if you write your own Dockerfile, add the two lines that copy and trust it; the bundle's `Dockerfile` already includes both:
295 
296```dockerfile theme={null}
297COPY rds-global-bundle.pem /etc/claude/rds-global-bundle.pem
298ENV NODE_EXTRA_CA_CERTS=/etc/claude/rds-global-bundle.pem
299```
300 
301Create the ECR repository and sign Docker in to it. Immutable tags mean the `<version>` tag the deploy step pins cannot later be silently re-pointed at a different image:
302 
303```bash theme={null}
304aws ecr create-repository --repository-name claude-gateway \
305 --image-tag-mutability IMMUTABLE \
306 --image-scanning-configuration scanOnPush=true
307aws ecr get-login-password --region "$AWS_REGION" \
308 | docker login --username AWS --password-stdin \
309 "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
310```
311 
312Build and push the image. The task definition below runs `linux/amd64`, so the platform must match here; for Fargate on ARM64 (Graviton), build `linux/arm64` with the `linux-arm64` binary and set `cpuArchitecture` to `ARM64` instead:
313 
314```bash theme={null}
315docker build --platform=linux/amd64 \
316 -t "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/claude-gateway:<version>" .
317docker push "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/claude-gateway:<version>"
318```
319 
320**7. Deploy**
321 
322**ECS Fargate**
323 
324Create 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:
325 
326```bash theme={null}
327aws ecs create-cluster --cluster-name claude-gateway
328aws logs create-log-group --log-group-name /ecs/claude-gateway
329aws logs put-retention-policy --log-group-name /ecs/claude-gateway \
330 --retention-in-days 90
331```
332 
333Write 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:
334 
335```json claude-gateway-task.json theme={null}
336{
337 "family": "claude-gateway",
338 "networkMode": "awsvpc",
339 "requiresCompatibilities": ["FARGATE"],
340 "cpu": "1024",
341 "memory": "2048",
342 "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
343 "executionRoleArn": "arn:aws:iam::<account-id>:role/claude-gateway-execution",
344 "taskRoleArn": "arn:aws:iam::<account-id>:role/claude-gateway-task",
345 "containerDefinitions": [
98346 {
99 "Version": "2012-10-17",
100 "Statement": [{
101 "Effect": "Allow",
102 "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:CountTokens"],
103 "Resource": [
104 "arn:aws:bedrock:${AWS_REGION}:${ACCOUNT_ID}:inference-profile/us.anthropic.*",
105 "arn:aws:bedrock:*::foundation-model/anthropic.*"
106 ]
107 }]
347 "name": "gateway",
348 "image": "<account-id>.dkr.ecr.<region>.amazonaws.com/claude-gateway:<version>",
349 "portMappings": [{ "containerPort": 8080 }],
350 "secrets": [
351 { "name": "GATEWAY_JWT_SECRET", "valueFrom": "<gateway-jwt-secret ARN>" },
352 { "name": "OIDC_CLIENT_SECRET", "valueFrom": "<gateway-oidc-client-secret ARN>" },
353 { "name": "GATEWAY_POSTGRES_URL", "valueFrom": "<gateway-postgres-url ARN>" }
354 ],
355 "logConfiguration": {
356 "logDriver": "awslogs",
357 "options": {
358 "awslogs-group": "/ecs/claude-gateway",
359 "awslogs-region": "<region>",
360 "awslogs-stream-prefix": "gateway"
361 }
362 }
108363 }
109 EOF
110 cat > ecs-trust.json <<'EOF'
111 {
112 "Version": "2012-10-17",
113 "Statement": [{
114 "Effect": "Allow",
115 "Principal": { "Service": "ecs-tasks.amazonaws.com" },
116 "Action": "sts:AssumeRole"
117 }]
118 }
119 EOF
120 
121 aws iam create-role --role-name claude-gateway-task \
122 --assume-role-policy-document file://ecs-trust.json
123 aws iam put-role-policy --role-name claude-gateway-task \
124 --policy-name bedrock-invoke --policy-document file://bedrock-invoke.json
125 ```
126 
127 ECS also needs an execution role, which the ECS agent itself uses to pull the image from ECR and inject the Secrets Manager values created later. It is separate from the task role the gateway's AWS SDK uses at runtime:
128 
129 ```bash theme={null}
130 aws iam create-role --role-name claude-gateway-execution \
131 --assume-role-policy-document file://ecs-trust.json
132 aws iam attach-role-policy --role-name claude-gateway-execution \
133 --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
134 cat > secrets-read.json <<EOF
135 {
136 "Version": "2012-10-17",
137 "Statement": [{
138 "Effect": "Allow",
139 "Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
140 "Resource": [
141 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:gateway-jwt-secret-??????",
142 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:gateway-oidc-client-secret-??????",
143 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:gateway-postgres-url-??????"
144 ]
145 }]
146 }
147 EOF
148 aws iam put-role-policy --role-name claude-gateway-execution \
149 --policy-name read-gateway-secrets --policy-document file://secrets-read.json
150 ```
151 
152 The policy names one ARN per secret rather than a bare `gateway-*` wildcard, which in a shared account would also match unrelated secrets; the trailing `-??????` matches exactly the random six-character suffix Secrets Manager appends to every secret's ARN. A trailing `-*` would be a plain prefix glob and would also match longer names such as `gateway-postgres-url-prod`.
153 
154 The IAM policy grants the gateway permission to call Bedrock, and Bedrock enables model access by default in commercial regions. The remaining account-level gate is Anthropic's one-time use case form: if no one in your account has submitted it, open the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/), select an Anthropic model from the Model catalog, and complete the form. Access is granted immediately after submission; see [Claude Code on Amazon Bedrock](/docs/en/amazon-bedrock#1-submit-use-case-details) for the AWS Organizations form and the IAM permissions the submitter needs.
155 
156 The EKS track reuses both policy documents on an IRSA role instead of the two ECS roles; see the deploy step.
157 </Step>
158 
159 <Step title="Provision Amazon RDS for PostgreSQL">
160 The instance runs Postgres 16 in the private subnets, with no public address and storage encryption on.
161 
162 First, create the subnet group that places the database in the private subnets, and a parameter group with `rds.force_ssl=1` so the server rejects plaintext connections. The engine version is pinned once because the parameter group's family must match the engine major version the instance runs:
163 
164 ```bash theme={null}
165 aws rds create-db-subnet-group --db-subnet-group-name claude-gateway-db \
166 --db-subnet-group-description "Claude gateway" --subnet-ids $PRIVATE_SUBNETS
167 
168 PG_VERSION=16
169 PG_FAMILY="postgres${PG_VERSION}"
170 aws rds create-db-parameter-group --db-parameter-group-name claude-gateway-db \
171 --db-parameter-group-family "$PG_FAMILY" \
172 --description "Claude gateway - require TLS on every connection"
173 aws rds modify-db-parameter-group --db-parameter-group-name claude-gateway-db \
174 --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=immediate"
175 ```
176 
177 Then create the instance with a generated master password:
178 
179 ```bash theme={null}
180 PGPASS="$(openssl rand -hex 24)"
181 aws rds create-db-instance --db-instance-identifier claude-gateway-db \
182 --engine postgres --engine-version "$PG_VERSION" \
183 --db-instance-class db.t4g.micro \
184 --allocated-storage 20 --db-name claude_gateway \
185 --master-username gateway --master-user-password "$PGPASS" \
186 --db-subnet-group-name claude-gateway-db \
187 --db-parameter-group-name claude-gateway-db \
188 --vpc-security-group-ids "$DB_SG" \
189 --no-publicly-accessible --storage-encrypted
190 ```
191 
192 The literal `--master-user-password` argument is visible in the process table and in audit/EDR logs while the command runs, the same exposure the secrets step's note covers. On a shared or monitored host, pass the password via `--cli-input-json` from a `0600` file instead, the way the bundle's `setup.sh` does.
193 
194 Wait for the instance to come up, which can take several minutes, then read its private endpoint and assemble the connection string the gateway will use:
195 
196 ```bash theme={null}
197 aws rds wait db-instance-available --db-instance-identifier claude-gateway-db
198 DB_HOST="$(aws rds describe-db-instances --db-instance-identifier claude-gateway-db \
199 --query 'DBInstances[0].Endpoint.Address' --output text)"
200 GATEWAY_POSTGRES_URL="postgres://gateway:${PGPASS}@${DB_HOST}:5432/claude_gateway?sslmode=verify-full"
201 ```
202 
203 `sslmode=verify-full` makes the gateway verify the RDS server certificate's chain and hostname, not only encrypt. The trust anchor is the [AWS RDS certificate bundle](https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem), which the image build step below copies to `/etc/claude/rds-global-bundle.pem` and trusts via `NODE_EXTRA_CA_CERTS`. Don't append a libpq-style `sslrootcert=` parameter to the URL: the gateway's driver reads only `sslmode` from the query string and would forward `sslrootcert` to Postgres as a startup parameter, which the server rejects.
204 
205 The ECS service or EKS pods must run in this VPC so they can reach the instance's private endpoint, and the `claude-gateway-db` security group only admits the gateway's security group.
206 </Step>
207 
208 <Step title="Write gateway.yaml">
209 The `upstreams` block points at Bedrock with `auth: {}`, so the gateway authenticates via the AWS default credential chain from the task role on ECS or the IRSA role on EKS. See the [configuration reference](/docs/en/claude-apps-gateway-config) for every field.
210 
211 Two `listen` fields describe what fronts the gateway:
212 
213 * `public_url`: the external `https://` origin, required for any non-loopback bind; see the [`listen` reference](/docs/en/claude-apps-gateway-config#listen). The gateway builds the IdP `redirect_uri` and its discovery document only from this value, never from `X-Forwarded-*` headers.
214 * `trusted_proxies`: the front end's source ranges. The gateway honors `X-Forwarded-For` only when the TCP peer is in this list, then walks the chain past trusted hops, so per-IP sign-in rate limits and audit events record developer IPs instead of the load balancer's.
215 
216 On both tracks the front end is an internal ALB, whether created directly or by the AWS Load Balancer Controller, and an ALB's nodes take addresses from the subnets it is attached to, so set `trusted_proxies` to those subnets' CIDRs. This trusts every host in those subnets as a proxy. Keep the ALB's ingress source, your corporate CIDR, from overlapping them, and don't share the subnets with untrusted workloads that could spoof client IPs via `X-Forwarded-For`.
217 
218 The ALB's client port preservation attribute, `routing.http.xff_client_port.enabled`, can stay at either setting: with it on, the ALB writes the client as `203.0.113.7:54321` or `[2001:db8::1]:54321`, and the gateway reads both with the port dropped.
219 
220 ```yaml gateway.yaml theme={null}
221 listen:
222 host: 0.0.0.0
223 port: 8080
224 public_url: https://claude-gateway.internal.example.com
225 trusted_proxies: [<your-alb-subnet-cidrs>]
226 
227 oidc:
228 issuer: https://example.okta.com
229 client_id: 0oa1example2
230 client_secret: ${OIDC_CLIENT_SECRET} # EKS: ${file:/secrets/oidc-client-secret}
231 allowed_email_domains: [example.com]
232 # The Okta org authorization server returns a thin id_token that omits
233 # email and groups; the gateway fills them from /userinfo.
234 userinfo_fallback: true
235 # Okta emits groups only when the `groups` scope is requested and the
236 # app's groups claim filter allows them.
237 scopes: [openid, profile, email, offline_access, groups]
238 
239 session:
240 jwt_secret: ${GATEWAY_JWT_SECRET} # EKS: ${file:/secrets/jwt-secret}
241 ttl_hours: 8 # bounds deprovision latency; lower
242 # toward 1 for tighter revocation
243 
244 store:
245 postgres_url: ${GATEWAY_POSTGRES_URL} # EKS: ${file:/secrets/postgres-url}
246 # readiness_grace_seconds: 300 # keep passing the health check
247 # through an RDS failover
248 
249 upstreams:
250 - provider: bedrock
251 region: <your-region> # match $AWS_REGION so the IAM
252 # policy's ARNs cover it
253 auth: {} # AWS default credential chain:
254 # ECS task role, or IRSA on EKS
255 ```
256 
257 <Note>
258 Only the `oidc` block is Okta-specific. To use Microsoft Entra ID instead, set `issuer` to `https://login.microsoftonline.com/<tenant-id>/v2.0`, drop `userinfo_fallback` and the `groups` scope, and note that Entra emits group Object IDs rather than names, so [`managed.policies`](/docs/en/claude-apps-gateway-config#managed) must match on the GUIDs, or on App Roles with `oidc.groups_claim: roles`. See [Identity provider setup](/docs/en/claude-apps-gateway-deploy#identity-provider-setup).
259 </Note>
260 </Step>
261 
262 <Step title="Store secrets in AWS Secrets Manager">
263 Create three secrets; the execution role from the IAM step can already read them:
264 
265 ```bash theme={null}
266 aws secretsmanager create-secret --name gateway-jwt-secret \
267 --secret-string "$(openssl rand -base64 32)"
268 aws secretsmanager create-secret --name gateway-oidc-client-secret \
269 --secret-string '<your-okta-client-secret>'
270 aws secretsmanager create-secret --name gateway-postgres-url \
271 --secret-string "$GATEWAY_POSTGRES_URL"
272 ```
273 
274 Note the ARN each call prints; the ECS task definition references secrets by ARN.
275 
276 <Note>
277 Literal `--secret-string` arguments are visible in the process table and in audit/EDR logs while each command runs. On a shared or monitored host, put the value in a `0600` file and pass `--secret-string file://<path>` instead. The bundle's `setup.sh` keeps secret values off process argv the same way, passing `0600` temporary files to `--cli-input-json`.
278 </Note>
279 
280 Unlike the secrets, `gateway.yaml` itself contains no secret values, because every credential resolves at boot through [`${VAR}` or `${file:...}` expansion](/docs/en/claude-apps-gateway-config#secret-expansion). How everything reaches the container differs by track:
281 
282 * On ECS, the next step's build copies `gateway.yaml` into the image at `/etc/claude/gateway.yaml`, and the task definition injects the three secrets as environment variables via its `secrets` field, so the YAML references `${GATEWAY_JWT_SECRET}`, `${OIDC_CLIENT_SECRET}`, and `${GATEWAY_POSTGRES_URL}`.
283 * On EKS, mount `gateway.yaml` from a ConfigMap and the secrets as files at `/secrets`, referenced as `${file:/secrets/...}`. Source the Kubernetes Secrets from Secrets Manager with External Secrets Operator or the Secrets Store CSI driver's AWS provider, or create them directly with `kubectl`.
284 </Step>
285 
286 <Step title="Build and push the image to Amazon ECR">
287 Build the image per the [container image requirements](/docs/en/claude-apps-gateway-deploy#container-image), placing the `linux-x64` glibc binary at `./claude` in the build context. Write your own Dockerfile per those requirements or start from the bundle's [`Dockerfile`](https://github.com/anthropics/claude-code/blob/main/examples/gateway/aws/Dockerfile), which copies the filled-in `gateway.yaml` from the previous steps into the image at `/etc/claude/gateway.yaml`. On ECS that embedded copy is how the configuration reaches the container, which is why the build comes after the file is written. The EKS track instead mounts `gateway.yaml` from a ConfigMap at deploy, so the embedded copy is unused there.
288 
289 The image also carries the AWS RDS certificate bundle as the trust anchor for the connection string's `sslmode=verify-full`, so download it into the build context first. AWS rotates the bundle (new regional CAs get appended), so download it per build rather than pinning a checksum or committing it:
290 
291 ```bash theme={null}
292 curl -fL --proto '=https' -o rds-global-bundle.pem \
293 https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
294 ```
295 
296 The container image requirements don't cover the bundle, so if you write your own Dockerfile, add the two lines that copy and trust it; the bundle's `Dockerfile` already includes both:
297 
298 ```dockerfile theme={null}
299 COPY rds-global-bundle.pem /etc/claude/rds-global-bundle.pem
300 ENV NODE_EXTRA_CA_CERTS=/etc/claude/rds-global-bundle.pem
301 ```
302 
303 Create the ECR repository and sign Docker in to it. Immutable tags mean the `<version>` tag the deploy step pins cannot later be silently re-pointed at a different image:
304 
305 ```bash theme={null}
306 aws ecr create-repository --repository-name claude-gateway \
307 --image-tag-mutability IMMUTABLE \
308 --image-scanning-configuration scanOnPush=true
309 aws ecr get-login-password --region "$AWS_REGION" \
310 | docker login --username AWS --password-stdin \
311 "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
312 ```
313 
314 Build and push the image. The task definition below runs `linux/amd64`, so the platform must match here; for Fargate on ARM64 (Graviton), build `linux/arm64` with the `linux-arm64` binary and set `cpuArchitecture` to `ARM64` instead:
315 
316 ```bash theme={null}
317 docker build --platform=linux/amd64 \
318 -t "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/claude-gateway:<version>" .
319 docker push "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/claude-gateway:<version>"
320 ```
321 </Step>
322 
323 <Step title="Deploy">
324 <Tabs>
325 <Tab title="ECS Fargate">
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](/docs/en/claude-apps-gateway-deploy#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](/docs/en/claude-apps-gateway-config#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.
425 </Tab>
426 
427 <Tab title="EKS">
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](/docs/en/claude-apps-gateway-deploy#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](/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.
464 </Tab>
465 </Tabs>
466 </Step>
467 
468 <Step title="Push the gateway URL to developer machines">
469 The gateway is now running, but developers can't reach it from `/login` until the gateway URL is on their machines. Deploy the full [managed settings snippet](/docs/en/claude-apps-gateway#set-the-gateway-url), with `forceLoginMethod`, `forceLoginGatewayUrl`, and the `parentSettingsBehavior: "merge"` opt-in, to each device via MDM. There is no gateway option in the login picker for a developer to select manually.
470 </Step>
471</Steps>
364 ]
365}
366```
367 
368Register it:
369 
370```bash theme={null}
371aws ecs register-task-definition --cli-input-json file://claude-gateway-task.json
372```
373 
374Put 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:
375 
376```bash theme={null}
377ALB_ARN="$(aws elbv2 create-load-balancer --name claude-gateway \
378 --scheme internal --type application --ip-address-type ipv4 \
379 --subnets $PRIVATE_SUBNETS --security-groups "$ALB_SG" \
380 --query 'LoadBalancers[0].LoadBalancerArn' --output text)"
381 
382TG_ARN="$(aws elbv2 create-target-group --name claude-gateway \
383 --protocol HTTP --port 8080 --vpc-id "$VPC_ID" --target-type ip \
384 --health-check-path /readyz \
385 --query 'TargetGroups[0].TargetGroupArn' --output text)"
386```
387 
388Add 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.
389 
390The 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:
391 
392```bash theme={null}
393aws elbv2 create-listener --load-balancer-arn "$ALB_ARN" \
394 --protocol HTTPS --port 443 \
395 --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
396 --certificates CertificateArn=<your-acm-certificate-arn> \
397 --default-actions Type=forward,TargetGroupArn="$TG_ARN"
398 
399aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \
400 --attributes Key=idle_timeout.timeout_seconds,Value=3600
401```
402 
403Create 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:
404 
405```bash theme={null}
406aws ecs create-service --cluster claude-gateway --service-name claude-gateway \
407 --task-definition claude-gateway --desired-count 1 --launch-type FARGATE \
408 --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
409 --health-check-grace-period-seconds 60 \
410 --network-configuration "awsvpcConfiguration={subnets=[$(echo $PRIVATE_SUBNETS | tr ' ' ',')],securityGroups=[$GW_SG],assignPublicIp=DISABLED}" \
411 --load-balancers "targetGroupArn=$TG_ARN,containerName=gateway,containerPort=8080"
412```
413 
414The 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.
415 
416The 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](/docs/en/claude-apps-gateway-deploy#outage-behavior), which also covers the `/healthz` alternative.
417 
418The 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](/docs/en/claude-apps-gateway-config#amazon-bedrock); the IdP still needs internet egress.
419 
420Finish 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.
421 
422Update 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.
423 
424**EKS**
425 
426This 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`.
427 
428On 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:
429 
430```bash theme={null}
431BEDROCK_POLICY_ARN="$(aws iam create-policy --policy-name claude-gateway-bedrock-invoke \
432 --policy-document file://bedrock-invoke.json --query Policy.Arn --output text)"
433SECRETS_POLICY_ARN="$(aws iam create-policy --policy-name claude-gateway-secrets-read \
434 --policy-document file://secrets-read.json --query Policy.Arn --output text)"
435 
436kubectl create namespace claude-gateway
437eksctl create iamserviceaccount --cluster <your-cluster> --region "$AWS_REGION" \
438 --namespace claude-gateway --name gateway --role-name claude-gateway \
439 --attach-policy-arn "$BEDROCK_POLICY_ARN" \
440 --attach-policy-arn "$SECRETS_POLICY_ARN" \
441 --approve
442```
443 
444The 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.
445 
446Deploy the gateway as a standard Deployment plus a Service and an Ingress, as described in [Kubernetes deployment](/docs/en/claude-apps-gateway-deploy#kubernetes), with:
447 
448* `serviceAccountName: gateway`
449* `gateway.yaml` mounted from a ConfigMap and the secrets mounted at `/secrets`
450* the readiness probe pointed at `GET /readyz`
451 
452For the front end, an Ingress managed by the AWS Load Balancer Controller provisions the internal ALB. Annotate it with:
453 
454* `alb.ingress.kubernetes.io/scheme: internal` and `alb.ingress.kubernetes.io/target-type: ip`
455* `alb.ingress.kubernetes.io/ip-address-type: ipv4`, so no public-range AAAA records are published for the `/login` [private-network check](/docs/en/claude-apps-gateway#prerequisites) to reject
456* `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
457* `alb.ingress.kubernetes.io/certificate-arn` with the ACM certificate
458* `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
459* `alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600`, a margin above the gateway's streaming keepalive; see [Troubleshooting](#troubleshooting)
460 
461With 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.
462 
463**8. Push the gateway URL to developer machines**
464 
465The gateway is now running, but developers can't reach it from `/login` until the gateway URL is on their machines. Deploy the full [managed settings snippet](/docs/en/claude-apps-gateway#set-the-gateway-url), with `forceLoginMethod`, `forceLoginGatewayUrl`, and the `parentSettingsBehavior: "merge"` opt-in, to each device via MDM. There is no gateway option in the login picker for a developer to select manually.
472466 
473467## Terraform reference
474468 
475469 
Feedback