spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

Add Claude apps gateway on AWS example deployment assetsBrowse files
Reference artifacts accompanying the Gateway-on-AWS walkthrough
(https://code.claude.com/docs/en/claude-apps-gateway-on-aws), sibling
to the existing examples/gateway/gcp assets:

- setup.sh: scripts the walkthrough end to end via the aws CLI
  (security groups, IAM roles, ECR image build/push with gateway.yaml
  baked in, private-subnet RDS for PostgreSQL, Secrets Manager secrets,
  ECS Fargate service behind an internal ALB)
- Dockerfile: distroless runtime image for the gateway; the Claude Code
  release binary is verified against an operator-supplied sha256
- gateway.yaml.example: config template (Bedrock upstream, Okta IdP)
- terraform/: module provisioning the same architecture (ECS track)

Provided as a working example to adapt, not a supported production
deployment.
Roy Arsan authored on Jul 21, 2026 parent b799fca · commit 843297f6b126
13 changed files+2240 -0compared with b799fca
Aexamples/gateway/aws/.dockerignore
Aexamples/gateway/aws/.gitignore
Aexamples/gateway/aws/Dockerfile
Aexamples/gateway/aws/README.md
Aexamples/gateway/aws/gateway.yaml.example
Aexamples/gateway/aws/setup.sh
Aexamples/gateway/aws/terraform/.gitignore
Aexamples/gateway/aws/terraform/README.md
Aexamples/gateway/aws/terraform/main.tf
Aexamples/gateway/aws/terraform/outputs.tf
Aexamples/gateway/aws/terraform/terraform.tfvars.example
Aexamples/gateway/aws/terraform/variables.tf
Aexamples/gateway/aws/terraform/versions.tf
@@ -1,0 +1,19 @@
1+# Keep secrets and generated artifacts out of the build context. The Dockerfile
2+# COPYs the binary, gateway.yaml (unlike the GCP example, the config is baked
3+# into the image — ECS injects only the secrets it references, as env vars),
4+# and the RDS CA bundle. BuildKit (the default builder) only syncs the
5+# referenced COPY sources anyway, so this is a denylist for the classic
6+# builder (DOCKER_BUILDKIT=0) and a conventional signal that the .gitignore'd
7+# secrets in this directory aren't part of the image build.
8+terraform/
9+**/.terraform/
10+*.tfstate*
11+terraform.tfvars
12+secrets/
13+*.pem
14+# The RDS CA bundle is public trust-anchor material (no secret), and the
15+# Dockerfile COPYs it — carve it out of the *.pem exclusion above.
16+!rds-global-bundle.pem
17+*.iam.json
18+claude.download
19+claude.bad
@@ -1,0 +1,18 @@
1+# Local, environment-specific config — copy gateway.yaml.example -> gateway.yaml
2+# (gateway.yaml.example IS committed; your filled-in gateway.yaml is not)
3+gateway.yaml
4+
5+# Secrets / credentials — never commit. Also covers rds-global-bundle.pem:
6+# not a secret, but downloaded by setup.sh when absent (delete it to refresh
7+# after an RDS CA rotation), so it stays out of git.
8+secrets/
9+*.pem
10+
11+# Scratch IAM policy documents written by setup.sh (no secrets, but generated)
12+*.iam.json
13+
14+# Vendored release binary — download per release (see setup.sh DIST_URL).
15+# claude.bad is a checksum-mismatched binary that setup.sh set aside.
16+claude
17+claude.download
18+claude.bad
@@ -1,0 +1,67 @@
1+# Runtime image for `claude gateway`.
2+#
3+# This image does NOT build the binary. It expects a prebuilt native
4+# linux-x64 `claude` executable in the build context — the Claude Code release
5+# binary, which includes the `gateway` subcommand. setup.sh places it at
6+# ./claude (downloading and checksum-verifying it via DIST_URL/DIST_SHA256 if
7+# missing). Override CLAUDE_BINARY to point at a different path.
8+#
9+# Unlike the GCP example (which mounts the config from Secret Manager at
10+# runtime), this image BAKES gateway.yaml in at /etc/claude/gateway.yaml — on
11+# ECS the task definition injects only the secrets the YAML references, as env
12+# vars. gateway.yaml therefore must be fully filled in (no REPLACE_ME) before
13+# building; setup.sh enforces this. A config edit means a rebuild under a new
14+# tag. The file contains no secret values — every credential resolves at boot
15+# via ${ENV_VAR} expansion.
16+#
17+# The image also bakes in the AWS RDS CA bundle (rds-global-bundle.pem —
18+# setup.sh downloads it from https://truststore.pki.rds.amazonaws.com before
19+# the build) and trusts it via NODE_EXTRA_CA_CERTS, so the store connection
20+# string's `?sslmode=verify-full` verifies the RDS server certificate chain
21+# and hostname. NOTE the gateway's driver reads `sslmode` from the URL but NOT
22+# a libpq-style `sslrootcert=` param — the CA must come from this env var.
23+#
24+# Build:
25+# docker build --platform=linux/amd64 --provenance=false \
26+# --build-arg CLAUDE_BINARY=./claude -t claude-gateway .
27+#
28+# (For Fargate on ARM64/Graviton: build --platform=linux/arm64 with the
29+# linux-arm64 binary and set the task definition's cpuArchitecture to ARM64.)
30+#
31+# Run:
32+# docker run --rm -p 8080:8080 \
33+# -e OIDC_CLIENT_SECRET -e GATEWAY_JWT_SECRET -e GATEWAY_POSTGRES_URL \
34+# claude-gateway
35+
36+ARG CLAUDE_BINARY=./claude
37+ARG GATEWAY_CONFIG=./gateway.yaml
38+ARG RDS_CA_BUNDLE=./rds-global-bundle.pem
39+
40+# distroless/cc provides glibc + libstdc++ (required by the Bun-compiled
41+# native binary). The :nonroot tag runs as uid/gid 65532. Pinned by digest so
42+# the build never silently takes new upstream bytes (the digest is the
43+# multi-arch OCI index, so --platform still selects amd64/arm64). To refresh
44+# the pin after reviewing upstream changes:
45+# docker manifest inspect -v gcr.io/distroless/cc-debian12:nonroot # prints the index digest
46+FROM gcr.io/distroless/cc-debian12:nonroot@sha256:ce0d66bc0f64aae46e6a03add867b07f42cc7b8799c949c2e898057b7f75a151
47+
48+ARG CLAUDE_BINARY
49+ARG GATEWAY_CONFIG
50+ARG RDS_CA_BUNDLE
51+COPY --chmod=0755 ${CLAUDE_BINARY} /usr/local/bin/claude
52+# WORKDIR pre-creates /etc/claude with 0755 — without it, COPY --chmod would
53+# also stamp the auto-created parent directory 0644 (no execute bit), making
54+# the config unreadable for the nonroot user.
55+WORKDIR /etc/claude
56+COPY --chmod=0644 ${GATEWAY_CONFIG} /etc/claude/gateway.yaml
57+COPY --chmod=0644 ${RDS_CA_BUNDLE} /etc/claude/rds-global-bundle.pem
58+WORKDIR /
59+
60+ENV CLAUDE_CONFIG_DIR=/tmp/.claude
61+# Trust anchor for the store's sslmode=verify-full (see header comment).
62+ENV NODE_EXTRA_CA_CERTS=/etc/claude/rds-global-bundle.pem
63+
64+EXPOSE 8080
65+USER nonroot
66+
67+ENTRYPOINT ["/usr/local/bin/claude", "gateway", "--config", "/etc/claude/gateway.yaml"]
@@ -1,0 +1,19 @@
1+# Claude apps gateway on AWS
2+
3+Reference deployment artifacts for running Claude apps gateway on AWS with
4+Amazon Bedrock as the upstream: ECS on Fargate or EKS, Amazon RDS for
5+PostgreSQL, AWS Secrets Manager, and IAM-role auth to Bedrock.
6+
7+These files are provided as a working example rather than a supported production
8+deployment. Adapt them to your own environment.
9+
10+- **Walkthrough**: https://code.claude.com/docs/en/claude-apps-gateway-on-aws
11+- **Related**: AWS-maintained samples for various customer environments at
12+ https://github.com/aws-samples/anthropic-on-aws/tree/main/claude-apps-gateway
13+
14+| File | Purpose |
15+|---|---|
16+| `setup.sh` | Scripts the walkthrough end to end via the `aws` CLI |
17+| `Dockerfile` | Runtime image for the `claude gateway` binary (bakes in `gateway.yaml`) |
18+| `gateway.yaml.example` | Gateway config template, AWS-shaped (Bedrock upstream, Okta IdP) |
19+| `terraform/` | Provisions the full architecture (two-pass apply — see `terraform/README.md`) |
@@ -1,0 +1,174 @@
1+# gateway.yaml.example — Claude apps gateway config template, AWS-shaped (walkthrough §4).
2+#
3+# Okta IdP + Bedrock upstream, following the walkthrough at
4+# https://code.claude.com/docs/en/claude-apps-gateway-on-aws. The active sections
5+# below are a strict subset of the full configuration reference at
6+# https://code.claude.com/docs/en/claude-apps-gateway-config; optional keys are
7+# included commented-out.
8+#
9+# USAGE — this is the shippable TEMPLATE. Copy it to gateway.yaml and fill it in:
10+# cp gateway.yaml.example gateway.yaml
11+# setup.sh and terraform/ read gateway.yaml (your filled-in copy, which is
12+# gitignored). Unlike the GCP example it is NOT published to a secret store:
13+# the Dockerfile bakes it into the image at /etc/claude/gateway.yaml — the
14+# container ENTRYPOINT runs `claude gateway --config /etc/claude/gateway.yaml`.
15+# It holds no secret values; a config edit means an image rebuild (setup.sh
16+# tags images with a hash of this file, so a re-run rebuilds automatically).
17+#
18+# Secret expansion: ${ENV_VAR} reads an env var; ${file:/path} reads a mounted file.
19+# On ECS, the task definition injects the JWT / OIDC / Postgres secrets as ENV
20+# VARS via its `secrets` field (valueFrom -> Secrets Manager ARN). On EKS you
21+# may mount them as files instead and use ${file:/secrets/...}.
22+#
23+# BEFORE BUILD — replace every REPLACE_ME placeholder below (setup.sh refuses to
24+# build the image while any remain — the config is baked in, so a half-filled
25+# config would ship), and create the referenced secrets:
26+# gateway-jwt-secret (setup.sh generates this)
27+# gateway-oidc-client-secret (from the Okta admin console OIDC web app)
28+# gateway-postgres-url (setup.sh generates this)
29+
30+# ── Listener ─────────────────────────────────────────────────────────────────
31+listen:
32+ host: 0.0.0.0
33+ port: 8080 # the target group forwards ALB :443 -> :8080
34+ # Required. Fixes the IdP redirect_uri, the OIDC discovery doc, and the
35+ # gateway-token issuer so none are derived from the client-controlled Host
36+ # header (X-Forwarded-Host/-Proto are likewise never trusted). Set it to the
37+ # internal hostname you picked in the prerequisites — the Route 53 private
38+ # zone name your ACM certificate covers (e.g.
39+ # https://claude-gateway.internal.example.com). Unlike Cloud Run there is no
40+ # first-deploy placeholder dance: you choose the hostname up front, alias it
41+ # to the internal ALB after the deploy, and register the same host's
42+ # /oauth/callback on the Okta app.
43+ public_url: REPLACE_ME
44+ # Register this exact redirect URI on the Okta OIDC web application:
45+ # https://<public_url host>/oauth/callback
46+ #
47+ # Behind the internal ALB every request arrives via the load balancer, so the
48+ # gateway sees ALB-node peer IPs for all developers — set trusted_proxies so
49+ # X-Forwarded-For from those proxies is trusted and per-IP rate limiting /
50+ # audit IPs record the real client. ALB nodes take addresses from the subnets
51+ # the ALB is attached to, so list those subnets' CIDRs (the private subnets
52+ # from the prerequisites).
53+ #
54+ # NOTE: listing the ALB subnets' CIDRs trusts every host in those subnets as a
55+ # proxy — any co-located workload that can reach the ALB can then spoof the
56+ # client IP via X-Forwarded-For (audit logs, per-IP rate limits, IP
57+ # allowlists). Keep the ALB :443 ingress source (CORP_CIDR / corporate_cidr)
58+ # from overlapping these subnets, and don't share the subnets with untrusted
59+ # workloads.
60+ trusted_proxies: [REPLACE_ME] # e.g. [10.0.1.0/24, 10.0.2.0/24]
61+ #
62+ # Alternative — terminate TLS in the gateway itself instead of at the ALB:
63+ # tls:
64+ # cert: /certs/gateway.crt
65+ # key: /certs/gateway.key
66+
67+# ── Identity provider — Okta ─────────────────────────────────────────────────
68+oidc:
69+ issuer: REPLACE_ME # e.g. https://example.okta.com (or your custom auth server URL)
70+ client_id: REPLACE_ME # Okta OIDC web app client ID (not secret)
71+ client_secret: ${OIDC_CLIENT_SECRET} # EKS file mounts: ${file:/secrets/oidc-client-secret}
72+ allowed_email_domains: [REPLACE_ME] # e.g. [example.com] — reject id_tokens outside your org
73+ # The Okta org authorization server returns a thin id_token that omits email
74+ # and groups; the gateway fills them from /userinfo.
75+ userinfo_fallback: true
76+ # offline_access yields refresh tokens (silent renewal + the deprovision
77+ # leash); Okta emits groups only when the `groups` scope is requested AND the
78+ # app's groups claim filter allows them (Okta admin console -> the app's
79+ # Sign On tab -> OpenID Connect ID Token -> Groups claim filter).
80+ scopes: [openid, profile, email, offline_access, groups]
81+ # groups_claim: groups # Okta default. Entra app roles=roles; see the config reference
82+ # ca_cert_pem: ${file:/secrets/idp-ca.pem} # only for an IdP behind a private CA
83+
84+# ── Sessions ─────────────────────────────────────────────────────────────────
85+session:
86+ jwt_secret: ${GATEWAY_JWT_SECRET} # >= 32 bytes; openssl rand -base64 32
87+ # Okta issues refresh tokens (offline_access above), so sessions renew
88+ # silently and this mainly bounds deprovision latency. 8 is a sane default;
89+ # lower toward 1 for tighter revocation. Array form rotates keys:
90+ # [new, old] (index 0 signs, all verify).
91+ ttl_hours: 8
92+
93+# ── Store (REQUIRED — the gateway refuses to boot without it) ─────────────────
94+store:
95+ postgres_url: ${GATEWAY_POSTGRES_URL} # private-subnet RDS; built with ?sslmode=verify-full by setup.sh
96+ # (the image trusts the RDS CA bundle via NODE_EXTRA_CA_CERTS — see Dockerfile)
97+
98+# ── Upstreams — Amazon Bedrock ───────────────────────────────────────────────
99+upstreams:
100+ - provider: bedrock
101+ # Must equal the region you provision in (setup.sh's AWS_REGION /
102+ # terraform's region): the IAM policy's inference-profile ARNs are scoped
103+ # to that region, and Bedrock model access is enabled there (cross-region
104+ # us.anthropic.* profiles need access in every spanned region). NOTE: the
105+ # walkthrough is scoped to US regions — the built-in model catalog maps to
106+ # us.anthropic.* (US-geo) profiles; a non-US region also needs a models:
107+ # list below (see the model catalog section).
108+ region: REPLACE_ME # e.g. us-east-1
109+ auth: {} # AWS default credential chain: ECS task role / IRSA on EKS (preferred — no static keys)
110+ # base_url: https://bedrock-runtime.us-east-1.amazonaws.com # bedrock-runtime interface VPC endpoint, to keep model traffic off the public path
111+ # Add more upstreams for failover (tried top→bottom on 5xx/timeout/501): a
112+ # second region, or an anthropic/vertex fallback. See
113+ # https://code.claude.com/docs/en/claude-apps-gateway.
114+
115+# ── Telemetry fan-out (OPTIONAL) ─────────────────────────────────────────────
116+# The CLI sends OTLP/HTTP to the gateway; the gateway fans out, stamping
117+# user.id/user.email/user.groups server-side. On AWS, point at an OpenTelemetry
118+# Collector (e.g. the AWS Distro for OpenTelemetry -> CloudWatch / Managed
119+# Prometheus). When forward_to and public_url are both configured the gateway
120+# pushes CLAUDE_CODE_ENABLE_TELEMETRY and the OTEL exporter selectors to every
121+# client automatically — no per-developer config needed.
122+# telemetry:
123+# forward_to:
124+# - url: https://otel-collector.internal.example.com:4318
125+# headers:
126+# Authorization: ${file:/secrets/otlp-token}
127+# metrics: true # safe aggregate counters (default)
128+# logs: false # carries bash commands / tool inputs — opt in deliberately
129+# traces: false
130+
131+# ── RBAC + managed settings (OPTIONAL; first-match-wins, top -> bottom) ───────
132+# With Okta as IdP, match on the group names the `groups` scope emits (subject
133+# to the app's groups claim filter), or on email_domain.
134+# managed:
135+# policies:
136+# - match: { groups: [engineering] }
137+# cli:
138+# availableModels: [claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5]
139+# permissions: { deny: ["Read(./.env)", "Read(./secrets/**)"] }
140+# - match: {} # catch-all floor — keep LAST
141+# cli:
142+# availableModels: [claude-sonnet-4-6, claude-haiku-4-5]
143+
144+# ── Admin API (OPTIONAL — enables db-mode runtime config + spend caps) ───────
145+# admin_groups needs a groups claim — Okta provides one via the `groups` scope
146+# above — or use the bootstrap keys below instead. Named keys for attribution
147+# in the audit log; 32-char minimum on key values. On ECS add these to the task
148+# definition's `secrets` field (valueFrom -> a Secrets Manager ARN), same as the
149+# JWT/OIDC/Postgres secrets above; on EKS you may use ${file:...}.
150+# admin:
151+# write_keys:
152+# - id: terraform
153+# key: ${GATEWAY_ADMIN_WRITE_KEY}
154+# read_keys:
155+# - id: reporting
156+# key: ${GATEWAY_ADMIN_READ_KEY}
157+# # admin_groups: [platform-finops] # Okta group names via the groups scope
158+
159+# ── Model catalog (OPTIONAL for US regions) ──────────────────────────────────
160+# Default true: every built-in Claude model is exposed and auto-translated per
161+# upstream (the built-in table already maps to us.anthropic.* cross-region
162+# inference profiles). Set false + a models: list to pin IDs (e.g. an
163+# application or provisioned-throughput inference-profile ARN).
164+# NON-US REGIONS: the built-in us.anthropic.* mappings do not exist outside
165+# the US geo — set auto_include_builtin_models: false and list your region's
166+# inference profiles (eu.anthropic.*, apac.anthropic.*, ...) here, and widen
167+# the geo prefix in the deploy's bedrock-invoke IAM policy to match. See the
168+# models: guidance in the config reference:
169+# https://code.claude.com/docs/en/claude-apps-gateway-config
170+# auto_include_builtin_models: true
171+# models:
172+# - id: claude-opus-4-8
173+# label: Claude Opus 4.8
174+# upstream_model: { bedrock: us.anthropic.claude-opus-4-8 }
@@ -1,0 +1,964 @@
1+#!/usr/bin/env bash
2+#
3+# setup.sh — AWS setup for Claude apps gateway (walkthrough §1–7, ECS track).
4+#
5+# Provisions, in this order: the three security groups (§1), the task +
6+# execution IAM roles (§2), the gateway container image in Amazon ECR (§6),
7+# an RDS for PostgreSQL instance in the private subnets with no public
8+# address (§3), the JWT + postgres-url secrets (§5), and an ECS Fargate
9+# service behind an internal Application Load Balancer (§7).
10+#
11+# gateway.yaml (§4 of the walkthrough) is BAKED INTO THE IMAGE on this track —
12+# the task definition injects only the secrets it references, as env vars — so
13+# the config step here lives inside the image build (§6): the build is gated on
14+# a fully filled-in gateway.yaml and the image tag carries a hash of it, so a
15+# config edit triggers a rebuild on the next run.
16+#
17+# Section markers (§N) below map to the walkthrough:
18+# https://code.claude.com/docs/en/claude-apps-gateway-on-aws
19+#
20+# Covers here: security groups (§1) -> IAM roles + Bedrock model-access note (§2)
21+# -> build & push image, config baked in (§6 + §4) -> DB subnet group
22+# + RDS instance (§3) -> jwt + postgres-url secrets (§5) -> ECS
23+# cluster/task definition/service + internal ALB (§7, ECS Fargate tab).
24+# Not covered: EKS track (§7's EKS tab) — ECS Fargate is the lower-friction path here.
25+# Bedrock model access (§2) — console-only; the script reminds you.
26+# Route 53 alias — see the next steps it prints. Client MDM
27+# push (§8) is covered by the walkthrough, not this script.
28+#
29+# Idempotent: existing resources are detected and skipped, so it is safe to re-run.
30+# Reuse is by NAME, so a pre-existing resource may not match what this script
31+# would have created: reuse that would change the exposure model is fatal (an
32+# ALB that is not internal/in ${VPC_ID}); upsert-able settings are converged on
33+# every run; other posture drift (extra security group ingress, a public or
34+# unencrypted RDS instance, wrong-VPC target group) is checked and warned
35+# about, never silently adopted.
36+# Override any default below via environment variable, e.g. `AWS_REGION=us-west-2 ./setup.sh`.
37+
38+set -euo pipefail
39+
40+# ---- configuration (env-overridable) ----------------------------------------
41+AWS_REGION="${AWS_REGION:-$(aws configure get region 2>/dev/null || true)}" # guide uses us-east-1 (a region where Bedrock serves the Claude models you need)
42+ACCOUNT_ID="${ACCOUNT_ID:-$(aws sts get-caller-identity --query Account --output text 2>/dev/null || true)}"
43+
44+VPC_ID="${VPC_ID:-}" # REQUIRED — the VPC from the prerequisites
45+PRIVATE_SUBNETS="${PRIVATE_SUBNETS:-}" # REQUIRED — two+ private subnet IDs in different AZs, space-separated
46+CORP_CIDR="${CORP_CIDR:-}" # REQUIRED — your corporate network CIDR (ALB :443 ingress source)
47+ # Must not overlap PRIVATE_SUBNETS: hosts there are trusted_proxies (gateway.yaml) and could spoof client IPs via X-Forwarded-For.
48+
49+# §1 security groups
50+ALB_SG_NAME="${ALB_SG_NAME:-claude-gateway-alb}"
51+GW_SG_NAME="${GW_SG_NAME:-claude-gateway-svc}"
52+DB_SG_NAME="${DB_SG_NAME:-claude-gateway-db}"
53+
54+# §2 IAM roles (task role = the gateway's runtime AWS identity; execution role
55+# = the ECS agent's identity for pulling the image and injecting secrets)
56+TASK_ROLE="${TASK_ROLE:-claude-gateway-task}"
57+EXEC_ROLE="${EXEC_ROLE:-claude-gateway-execution}"
58+
59+# §6 image
60+ECR_REPO="${ECR_REPO:-claude-gateway}" # ECR repository name
61+VERSION="${VERSION:-}" # REQUIRED — the gateway release tag you build and push (e.g. the linux-x64 binary's version)
62+DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
63+CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # prebuilt linux-x64 Claude Code release binary (includes the gateway subcommand)
64+DIST_URL="${DIST_URL:-}" # optional: download URL, used only if $CLAUDE_BINARY is missing
65+DIST_SHA256="${DIST_SHA256:-}" # REQUIRED with DIST_URL: expected sha256 of the binary (verified fail-closed)
66+DIST_SHA256="${DIST_SHA256,,}" # normalize to lowercase — openssl emits lowercase hex; some tools (PowerShell Get-FileHash) publish uppercase
67+# Obtain DIST_SHA256 out-of-band — never from the server that serves DIST_URL.
68+# For binaries from the standard Claude Code release channel, verify the
69+# release's GPG-signed manifest.json and copy the platform checksum from it:
70+# https://code.claude.com/docs/en/setup#binary-integrity-and-code-signing
71+# For any other distribution channel, use the checksum published alongside the
72+# download link on that channel.
73+GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §4 config file — BAKED into the image
74+RDS_CA_BUNDLE="${RDS_CA_BUNDLE:-./rds-global-bundle.pem}" # RDS CA trust anchor — BAKED into the image (downloaded below if missing)
75+# Official AWS RDS truststore. AWS rotates this bundle (new regional CAs get
76+# appended), so no checksum is pinned — a pinned hash would break on every
77+# rotation. The script downloads it only when absent (an existing file is never
78+# re-downloaded); to pick up a rotation, delete the file — and since the image
79+# tag hashes only gateway.yaml, also bump VERSION or set IMAGE_TAG so the
80+# next run rebuilds rather than reusing the existing tag. Operators who want
81+# to pin may pre-place a reviewed copy at ${RDS_CA_BUNDLE}.
82+RDS_CA_BUNDLE_URL="${RDS_CA_BUNDLE_URL:-https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem}"
83+REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
84+
85+# §3 RDS
86+DB_SUBNET_GROUP="${DB_SUBNET_GROUP:-claude-gateway-db}"
87+DB_PARAM_GROUP="${DB_PARAM_GROUP:-claude-gateway-db}" # carries rds.force_ssl=1 (server-side TLS enforcement)
88+DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
89+DB_CLASS="${DB_CLASS:-db.t4g.micro}"
90+DB_STORAGE_GB="${DB_STORAGE_GB:-20}"
91+DB_NAME="${DB_NAME:-claude_gateway}"
92+DB_USER="${DB_USER:-gateway}"
93+# PG14+ supported; 16 is the recommended default (matches terraform/'s).
94+# Always pinned: the instance's engine version and the parameter group's
95+# family must name the same major, so both derive from this one value.
96+DB_ENGINE_VERSION="${DB_ENGINE_VERSION:-16}"
97+
98+SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
99+JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
100+OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Okta OIDC web app)
101+# NOTE: the execution role's secrets-read policy (§2) is built from these
102+# three names, one per-secret ARN prefix each — a rename is picked up on the
103+# next run (put-role-policy is an upsert).
104+
105+# §7 ECS + internal ALB deploy
106+CLUSTER="${CLUSTER:-claude-gateway}"
107+SERVICE="${SERVICE:-claude-gateway}"
108+TASK_FAMILY="${TASK_FAMILY:-claude-gateway}"
109+LOG_GROUP="${LOG_GROUP:-/ecs/claude-gateway}"
110+LOG_RETENTION_DAYS="${LOG_RETENTION_DAYS:-90}" # CloudWatch retention — the group carries the gateway's audit events, so align with your audit retention policy
111+ALB_NAME="${ALB_NAME:-claude-gateway}"
112+TG_NAME="${TG_NAME:-claude-gateway}"
113+# Explicit modern TLS policy — omitting it falls back to the legacy
114+# ELBSecurityPolicy-2016-08 default, which still accepts TLS 1.0/1.1.
115+ALB_SSL_POLICY="${ALB_SSL_POLICY:-ELBSecurityPolicy-TLS13-1-2-2021-06}"
116+ACM_CERT_ARN="${ACM_CERT_ARN:-}" # REQUIRED for deploy — ACM cert for your internal gateway hostname
117+TASK_CPU="${TASK_CPU:-1024}"
118+TASK_MEMORY="${TASK_MEMORY:-2048}"
119+DESIRED_COUNT="${DESIRED_COUNT:-1}" # each task opens a Postgres pool of up to 5 connections (store.max_connections default); keep DESIRED_COUNT × 5 below the DB class's max_connections (~80 on db.t4g.micro)
120+DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no ECS/ALB deploy
121+
122+# ---- helpers ----------------------------------------------------------------
123+log() { printf '\n==> %s\n' "$*"; }
124+skip() { printf ' (exists) %s\n' "$*"; }
125+curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
126+sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
127+
128+# authorize-security-group-ingress is NOT idempotent (re-adding a rule errors),
129+# so tolerate exactly the duplicate-rule error and fail on anything else.
130+authorize_ingress() {
131+ local out
132+ if out="$(aws ec2 authorize-security-group-ingress "$@" 2>&1)"; then
133+ return 0
134+ elif grep -q 'InvalidPermission.Duplicate' <<<"${out}"; then
135+ skip "ingress rule already present"
136+ else
137+ printf '%s\n' "${out}" >&2
138+ return 1
139+ fi
140+}
141+
142+# Security-group lookup by name within the VPC; prints the GroupId or "None".
143+sg_id() {
144+ aws ec2 describe-security-groups \
145+ --filters "Name=group-name,Values=$1" "Name=vpc-id,Values=${VPC_ID}" \
146+ --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || echo None
147+}
148+
149+# Name-based reuse can adopt a pre-existing group carrying ingress this script
150+# never added. Audit after the intended rule is ensured: each group's traffic
151+# path is exactly one rule (tcp <port> from <cidr-or-source-group>), so anything
152+# else is flagged on stderr. Non-fatal — an extra rule may be a deliberate
153+# operator addition — but every one widens the path, so it must be visible.
154+warn_unexpected_ingress() { # <group-id> <group-name> <port> <expected cidr or source group-id>
155+ local perms
156+ if ! perms="$(aws ec2 describe-security-groups --group-ids "$1" \
157+ --query 'SecurityGroups[0].IpPermissions' --output json 2>/dev/null)"; then
158+ echo " WARN — could not audit ingress rules on $2 ($1)." >&2
159+ return 0
160+ fi
161+ # `|| echo` keeps a parse hiccup non-fatal — this audit must never abort a run.
162+ _SG_ID="$1" _SG_NAME="$2" _SG_PORT="$3" _SG_EXPECTED="$4" python3 -c "
163+import json, os, sys
164+perms = json.load(sys.stdin) or []
165+port, expected = int(os.environ[\"_SG_PORT\"]), os.environ[\"_SG_EXPECTED\"]
166+extras = []
167+for p in perms:
168+ proto, lo, hi = p.get(\"IpProtocol\"), p.get(\"FromPort\"), p.get(\"ToPort\")
169+ scope_ok = proto == \"tcp\" and lo == port and hi == port
170+ sources = (
171+ [r.get(\"CidrIp\", \"?\") for r in p.get(\"IpRanges\", [])]
172+ + [r.get(\"CidrIpv6\", \"?\") for r in p.get(\"Ipv6Ranges\", [])]
173+ + [r.get(\"GroupId\", \"?\") for r in p.get(\"UserIdGroupPairs\", [])]
174+ + [r.get(\"PrefixListId\", \"?\") for r in p.get(\"PrefixListIds\", [])]
175+ )
176+ extras += [(proto, lo, hi, s) for s in sources if not (scope_ok and s == expected)]
177+if extras:
178+ name, gid = os.environ[\"_SG_NAME\"], os.environ[\"_SG_ID\"]
179+ print(f\" WARN — security group {name} ({gid}) has ingress beyond the intended rule\", file=sys.stderr)
180+ print(f\" (tcp {port} from {expected}) — review it; remove anything you did not add deliberately:\", file=sys.stderr)
181+ for proto, lo, hi, src in extras:
182+ scope = \"all traffic\" if proto == \"-1\" else (f\"{proto} {lo}\" if lo == hi else f\"{proto} {lo}-{hi}\")
183+ print(f\" {scope} from {src}\", file=sys.stderr)
184+" <<<"${perms}" || echo " WARN — could not audit ingress rules on $2 ($1)." >&2
185+}
186+
187+secret_arn() {
188+ aws secretsmanager describe-secret --secret-id "$1" \
189+ --query ARN --output text 2>/dev/null || true
190+}
191+
192+# Existence check that fails closed: 0 = exists, 1 = definitively absent
193+# (ResourceNotFoundException), anything else ABORTS the run. Gating on a bare
194+# exit status would let a transient failure (throttle, expired token, network
195+# blip) masquerade as "secret missing" — and the missing-secret branches below
196+# do destructive work (the §3 self-heal resets the DB password), so they must
197+# run only on a definitive not-found.
198+secret_exists() { # <secret-id>
199+ local out
200+ if out="$(aws secretsmanager describe-secret --secret-id "$1" 2>&1 >/dev/null)"; then
201+ return 0
202+ elif grep -q 'ResourceNotFoundException' <<<"${out}"; then
203+ return 1
204+ else
205+ echo "ERROR: could not determine whether secret $1 exists (transient AWS error?):" >&2
206+ printf '%s\n' "${out}" >&2
207+ echo " Refusing to guess — re-run once the call succeeds." >&2
208+ exit 1
209+ fi
210+}
211+
212+# Secret values must never appear on a process argv (argv is world-readable
213+# via /proc and routinely recorded by EDR/auditd), so every aws call that
214+# carries one takes it via --cli-input-json file://<0600 temp file> instead —
215+# explicit flags on the same command line override/merge with the JSON, so
216+# only the secret parameter needs to live in the file. secret_json writes
217+# {"<Key>": "<value>"} to a fresh temp file and returns the path in the named
218+# variable (printf -v, not command substitution — a subshell would lose the
219+# SECRET_TMP_FILES bookkeeping below): the value crosses into python3 via the
220+# environment (never argv) and json.dumps escapes it, so any characters
221+# survive. Callers rm -f the file as soon as the aws call returns; the EXIT
222+# trap sweeps whatever an aborted run leaves.
223+SECRET_TMP_FILES=()
224+cleanup_secret_tmp() { rm -f "${SECRET_TMP_FILES[@]+"${SECRET_TMP_FILES[@]}"}"; }
225+trap cleanup_secret_tmp EXIT
226+secret_json() { # secret_json <outvar> <JsonKey> <value> -> path in <outvar>
227+ local file
228+ file="$(mktemp)" # mktemp creates 0600
229+ chmod 600 "${file}" # belt and braces if TMPDIR overrides umask semantics
230+ SECRET_TMP_FILES+=("${file}")
231+ _JSON_KEY="$2" _JSON_VALUE="$3" python3 -c \
232+ 'import json, os; print(json.dumps({os.environ["_JSON_KEY"]: os.environ["_JSON_VALUE"]}))' \
233+ > "${file}"
234+ printf -v "$1" '%s' "${file}"
235+}
236+
237+for required in AWS_REGION ACCOUNT_ID VPC_ID PRIVATE_SUBNETS CORP_CIDR VERSION; do
238+ if [[ -z "${!required}" ]]; then
239+ echo "ERROR: ${required} is not set." >&2
240+ case "${required}" in
241+ AWS_REGION) echo " Set it to a region where Bedrock serves the Claude models you need, e.g. export AWS_REGION=us-east-1" >&2 ;;
242+ ACCOUNT_ID) echo " Could not resolve it from STS — is the AWS CLI authenticated? (aws sts get-caller-identity)" >&2 ;;
243+ VPC_ID) echo " Set it to the VPC from the prerequisites, e.g. export VPC_ID=vpc-..." >&2 ;;
244+ PRIVATE_SUBNETS) echo " Set it to two+ private subnet IDs in different AZs, e.g. export PRIVATE_SUBNETS='subnet-a subnet-b'" >&2 ;;
245+ CORP_CIDR) echo " Set it to your corporate network CIDR (the ALB's :443 ingress source), e.g. export CORP_CIDR=10.0.0.0/8" >&2 ;;
246+ VERSION) echo " Set it to the gateway release version — it tags the image you build and push, e.g. export VERSION=<version>" >&2 ;;
247+ esac
248+ exit 1
249+ fi
250+done
251+
252+# The walkthrough (and this bundle) is scoped to commercial US regions: the
253+# task role's Bedrock policy (§2) and the gateway's built-in model catalog
254+# both use the us.anthropic.* geo-prefixed cross-region inference profiles,
255+# which only exist in the commercial US regions — an explicit list, not a
256+# `us-*` prefix match, because GovCloud (us-gov-*) and ISO (us-iso-*) regions
257+# share the prefix but live in different AWS partitions where those profiles
258+# and this bundle's arn:aws: ARNs are wrong. Anywhere else the deploy
259+# provisions fine and then every model call fails. Other-region deploys must
260+# pin region-appropriate inference profiles via a models: block in
261+# gateway.yaml (see the config reference:
262+# https://code.claude.com/docs/en/claude-apps-gateway-config) and adjust the
263+# inference-profile ARN prefix in bedrock-invoke.iam.json below — set
264+# ALLOW_NON_US_REGION=1 once that's done to proceed.
265+case "${AWS_REGION}" in
266+ us-east-1|us-east-2|us-west-1|us-west-2) ;;
267+ *)
268+ if [[ "${ALLOW_NON_US_REGION:-0}" != "1" ]]; then
269+ echo "ERROR: AWS_REGION=${AWS_REGION} is not a commercial US region, but this bundle's IAM policy" >&2
270+ echo " and model IDs use the US-geo (us.anthropic.*) cross-region inference profiles" >&2
271+ echo " (GovCloud/ISO regions are different partitions — the profiles and arn:aws: ARNs" >&2
272+ echo " here do not exist there)." >&2
273+ echo " Either deploy to us-east-1/us-east-2/us-west-1/us-west-2, or pin region-appropriate" >&2
274+ echo " inference profiles in a models: block in gateway.yaml" >&2
275+ echo " (https://code.claude.com/docs/en/claude-apps-gateway-config), adjust the" >&2
276+ echo " inference-profile ARN in the bedrock-invoke policy, and re-run with" >&2
277+ echo " ALLOW_NON_US_REGION=1." >&2
278+ exit 1
279+ fi
280+ ;;
281+esac
282+
283+if ! command -v python3 >/dev/null 2>&1; then
284+ echo "ERROR: python3 is required (it JSON-escapes secret values for --cli-input-json; the AWS CLI itself ships on Python)." >&2
285+ exit 1
286+fi
287+
288+# shellcheck disable=SC2086 # PRIVATE_SUBNETS is intentionally word-split everywhere below
289+set -- ${PRIVATE_SUBNETS}
290+if (( $# < 2 )); then
291+ echo "ERROR: PRIVATE_SUBNETS must list at least two subnets in different AZs (the internal ALB requires two)." >&2
292+ exit 1
293+fi
294+# Normalize whatever whitespace (spaces, tabs, newlines) separates the list —
295+# `set --` above word-split on IFS, so join those same words with commas
296+# rather than only converting single spaces.
297+SUBNETS_CSV="$(printf '%s,' "$@")"; SUBNETS_CSV="${SUBNETS_CSV%,}"
298+
299+log "Account: ${ACCOUNT_ID} Region: ${AWS_REGION} VPC: ${VPC_ID}"
300+
301+# ---- 1 Security groups -----------------------------------------------------
302+# Three groups chain the traffic path (walkthrough §1): corp network -> ALB :443,
303+# ALB -> gateway :8080, gateway -> Postgres :5432. Nothing else is reachable.
304+log "Creating security groups (§1)"
305+ALB_SG="$(sg_id "${ALB_SG_NAME}")"
306+if [[ "${ALB_SG}" != "None" ]]; then
307+ skip "security group ${ALB_SG_NAME} (${ALB_SG})"
308+else
309+ ALB_SG="$(aws ec2 create-security-group --group-name "${ALB_SG_NAME}" \
310+ --description "Claude gateway ALB" --vpc-id "${VPC_ID}" \
311+ --query GroupId --output text)"
312+fi
313+
314+GW_SG="$(sg_id "${GW_SG_NAME}")"
315+if [[ "${GW_SG}" != "None" ]]; then
316+ skip "security group ${GW_SG_NAME} (${GW_SG})"
317+else
318+ GW_SG="$(aws ec2 create-security-group --group-name "${GW_SG_NAME}" \
319+ --description "Claude gateway service" --vpc-id "${VPC_ID}" \
320+ --query GroupId --output text)"
321+fi
322+
323+DB_SG="$(sg_id "${DB_SG_NAME}")"
324+if [[ "${DB_SG}" != "None" ]]; then
325+ skip "security group ${DB_SG_NAME} (${DB_SG})"
326+else
327+ DB_SG="$(aws ec2 create-security-group --group-name "${DB_SG_NAME}" \
328+ --description "Claude gateway Postgres" --vpc-id "${VPC_ID}" \
329+ --query GroupId --output text)"
330+fi
331+
332+authorize_ingress --group-id "${ALB_SG}" --protocol tcp --port 443 --cidr "${CORP_CIDR}"
333+authorize_ingress --group-id "${GW_SG}" --protocol tcp --port 8080 --source-group "${ALB_SG}"
334+authorize_ingress --group-id "${DB_SG}" --protocol tcp --port 5432 --source-group "${GW_SG}"
335+
336+# Flag any ingress beyond the three rules above (pre-existing groups may carry more).
337+warn_unexpected_ingress "${ALB_SG}" "${ALB_SG_NAME}" 443 "${CORP_CIDR}"
338+warn_unexpected_ingress "${GW_SG}" "${GW_SG_NAME}" 8080 "${ALB_SG}"
339+warn_unexpected_ingress "${DB_SG}" "${DB_SG_NAME}" 5432 "${GW_SG}"
340+
341+# ---- 2 IAM roles ------------------------------------------------------------
342+# Task role: the gateway's runtime identity — its ONLY permission is invoking
343+# Claude models on Bedrock (the upstream's `auth: {}` resolves to this role via
344+# the AWS default credential chain). The policy must cover both the cross-region
345+# inference-profile ARNs and the underlying foundation-model ARNs.
346+# Execution role: the ECS agent's identity — pulls the image from ECR and
347+# injects the Secrets Manager values; the gateway never uses it.
348+log "Creating IAM roles ${TASK_ROLE} + ${EXEC_ROLE} (§2)"
349+cat > ecs-trust.iam.json <<'EOF'
350+{
351+ "Version": "2012-10-17",
352+ "Statement": [{
353+ "Effect": "Allow",
354+ "Principal": { "Service": "ecs-tasks.amazonaws.com" },
355+ "Action": "sts:AssumeRole"
356+ }]
357+}
358+EOF
359+cat > bedrock-invoke.iam.json <<EOF
360+{
361+ "Version": "2012-10-17",
362+ "Statement": [{
363+ "Effect": "Allow",
364+ "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
365+ "Resource": [
366+ "arn:aws:bedrock:${AWS_REGION}:${ACCOUNT_ID}:inference-profile/us.anthropic.*",
367+ "arn:aws:bedrock:*::foundation-model/anthropic.*"
368+ ]
369+ }]
370+}
371+EOF
372+# One ARN per secret (never a bare gateway-* wildcard, which would also match
373+# unrelated secrets in a shared account). The trailing -?????? matches exactly
374+# the random 6-character suffix Secrets Manager appends to every secret's ARN
375+# (AWS's documented pattern; a trailing -* would be a plain prefix glob and
376+# also match longer names like ${SECRET_NAME}-prod) — the exact ARNs aren't
377+# knowable here because the role is created before the secrets are.
378+cat > secrets-read.iam.json <<EOF
379+{
380+ "Version": "2012-10-17",
381+ "Statement": [{
382+ "Effect": "Allow",
383+ "Action": "secretsmanager:GetSecretValue",
384+ "Resource": [
385+ "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${JWT_SECRET_NAME}-??????",
386+ "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${OIDC_SECRET_NAME}-??????",
387+ "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${SECRET_NAME}-??????"
388+ ]
389+ }]
390+}
391+EOF
392+
393+if aws iam get-role --role-name "${TASK_ROLE}" >/dev/null 2>&1; then
394+ skip "role ${TASK_ROLE}"
395+else
396+ aws iam create-role --role-name "${TASK_ROLE}" \
397+ --assume-role-policy-document file://ecs-trust.iam.json >/dev/null
398+fi
399+# put-role-policy is an upsert — safe to re-run (it also picks up region changes).
400+aws iam put-role-policy --role-name "${TASK_ROLE}" \
401+ --policy-name bedrock-invoke --policy-document file://bedrock-invoke.iam.json
402+
403+if aws iam get-role --role-name "${EXEC_ROLE}" >/dev/null 2>&1; then
404+ skip "role ${EXEC_ROLE}"
405+else
406+ aws iam create-role --role-name "${EXEC_ROLE}" \
407+ --assume-role-policy-document file://ecs-trust.iam.json >/dev/null
408+fi
409+# attach-role-policy is idempotent (re-attaching is a no-op).
410+aws iam attach-role-policy --role-name "${EXEC_ROLE}" \
411+ --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
412+aws iam put-role-policy --role-name "${EXEC_ROLE}" \
413+ --policy-name read-gateway-secrets --policy-document file://secrets-read.iam.json
414+
415+echo " NOTE: Bedrock model access is console-only — enable it for the Claude models"
416+echo " you need (Bedrock console -> Model access), and submit the one-time use"
417+echo " case form for the account. Cross-region inference profiles"
418+echo " (us.anthropic.*) need access in EACH region the profile spans."
419+
420+# ---- 6 Build & push image to Amazon ECR (config baked in — §6 + §4) ---------
421+log "Ensuring ECR repository and image (§6)"
422+if aws ecr describe-repositories --repository-names "${ECR_REPO}" >/dev/null 2>&1; then
423+ skip "ECR repository ${ECR_REPO}"
424+ # Integrity-critical settings: converge on re-runs so a pre-existing MUTABLE repo can't slip through.
425+ aws ecr put-image-tag-mutability --repository-name "${ECR_REPO}" \
426+ --image-tag-mutability IMMUTABLE >/dev/null
427+ aws ecr put-image-scanning-configuration --repository-name "${ECR_REPO}" \
428+ --image-scanning-configuration scanOnPush=true >/dev/null
429+else
430+ # IMMUTABLE tags + scan-on-push: the ECS service pulls whatever this repo
431+ # serves under the deployed tag, so a pushed tag must never be silently
432+ # re-pointed. For production, also restrict push rights on this repo to your
433+ # CI / image-promotion pipeline rather than operator credentials — this
434+ # walkthrough pushes directly for simplicity.
435+ aws ecr create-repository --repository-name "${ECR_REPO}" \
436+ --image-tag-mutability IMMUTABLE \
437+ --image-scanning-configuration scanOnPush=true >/dev/null
438+fi
439+
440+# The config is baked into the image, so the build is gated the way the GCP
441+# example gates its config-secret publish: gateway.yaml must exist and be fully
442+# filled in (REPLACE_ME checked on non-comment lines so commented examples and
443+# the file's header don't trip the guard). The tag carries a hash of the config
444+# so an edit produces a NEW tag (required by tag immutability) and a re-run
445+# rebuilds automatically.
446+IMAGE=""
447+if [[ ! -f "${GATEWAY_YAML}" ]]; then
448+ echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§4)."
449+elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
450+ echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
451+ grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
452+ echo " Fill them in, then re-run to build the image (the config is baked in)."
453+else
454+ CONFIG_SHA="$(sha_of "${GATEWAY_YAML}" | cut -c1-8)"
455+ IMAGE_TAG="${IMAGE_TAG:-${VERSION}-cfg${CONFIG_SHA}}"
456+ IMAGE="${REGISTRY}/${ECR_REPO}:${IMAGE_TAG}"
457+
458+ # Image is the expensive, already-done step: skip the build+push entirely if
459+ # the tag already exists in the registry.
460+ if aws ecr describe-images --repository-name "${ECR_REPO}" \
461+ --image-ids "imageTag=${IMAGE_TAG}" >/dev/null 2>&1; then
462+ skip "image ${IMAGE}"
463+ else
464+ # When the expected checksum is known, verify a PRE-EXISTING binary too:
465+ # the [[ ! -f ]] guard below otherwise trusts whatever is on disk, so a
466+ # stale binary from an earlier VERSION (or a tampered one) would be baked
467+ # into the image silently. On mismatch, set it aside (never delete — the
468+ # mismatch may be a typo'd DIST_SHA256, not a bad binary) and fall through
469+ # to the fail-closed download path. Without DIST_SHA256 the operator-
470+ # provided-binary flow is unchanged — no checksum was declared, so none is
471+ # checked.
472+ QUARANTINED_SHA=""
473+ if [[ -n "${DIST_SHA256}" && -f "${CLAUDE_BINARY}" ]]; then
474+ existing_sha="$(sha_of "${CLAUDE_BINARY}")"
475+ if [[ "${existing_sha}" != "${DIST_SHA256}" ]]; then
476+ log "Existing ${CLAUDE_BINARY} sha256 ${existing_sha} does not match DIST_SHA256 — setting it aside as ${CLAUDE_BINARY}.bad"
477+ mv -f "${CLAUDE_BINARY}" "${CLAUDE_BINARY}.bad"
478+ QUARANTINED_SHA="${existing_sha}"
479+ fi
480+ fi
481+ if [[ ! -f "${CLAUDE_BINARY}" ]]; then
482+ if [[ -n "${DIST_URL}" ]]; then
483+ # Fail closed: never download an executable we can't verify.
484+ if [[ -z "${DIST_SHA256}" ]]; then
485+ echo "ERROR: DIST_SHA256 must be set when DIST_URL is used — refusing to download an unverified binary." >&2
486+ echo " Set DIST_SHA256 to the expected sha256 of the binary at DIST_URL, obtained out-of-band:" >&2
487+ echo " for standard-release binaries, from the release's GPG-signed manifest.json (verify the" >&2
488+ echo " manifest signature first — see code.claude.com/docs/en/setup#binary-integrity-and-code-signing);" >&2
489+ echo " otherwise from the channel that published the download link, never from the download server." >&2
490+ exit 1
491+ fi
492+ log "Downloading gateway binary from ${DIST_URL}"
493+ # Download to a temp path and only mv into place after the checksum
494+ # verifies, so an interrupted download can't leave a partial CLAUDE_BINARY
495+ # that the [[ ! -f ]] guard above would skip — and silently push — on re-run.
496+ # Refuse plaintext/protocol-downgrade; only follow HTTPS redirects.
497+ dl_tmp="${CLAUDE_BINARY}.download"
498+ rm -f "${dl_tmp}"
499+ curl_https -fL -o "${dl_tmp}" "${DIST_URL}"
500+ actual_sha="$(sha_of "${dl_tmp}")"
501+ if [[ "${actual_sha}" != "${DIST_SHA256}" ]]; then
502+ echo "ERROR: checksum mismatch for ${dl_tmp} (expected ${DIST_SHA256}, got ${actual_sha}) — refusing to build." >&2
503+ rm -f "${dl_tmp}"
504+ exit 1
505+ fi
506+ log "Verified binary sha256 ${actual_sha}"
507+ chmod +x "${dl_tmp}"
508+ mv -f "${dl_tmp}" "${CLAUDE_BINARY}"
509+ else
510+ echo "ERROR: build binary not found at ${CLAUDE_BINARY} and DIST_URL is not set." >&2
511+ if [[ -n "${QUARANTINED_SHA}" ]]; then
512+ echo " The binary that WAS there had sha256 ${QUARANTINED_SHA}, which does not match" >&2
513+ echo " DIST_SHA256=${DIST_SHA256} — it was preserved as ${CLAUDE_BINARY}.bad." >&2
514+ echo " If DIST_SHA256 was a typo, fix it and move the file back:" >&2
515+ echo " mv '${CLAUDE_BINARY}.bad' '${CLAUDE_BINARY}'" >&2
516+ echo " Otherwise treat that file as untrusted and obtain a verified binary." >&2
517+ fi
518+ echo " Provide the prebuilt linux-x64 Claude Code release binary at that path" >&2
519+ echo " or set DIST_URL to its download URL (see the walkthrough, §6)." >&2
520+ exit 1
521+ fi
522+ fi
523+ # The RDS CA bundle is baked into the image as the trust anchor for the
524+ # connection string's sslmode=verify-full (§3/§5). Fail closed: no bundle,
525+ # no build. AWS rotates the bundle, so no checksum is pinned (see the
526+ # RDS_CA_BUNDLE_URL comment up top); the sanity check below catches an
527+ # error page or truncated download.
528+ if [[ ! -f "${RDS_CA_BUNDLE}" ]]; then
529+ log "Downloading RDS CA bundle from ${RDS_CA_BUNDLE_URL}"
530+ curl_https -fL -o "${RDS_CA_BUNDLE}" "${RDS_CA_BUNDLE_URL}"
531+ fi
532+ if ! grep -q 'BEGIN CERTIFICATE' "${RDS_CA_BUNDLE}" \
533+ || (( "$(wc -c < "${RDS_CA_BUNDLE}")" < 10000 )); then
534+ echo "ERROR: ${RDS_CA_BUNDLE} does not look like the RDS CA bundle (missing PEM blocks or implausibly small) — refusing to build." >&2
535+ echo " Delete it and re-run to re-download, or place the bundle from ${RDS_CA_BUNDLE_URL} there yourself." >&2
536+ exit 1
537+ fi
538+ log "Building and pushing ${IMAGE}"
539+ aws ecr get-login-password --region "${AWS_REGION}" \
540+ | docker login --username AWS --password-stdin "${REGISTRY}"
541+ # The task definition below runs linux/amd64 (cpuArchitecture X86_64);
542+ # --platform forces it (e.g. when building on an Apple Silicon Mac), and
543+ # --provenance=false keeps buildx from wrapping the result in an OCI image
544+ # index that some pullers reject. For Fargate on ARM64 (Graviton), build
545+ # linux/arm64 with the linux-arm64 binary and set cpuArchitecture to ARM64.
546+ docker build --platform=linux/amd64 --provenance=false \
547+ -f "${DOCKERFILE}" \
548+ --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" \
549+ --build-arg GATEWAY_CONFIG="${GATEWAY_YAML}" \
550+ --build-arg RDS_CA_BUNDLE="${RDS_CA_BUNDLE}" \
551+ -t "${IMAGE}" .
552+ docker push "${IMAGE}"
553+ fi
554+fi
555+
556+# ---- 3 RDS for PostgreSQL (private subnets, no public address) --------------
557+log "Creating DB subnet group ${DB_SUBNET_GROUP} (§3)"
558+if aws rds describe-db-subnet-groups --db-subnet-group-name "${DB_SUBNET_GROUP}" >/dev/null 2>&1; then
559+ skip "DB subnet group ${DB_SUBNET_GROUP}"
560+else
561+ # shellcheck disable=SC2086 # subnet IDs are separate arguments by design
562+ aws rds create-db-subnet-group --db-subnet-group-name "${DB_SUBNET_GROUP}" \
563+ --db-subnet-group-description "Claude gateway" --subnet-ids ${PRIVATE_SUBNETS} >/dev/null
564+fi
565+
566+# Parameter group with rds.force_ssl=1: the server side of TLS enforcement —
567+# the client side is sslmode=verify-full in the connection string (§5). The
568+# family must match the engine major version, so it derives from the same
569+# DB_ENGINE_VERSION that create-db-instance pins below.
570+log "Ensuring DB parameter group ${DB_PARAM_GROUP} (rds.force_ssl=1)"
571+PG_FAMILY="postgres${DB_ENGINE_VERSION%%.*}"
572+if aws rds describe-db-parameter-groups --db-parameter-group-name "${DB_PARAM_GROUP}" >/dev/null 2>&1; then
573+ skip "DB parameter group ${DB_PARAM_GROUP}"
574+else
575+ aws rds create-db-parameter-group --db-parameter-group-name "${DB_PARAM_GROUP}" \
576+ --db-parameter-group-family "${PG_FAMILY}" \
577+ --description "Claude gateway - require TLS on every connection" >/dev/null
578+fi
579+# modify-db-parameter-group is an upsert — applied every run so a pre-existing
580+# group converges too. rds.force_ssl is dynamic; no reboot needed.
581+aws rds modify-db-parameter-group --db-parameter-group-name "${DB_PARAM_GROUP}" \
582+ --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=immediate" >/dev/null
583+
584+# hex (not base64) keeps the password URL-safe for the connection string below.
585+# The password reaches every aws call via --cli-input-json (never argv — see
586+# the secret_json helper); explicit flags merge with (and would override) the
587+# JSON, so only the password lives in the temp file.
588+log "Creating RDS instance ${DB_INSTANCE} (private subnets, --no-publicly-accessible)"
589+DB_PASSWORD=""
590+DB_POSTURE="$(aws rds describe-db-instances --db-instance-identifier "${DB_INSTANCE}" \
591+ --query 'DBInstances[0].[PubliclyAccessible,StorageEncrypted]' --output text 2>/dev/null || true)"
592+if [[ -n "${DB_POSTURE}" ]]; then
593+ # Name-based reuse: a pre-existing instance may not carry the posture this
594+ # script would have created it with. Non-fatal (the operator may be migrating
595+ # an existing DB on purpose), but drift from the guide's baseline must be seen.
596+ read -r DB_PUBLIC DB_ENCRYPTED <<<"${DB_POSTURE}"
597+ if [[ "${DB_PUBLIC}" == "True" ]]; then
598+ echo " WARN — RDS instance ${DB_INSTANCE} is PubliclyAccessible; this script would have" >&2
599+ echo " created it with --no-publicly-accessible. Fix: aws rds modify-db-instance" >&2
600+ echo " --db-instance-identifier ${DB_INSTANCE} --no-publicly-accessible --apply-immediately" >&2
601+ fi
602+ if [[ "${DB_ENCRYPTED}" == "False" ]]; then
603+ echo " WARN — RDS instance ${DB_INSTANCE} has StorageEncrypted=false; this script would" >&2
604+ echo " have created it with --storage-encrypted (encryption cannot be enabled in" >&2
605+ echo " place — restore an encrypted snapshot copy to migrate)." >&2
606+ fi
607+ if secret_exists "${SECRET_NAME}"; then
608+ skip "instance ${DB_INSTANCE} (password unchanged; secret not rewritten)"
609+ else
610+ # Self-heal: a previous run died after creating the instance but before
611+ # writing the connection-string secret, losing the only copy of the
612+ # password. The secret is the password's only consumer, so resetting it is
613+ # safe and keeps re-runs able to recover from any partial state.
614+ # secret_exists (not a bare exit-status check) gates this: only a
615+ # definitive ResourceNotFoundException may trigger a password reset.
616+ # ORDERING INVARIANT: the secret write (§5 below) is the heal's commit
617+ # point — everything that can fail must happen BEFORE it, so a crash at
618+ # any point leaves the secret still missing and the next run simply
619+ # repeats the heal. Writing the secret first would invert that: a crash
620+ # between secret write and modify-db-instance would leave an existing
621+ # secret whose password the DB never received, and every later run would
622+ # skip the heal while the gateway can't connect.
623+ # NOTE: the parameter group is attached on create only — an instance that
624+ # predates it keeps its current group (attach via modify-db-instance
625+ # --db-parameter-group-name yourself if you want force_ssl retrofitted).
626+ log "Instance ${DB_INSTANCE} exists but secret ${SECRET_NAME} is missing — resetting password"
627+ DB_PASSWORD="$(openssl rand -hex 24)"
628+ pw_json=""; secret_json pw_json MasterUserPassword "${DB_PASSWORD}"
629+ aws rds modify-db-instance --db-instance-identifier "${DB_INSTANCE}" \
630+ --cli-input-json "file://${pw_json}" --apply-immediately >/dev/null
631+ rm -f "${pw_json}"
632+ fi
633+else
634+ DB_PASSWORD="$(openssl rand -hex 24)"
635+ pw_json=""; secret_json pw_json MasterUserPassword "${DB_PASSWORD}"
636+ aws rds create-db-instance --db-instance-identifier "${DB_INSTANCE}" \
637+ --engine postgres --engine-version "${DB_ENGINE_VERSION}" \
638+ --db-instance-class "${DB_CLASS}" \
639+ --allocated-storage "${DB_STORAGE_GB}" --db-name "${DB_NAME}" \
640+ --master-username "${DB_USER}" --cli-input-json "file://${pw_json}" \
641+ --db-subnet-group-name "${DB_SUBNET_GROUP}" \
642+ --db-parameter-group-name "${DB_PARAM_GROUP}" \
643+ --vpc-security-group-ids "${DB_SG}" \
644+ --no-publicly-accessible \
645+ --storage-encrypted >/dev/null
646+ rm -f "${pw_json}"
647+fi
648+
649+log "Waiting for ${DB_INSTANCE} to become available (first creation takes ~10 min)"
650+aws rds wait db-instance-available --db-instance-identifier "${DB_INSTANCE}"
651+DB_HOST="$(aws rds describe-db-instances --db-instance-identifier "${DB_INSTANCE}" \
652+ --query 'DBInstances[0].Endpoint.Address' --output text)"
653+
654+# ---- 5 Connection string + JWT secret -> Secrets Manager --------------------
655+# No per-secret IAM grants are needed: the execution role's read-gateway-secrets
656+# policy (§2) names each of the three secrets by its ARN prefix.
657+# Secret values go to aws via --cli-input-json temp files, never argv.
658+if [[ -n "${DB_PASSWORD}" ]]; then
659+ # RDS private endpoint (guide §3); the gateway connects directly over the
660+ # VPC — the DB security group only admits ${GW_SG_NAME}.
661+ # sslmode=verify-full: the gateway's driver honors sslmode from the URL and
662+ # verifies the RDS certificate chain AND hostname against the CA bundle the
663+ # image trusts via NODE_EXTRA_CA_CERTS (see the Dockerfile). Do NOT add a
664+ # libpq-style `sslrootcert=` query param — the driver doesn't read it and
665+ # forwards it to Postgres as a startup parameter, which the server rejects.
666+ CONN="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}?sslmode=verify-full"
667+ log "Storing connection string in Secrets Manager secret ${SECRET_NAME} (§5)"
668+ conn_json=""; secret_json conn_json SecretString "${CONN}"
669+ if secret_exists "${SECRET_NAME}"; then
670+ aws secretsmanager put-secret-value --secret-id "${SECRET_NAME}" \
671+ --cli-input-json "file://${conn_json}" >/dev/null
672+ else
673+ aws secretsmanager create-secret --name "${SECRET_NAME}" \
674+ --cli-input-json "file://${conn_json}" >/dev/null
675+ fi
676+ rm -f "${conn_json}"
677+else
678+ log "Skipping postgres-url secret write (instance already existed, password not available this run)"
679+fi
680+
681+# JWT signing secret — generated once (re-runs do NOT rotate it).
682+log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
683+if secret_exists "${JWT_SECRET_NAME}"; then
684+ skip "secret ${JWT_SECRET_NAME}"
685+else
686+ jwt_json=""; secret_json jwt_json SecretString "$(openssl rand -base64 32)"
687+ aws secretsmanager create-secret --name "${JWT_SECRET_NAME}" \
688+ --cli-input-json "file://${jwt_json}" >/dev/null
689+ rm -f "${jwt_json}"
690+fi
691+
692+# OIDC client secret — operator-created (the script can't generate it; it comes
693+# from the Okta OIDC web application). Checked here so the deploy step below can
694+# gate on it with a clear message instead of a raw ECS secret-injection failure.
695+OIDC_ARN="$(secret_arn "${OIDC_SECRET_NAME}")"
696+
697+# ---- 7 ECS Fargate service + internal ALB ----------------------------------
698+# Self-gating: deploy only once its inputs exist (image pushed — i.e.
699+# gateway.yaml was filled in — plus the operator-provided OIDC client secret
700+# and the ACM certificate for the internal hostname). On a first run these are
701+# usually missing and it cleanly skips.
702+ALB_DNS=""
703+missing=""
704+[[ -n "${IMAGE}" ]] || missing="${missing} image(fill ${GATEWAY_YAML})"
705+[[ -n "${OIDC_ARN}" ]] || missing="${missing} ${OIDC_SECRET_NAME}"
706+[[ -n "${ACM_CERT_ARN}" ]] || missing="${missing} ACM_CERT_ARN"
707+SECRET_ARN="$(secret_arn "${SECRET_NAME}")"
708+JWT_ARN="$(secret_arn "${JWT_SECRET_NAME}")"
709+[[ -n "${SECRET_ARN}" ]] || missing="${missing} ${SECRET_NAME}"
710+[[ -n "${JWT_ARN}" ]] || missing="${missing} ${JWT_SECRET_NAME}"
711+
712+if [[ "${DEPLOY}" != "1" ]]; then
713+ log "Skipping ECS/ALB deploy (DEPLOY=${DEPLOY}) (§7)"
714+elif [[ -n "${missing// }" ]]; then
715+ log "Skipping ECS/ALB deploy — missing input(s):${missing} (§7)"
716+ echo " Fill ${GATEWAY_YAML} and re-run to build the image; create ${OIDC_SECRET_NAME}"
717+ echo " from the Okta client secret; set ACM_CERT_ARN to the certificate for your"
718+ echo " internal gateway hostname. Then re-run to deploy."
719+else
720+ log "Creating ECS cluster ${CLUSTER} and log group ${LOG_GROUP} (§7)"
721+ if [[ "$(aws ecs describe-clusters --clusters "${CLUSTER}" \
722+ --query 'clusters[0].status' --output text 2>/dev/null)" == "ACTIVE" ]]; then
723+ skip "cluster ${CLUSTER}"
724+ else
725+ aws ecs create-cluster --cluster-name "${CLUSTER}" >/dev/null
726+ fi
727+ # The gateway's stderr carries both its audit events and operational logs.
728+ if aws logs describe-log-groups --log-group-name-prefix "${LOG_GROUP}" \
729+ --query 'logGroups[?logGroupName==`'"${LOG_GROUP}"'`]' --output text 2>/dev/null | grep -q .; then
730+ skip "log group ${LOG_GROUP}"
731+ else
732+ aws logs create-log-group --log-group-name "${LOG_GROUP}"
733+ fi
734+ # Retention is a separate API (create-log-group has no retention flag) and an
735+ # upsert — applied every run so pre-existing groups converge too. Without it
736+ # the group keeps logs forever and cost grows unbounded.
737+ aws logs put-retention-policy --log-group-name "${LOG_GROUP}" \
738+ --retention-in-days "${LOG_RETENTION_DAYS}"
739+
740+ # Task definition: the task role carries the Bedrock permission; the
741+ # execution role injects the secrets. Registering is an append (a new
742+ # revision) — the service below always points at the latest.
743+ log "Registering task definition ${TASK_FAMILY}"
744+ taskdef_tmp="$(mktemp)"
745+ cat > "${taskdef_tmp}" <<EOF
746+{
747+ "family": "${TASK_FAMILY}",
748+ "networkMode": "awsvpc",
749+ "requiresCompatibilities": ["FARGATE"],
750+ "cpu": "${TASK_CPU}",
751+ "memory": "${TASK_MEMORY}",
752+ "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
753+ "executionRoleArn": "arn:aws:iam::${ACCOUNT_ID}:role/${EXEC_ROLE}",
754+ "taskRoleArn": "arn:aws:iam::${ACCOUNT_ID}:role/${TASK_ROLE}",
755+ "containerDefinitions": [
756+ {
757+ "name": "gateway",
758+ "image": "${IMAGE}",
759+ "portMappings": [{ "containerPort": 8080 }],
760+ "secrets": [
761+ { "name": "GATEWAY_JWT_SECRET", "valueFrom": "${JWT_ARN}" },
762+ { "name": "OIDC_CLIENT_SECRET", "valueFrom": "${OIDC_ARN}" },
763+ { "name": "GATEWAY_POSTGRES_URL", "valueFrom": "${SECRET_ARN}" }
764+ ],
765+ "logConfiguration": {
766+ "logDriver": "awslogs",
767+ "options": {
768+ "awslogs-group": "${LOG_GROUP}",
769+ "awslogs-region": "${AWS_REGION}",
770+ "awslogs-stream-prefix": "gateway"
771+ }
772+ }
773+ }
774+ ]
775+}
776+EOF
777+ aws ecs register-task-definition --cli-input-json "file://${taskdef_tmp}" >/dev/null
778+ rm -f "${taskdef_tmp}"
779+
780+ # Internal ALB. --ip-address-type ipv4: an internal dual-stack ALB publishes
781+ # public-range AAAA records, which the CLI's /login private-network check
782+ # rejects.
783+ log "Creating internal ALB ${ALB_NAME} + target group + HTTPS listener"
784+ read -r ALB_ARN ALB_SCHEME ALB_VPC ALB_IP_TYPE <<<"$(aws elbv2 describe-load-balancers --names "${ALB_NAME}" \
785+ --query 'LoadBalancers[0].[LoadBalancerArn,Scheme,VpcId,IpAddressType]' --output text 2>/dev/null || true)"
786+ if [[ -n "${ALB_ARN}" && "${ALB_ARN}" != "None" ]]; then
787+ # Reuse is by name, and scheme/VPC are immutable on an ALB — so posture is
788+ # asserted, fail-closed: attaching the gateway to an internet-facing or
789+ # wrong-VPC load balancer would change the exposure model, not just drift.
790+ if [[ "${ALB_SCHEME}" != "internal" || "${ALB_VPC}" != "${VPC_ID}" ]]; then
791+ echo "ERROR: load balancer ${ALB_NAME} exists but is not the internal ALB this script expects:" >&2
792+ echo " scheme=${ALB_SCHEME} (need internal), vpc=${ALB_VPC} (need ${VPC_ID})." >&2
793+ echo " Refusing to deploy the gateway behind it. Delete that load balancer, or set" >&2
794+ echo " ALB_NAME to an unused name, then re-run." >&2
795+ exit 1
796+ fi
797+ skip "load balancer ${ALB_NAME} (internal, ${ALB_VPC})"
798+ # ip-address-type IS mutable (unlike scheme/VPC) — converge a reused
799+ # dualstack ALB back to ipv4, matching the Terraform sibling: dual-stack
800+ # publishes public-range AAAA records that /login rejects (see above).
801+ if [[ "${ALB_IP_TYPE}" != "ipv4" ]]; then
802+ aws elbv2 set-ip-address-type --load-balancer-arn "${ALB_ARN}" \
803+ --ip-address-type ipv4 >/dev/null
804+ fi
805+ else
806+ # shellcheck disable=SC2086
807+ ALB_ARN="$(aws elbv2 create-load-balancer --name "${ALB_NAME}" \
808+ --scheme internal --type application --ip-address-type ipv4 \
809+ --subnets ${PRIVATE_SUBNETS} --security-groups "${ALB_SG}" \
810+ --query 'LoadBalancers[0].LoadBalancerArn' --output text)"
811+ fi
812+
813+ # The ALB closes a connection after 60 seconds with no data by default, which
814+ # cuts off streams during quiet periods (long prompt processing before the
815+ # first token, extended thinking). Attribute setting is idempotent.
816+ aws elbv2 modify-load-balancer-attributes --load-balancer-arn "${ALB_ARN}" \
817+ --attributes Key=idle_timeout.timeout_seconds,Value=3600 >/dev/null
818+
819+ read -r TG_ARN TG_VPC <<<"$(aws elbv2 describe-target-groups --names "${TG_NAME}" \
820+ --query 'TargetGroups[0].[TargetGroupArn,VpcId]' --output text 2>/dev/null || true)"
821+ if [[ -n "${TG_ARN}" && "${TG_ARN}" != "None" ]]; then
822+ skip "target group ${TG_NAME}"
823+ # VPC is immutable on a target group; a wrong-VPC one can't reach the tasks.
824+ if [[ "${TG_VPC}" != "${VPC_ID}" ]]; then
825+ echo " WARN — target group ${TG_NAME} is in ${TG_VPC}, not ${VPC_ID}; the service's tasks" >&2
826+ echo " will not become healthy behind it. Delete it or set TG_NAME to an unused" >&2
827+ echo " name, then re-run." >&2
828+ fi
829+ else
830+ # /readyz verifies the store is reachable, so a task that can't reach
831+ # Postgres never enters rotation (the gateway also serves liveness-only
832+ # /healthz — see the deploy guide's outage-behavior tradeoff).
833+ TG_ARN="$(aws elbv2 create-target-group --name "${TG_NAME}" \
834+ --protocol HTTP --port 8080 --vpc-id "${VPC_ID}" --target-type ip \
835+ --health-check-path /readyz \
836+ --query 'TargetGroups[0].TargetGroupArn' --output text)"
837+ fi
838+
839+ # Select the HTTPS:443 listener specifically — a reused ALB may carry other
840+ # listeners (say HTTP:80); those stay untouched, and the 443 listener is
841+ # still created when it's the one that's missing.
842+ # shellcheck disable=SC2016 # backticks are JMESPath literals, not expansion
843+ LISTENER_ARN="$(aws elbv2 describe-listeners --load-balancer-arn "${ALB_ARN}" \
844+ --query 'Listeners[?Port==`443`]|[0].ListenerArn' --output text 2>/dev/null || true)"
845+ if [[ -n "${LISTENER_ARN}" && "${LISTENER_ARN}" != "None" ]]; then
846+ skip "HTTPS:443 listener on ${ALB_NAME}"
847+ # Converge everything this script owns on pre-existing listeners
848+ # (modify-listener is an upsert): the TLS policy (so re-runs pick up an
849+ # ALB_SSL_POLICY change, and listeners created before this script pinned
850+ # one lose the legacy default), the certificate (so a changed ACM_CERT_ARN
851+ # — e.g. a renewal under a new ARN — is not silently ignored), and the
852+ # default action (so the listener always forwards to this target group).
853+ aws elbv2 modify-listener --listener-arn "${LISTENER_ARN}" \
854+ --ssl-policy "${ALB_SSL_POLICY}" \
855+ --certificates "CertificateArn=${ACM_CERT_ARN}" \
856+ --default-actions "Type=forward,TargetGroupArn=${TG_ARN}" >/dev/null
857+ else
858+ aws elbv2 create-listener --load-balancer-arn "${ALB_ARN}" \
859+ --protocol HTTPS --port 443 \
860+ --ssl-policy "${ALB_SSL_POLICY}" \
861+ --certificates "CertificateArn=${ACM_CERT_ARN}" \
862+ --default-actions "Type=forward,TargetGroupArn=${TG_ARN}" >/dev/null
863+ fi
864+
865+ # Service: created once, then rolled forward — a re-run points it at the
866+ # latest task-definition revision (which carries the current image tag, and
867+ # therefore the current gateway.yaml) and forces a new deployment.
868+ log "Creating/updating ECS service ${SERVICE} (Fargate, private subnets, no public IP)"
869+ svc_status="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \
870+ --query 'services[0].status' --output text 2>/dev/null || true)"
871+ if [[ "${svc_status}" == "ACTIVE" ]]; then
872+ aws ecs update-service --cluster "${CLUSTER}" --service "${SERVICE}" \
873+ --task-definition "${TASK_FAMILY}" --desired-count "${DESIRED_COUNT}" \
874+ --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
875+ --health-check-grace-period-seconds 60 \
876+ --force-new-deployment >/dev/null
877+ echo " service updated to the latest task-definition revision."
878+ else
879+ # All egress (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs) goes
880+ # through the NAT gateway — assignPublicIp stays DISABLED.
881+ # The deployment circuit breaker stops a rollout whose tasks keep failing
882+ # (bad image, unbootable config) and rolls back to the last steady state
883+ # instead of relaunching failing tasks forever. The health-check grace
884+ # period gives a cold task (image pull + store connect + first /readyz)
885+ # time before ECS counts it unhealthy — without it the circuit breaker can
886+ # declare the very first rollout failed (matches terraform/'s
887+ # health_check_grace_period_seconds).
888+ aws ecs create-service --cluster "${CLUSTER}" --service-name "${SERVICE}" \
889+ --task-definition "${TASK_FAMILY}" --desired-count "${DESIRED_COUNT}" \
890+ --launch-type FARGATE \
891+ --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
892+ --health-check-grace-period-seconds 60 \
893+ --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS_CSV}],securityGroups=[${GW_SG}],assignPublicIp=DISABLED}" \
894+ --load-balancers "targetGroupArn=${TG_ARN},containerName=gateway,containerPort=8080" >/dev/null
895+ fi
896+
897+ ALB_DNS="$(aws elbv2 describe-load-balancers --load-balancer-arns "${ALB_ARN}" \
898+ --query 'LoadBalancers[0].DNSName' --output text)"
899+ log "Internal ALB DNS: ${ALB_DNS}"
900+
901+ # Post-deploy smoke check: the ALB is internal (unreachable from this
902+ # machine), but target health is visible through the API — poll until the
903+ # /readyz health check passes. Non-fatal; a cold task needs a minute or two
904+ # (image pull + store connect).
905+ log "Smoke check: polling target health on ${TG_NAME} (health check: GET /readyz)"
906+ tg_state="unknown"
907+ for _ in $(seq 1 24); do
908+ tg_state="$(aws elbv2 describe-target-health --target-group-arn "${TG_ARN}" \
909+ --query 'TargetHealthDescriptions[0].TargetHealth.State' --output text 2>/dev/null || true)"
910+ [[ "${tg_state}" == "healthy" ]] && break
911+ sleep 10
912+ done
913+ if [[ "${tg_state}" == "healthy" ]]; then
914+ echo " OK — a gateway task is healthy behind the ALB (store reachable)."
915+ else
916+ echo " WARN — last target state: ${tg_state:-none}; the task may still be starting."
917+ echo " Check the service events and the gateway's logs:"
918+ echo " aws ecs describe-services --cluster ${CLUSTER} --services ${SERVICE} --query 'services[0].events[:5]'"
919+ echo " aws logs tail ${LOG_GROUP} --since 10m"
920+ fi
921+
922+ # public_url is baked into the image, so verify the operator's chosen
923+ # hostname is in place (the redirect URI and discovery doc derive from it).
924+ CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
925+ | head -1 \
926+ | sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
927+ || true)"
928+ CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
929+ CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
930+ echo " 1. In your Route 53 private hosted zone, alias the host of"
931+ echo " ${CFG_PUBLIC_URL:-<public_url>} to the ALB: ${ALB_DNS}"
932+ echo " (the ALB's own *.elb.amazonaws.com name can't carry your ACM certificate)."
933+ echo " 2. Register this redirect URI on the Okta OIDC web app: ${CFG_PUBLIC_URL:-<public_url>}/oauth/callback"
934+ echo " 3. Verify from inside your corporate network:"
935+ echo " curl -s ${CFG_PUBLIC_URL:-<public_url>}/.well-known/oauth-authorization-server"
936+fi
937+
938+# ---- summary ----------------------------------------------------------------
939+cat <<EOF
940+
941+==> Done.
942+
943+ Security groups ${ALB_SG_NAME}=${ALB_SG} ${GW_SG_NAME}=${GW_SG} ${DB_SG_NAME}=${DB_SG}
944+ IAM roles ${TASK_ROLE} (bedrock-invoke), ${EXEC_ROLE} (pull + secrets)
945+ Image ${IMAGE:-(not built yet — fill ${GATEWAY_YAML})}
946+ RDS instance ${DB_INSTANCE} -> ${DB_HOST}
947+ Database / user ${DB_NAME} / ${DB_USER}
948+ Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${OIDC_SECRET_NAME}$( [[ -n "${OIDC_ARN}" ]] || printf ' (MISSING — create it)' )
949+ ECS service ${CLUSTER}/${SERVICE} behind ${ALB_DNS:-(not deployed yet)}
950+
951+Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-aws):
952+ - Create the one operator-provided secret (from the Okta OIDC web app). Put the
953+ client secret in a 0600 file first — passing it as a literal argument would
954+ leave it readable in the process table and in audit/EDR logs:
955+ aws secretsmanager create-secret --name ${OIDC_SECRET_NAME} \\
956+ --secret-string file:///path/to/okta-client-secret.txt
957+ - Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh builds the
958+ image (config baked in) and deploys once the secret and ACM_CERT_ARN exist.
959+ - Enable Bedrock model access in the console for the Claude models you need (per
960+ region the us.anthropic.* profiles span) and submit the one-time use case form.
961+ - Alias your internal hostname (gateway.yaml public_url) to the ALB in a Route 53
962+ private hosted zone, and register <public_url>/oauth/callback on the Okta app.
963+ - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
964+EOF
@@ -1,0 +1,19 @@
1+# Never commit state (contains secrets) or local var files
2+*.tfstate
3+*.tfstate.*
4+.terraform/
5+terraform.tfvars
6+*.auto.tfvars
7+crash.log
8+
9+# The lock file holds no secrets. It's ignored here so consumers who copy this
10+# example into their own repo generate (and commit) their own platform-complete
11+# lock at first init — committing one from this repo would carry only one
12+# platform's provider hashes. In your copy, drop this line and commit the lock
13+# produced by:
14+# terraform providers lock -platform=linux_amd64 -platform=linux_arm64 \
15+# -platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64
16+# versions.tf pins by range only, so without a committed lock the registry
17+# serves the newest in-range build; a platform-complete lock gives hash
18+# continuity across machines/CI and makes provider upgrades reviewable diffs.
19+.terraform.lock.hcl
@@ -1,0 +1,182 @@
1+# Claude apps gateway — Terraform (ECS Fargate)
2+
3+Terraform equivalent of `../setup.sh`. Lets end-users provision and manage
4+the gateway with `terraform apply`. Covers the same scope ([walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-aws) §1–7,
5+ECS track): security groups → task + execution IAM roles → ECR repository →
6+private-subnet RDS for PostgreSQL → Secrets Manager secrets → ECS Fargate
7+service behind an internal ALB. The VPC and private subnets are walkthrough
8+prerequisites, passed in as variables — unlike the GCP example, no network is
9+created here.
10+
11+## Files
12+
13+| File | Purpose |
14+|------|---------|
15+| `versions.tf` | Provider pins (aws, random) |
16+| `variables.tf` | All inputs (defaults match `setup.sh`'s) |
17+| `main.tf` | Resources |
18+| `outputs.tf` | ALB DNS name + zone ID, image, roles, DB endpoint |
19+| `terraform.tfvars.example` | Copy to `terraform.tfvars` and edit |
20+
21+## Prerequisites
22+
23+1. **`../gateway.yaml` created and FULLY filled in** — copy the template first:
24+ `cp ../gateway.yaml.example ../gateway.yaml`, then replace every `REPLACE_ME`
25+ (Terraform reads this file and enforces no `REPLACE_ME` via a precondition).
26+ Unlike the GCP example there is no placeholder-first-pass: the config is
27+ **baked into the image**, and `public_url` is your own internal hostname,
28+ which you choose up front (you already hold its ACM certificate).
29+ `gateway.yaml` is gitignored; the committed template is `gateway.yaml.example`.
30+2. The **prebuilt linux-x64 `claude` binary at `../claude`** — the Claude Code
31+ release binary, which includes the `gateway` subcommand (see the
32+ [walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-aws)).
33+ See `../setup.sh`'s `DIST_URL`/`DIST_SHA256` download path for a
34+ checksum-verified fetch.
35+3. A **VPC with two+ private subnets** in different AZs and NAT egress, an **ACM
36+ certificate** for your internal gateway hostname, and **Bedrock model access**
37+ enabled in the console (cross-region `us.anthropic.*` profiles need it in each
38+ region the profile spans), with the one-time use case form submitted.
39+4. A **remote backend** for shared use (see below). State holds secrets — never commit it.
40+
41+## Deploy
42+
43+Terraform creates the ECR repository but does **not** build/push the image, so
44+the apply is two passes: a targeted apply to create the repo, then build/push,
45+then the full apply.
46+
47+```bash
48+cp terraform.tfvars.example terraform.tfvars # edit it
49+terraform init
50+
51+# Pin providers in your copy (once, then commit .terraform.lock.hcl and drop
52+# its .gitignore line): versions.tf pins by range only, so without a committed
53+# lock the registry serves the newest in-range build — a platform-complete
54+# lock gives hash continuity across machines/CI and makes provider upgrades
55+# reviewable diffs.
56+terraform providers lock -platform=linux_amd64 -platform=linux_arm64 \
57+ -platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64
58+
59+# 1. Create just the ECR repository (the -target warning is expected):
60+terraform apply -target=aws_ecr_repository.repo
61+
62+# 2. Build and push the image (gateway.yaml and the RDS CA bundle are baked in;
63+# the COPY sources are context-relative — the build context `..` is aws/, so
64+# `claude`, `gateway.yaml`, and `rds-global-bundle.pem`).
65+# The CA bundle is the trust anchor for the connection string's
66+# sslmode=verify-full (AWS rotates it; download it when absent — don't commit it):
67+curl -fL --proto '=https' -o ../rds-global-bundle.pem \
68+ https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
69+aws ecr get-login-password --region us-east-1 \
70+ | docker login --username AWS --password-stdin <account-id>.dkr.ecr.us-east-1.amazonaws.com
71+docker build --platform=linux/amd64 --provenance=false \
72+ -f ../Dockerfile --build-arg CLAUDE_BINARY=claude --build-arg GATEWAY_CONFIG=gateway.yaml \
73+ -t <account-id>.dkr.ecr.us-east-1.amazonaws.com/claude-gateway:<version> ..
74+docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/claude-gateway:<version>
75+
76+# 3. Full apply:
77+terraform apply
78+```
79+
80+Set in `terraform.tfvars`:
81+
82+- `region`, `vpc_id`, `private_subnet_ids`, `corporate_cidr`
83+- `acm_certificate_arn` — the certificate for your internal gateway hostname
84+ (`gateway.yaml`'s `public_url` host), served by the ALB's HTTPS listener
85+- `image_tag` (after building/pushing — step 2 above). The repo enforces
86+ **immutable tags**, so a `gateway.yaml` edit means a rebuild under a **new**
87+ tag and an `image_tag` bump (`../setup.sh` automates this by tagging
88+ `<version>-cfg<sha8-of-gateway.yaml>`)
89+- **`oidc_client_secret`** — required (the ECS tasks inject `latest` of this
90+ secret at start; with no version they fail with
91+ `ResourceInitializationError`). Terraform creates the secret + version from it.
92+
93+## Tear down
94+
95+Tear down a trial with `terraform destroy`: set `deletion_protection = false`,
96+run `terraform apply` to record that on RDS and the ALB (and to flip RDS to
97+`skip_final_snapshot` — the provider checks the value in **state**, not config,
98+so destroy would still refuse otherwise), then `terraform destroy`.
99+
100+The same switch drives the Secrets Manager recovery window: the three secrets
101+have **fixed names**, and a secret deleted with the default 30-day recovery
102+window keeps its name reserved — a later `terraform apply` would fail with a
103+name conflict until the window elapses. With `deletion_protection = false` the
104+destroy deletes them immediately (`recovery_window_in_days = 0`). If you
105+destroyed a deployment that still had `deletion_protection = true` (or tore
106+down an older copy of this module), clear the scheduled deletions before
107+re-applying:
108+
109+```bash
110+for s in gateway-postgres-url gateway-jwt-secret gateway-oidc-client-secret; do
111+ aws secretsmanager delete-secret --secret-id "$s" --force-delete-without-recovery
112+done
113+```
114+
115+## Guard rails
116+
117+Tuned so accidental deletion is hard but greenfield teardown stays easy:
118+
119+- `deletion_protection = true` (variable, default true) on RDS and the ALB —
120+ blocks accidental deletion; set `false` when you intend to `terraform destroy`.
121+ The same switch controls RDS `skip_final_snapshot`, so a protected instance
122+ always leaves a final snapshot.
123+- ECR tags are **immutable** and **scanned on push** — a deployed tag can never
124+ be silently re-pointed at different bytes. For production, also restrict push
125+ rights on the repo to your CI / image-promotion pipeline rather than operator
126+ credentials.
127+- The IAM roles carry only the walkthrough's least-privilege documents: Bedrock
128+ invoke on the Anthropic model ARNs (task role) and `secretsmanager:GetSecretValue`
129+ on exactly the three secrets this module creates (by ARN) plus the AWS-managed
130+ ECS execution policy (execution role). Inline policies are scoped to these
131+ roles, so nothing else in the account is touched.
132+- TLS everywhere it terminates: the ALB listener pins
133+ `ELBSecurityPolicy-TLS13-1-2-2021-06` (no TLS 1.0/1.1), and the store
134+ connection uses `sslmode=verify-full` against the RDS CA bundle baked into
135+ the image, with `rds.force_ssl=1` enforcing TLS server-side.
136+
137+## Private access
138+
139+The ALB is **internal** with `ip_address_type = "ipv4"` (a dual-stack internal
140+ALB publishes public-range AAAA records, which the CLI's `/login`
141+private-network check rejects), and its security group admits only
142+`corporate_cidr` on 443. Reaching it from on-prem requires your existing
143+routing into the VPC (Direct Connect / VPN) — **operator / network-team-owned**
144+plumbing this module does not create.
145+
146+After the apply, give developers a privately resolvable hostname: in a Route 53
147+private hosted zone, alias the host of `gateway.yaml`'s `public_url` to the ALB
148+(`alb_dns_name` / `alb_zone_id` outputs). The ALB's own `*.elb.amazonaws.com`
149+name can't carry your ACM certificate, so use your own name.
150+
151+The tasks run in the private subnets with no public IP; all egress (Bedrock,
152+the IdP, Secrets Manager, ECR, CloudWatch Logs) goes through the NAT gateway.
153+To keep Bedrock traffic off the public path, create a `bedrock-runtime`
154+interface VPC endpoint and point the upstream's `base_url` at it (see
155+`../gateway.yaml.example`); the IdP still needs internet egress.
156+
157+## Remote state (recommended for teams)
158+
159+Add a backend so state is shared and locked (and out of git):
160+
161+```hcl
162+# backend.tf
163+terraform {
164+ backend "s3" {
165+ bucket = "<your-tf-state-bucket>"
166+ key = "claude-gateway/ecs"
167+ region = "us-east-1"
168+ use_lockfile = true # S3-native locking (Terraform >= 1.10); or set dynamodb_table
169+ }
170+}
171+```
172+
173+## After deploy
174+
175+- `terraform output alb_dns_name` / `alb_zone_id` — create the Route 53 alias.
176+- Register `<public_url>/oauth/callback` on the Okta OIDC web app and make sure
177+ `../gateway.yaml` `public_url` matches the host you aliased.
178+- Notes: Terraform does not build the image. To ship a new gateway version **or
179+ a config edit**, rerun the docker build/push under a new tag and bump
180+ `image_tag` — secrets-only rotations roll the service without a rebuild (the
181+ task definition stamps a hash of the managed secret values), but a
182+ `gateway.yaml` edit reaches the container only through the rebuilt image.
@@ -1,0 +1,510 @@
1+# Claude apps gateway on ECS Fargate — Terraform equivalent of setup.sh.
2+# Section markers (§N) map to setup.sh and the walkthrough:
3+# https://code.claude.com/docs/en/claude-apps-gateway-on-aws
4+#
5+# Unlike the GCP example this module does NOT create the network — the VPC and
6+# private subnets are walkthrough prerequisites, passed in as variables.
7+
8+data "aws_caller_identity" "current" {}
9+data "aws_region" "current" {}
10+
11+# Read (not created) so a typo'd VPC or subnet ID fails the plan up front
12+# instead of half-applying.
13+data "aws_vpc" "this" {
14+ id = var.vpc_id
15+}
16+
17+data "aws_subnet" "private" {
18+ for_each = toset(var.private_subnet_ids)
19+ id = each.value
20+}
21+
22+locals {
23+ config_path = var.gateway_config_path != "" ? var.gateway_config_path : "${path.module}/../gateway.yaml"
24+ gateway_config = file(local.config_path)
25+ image = "${aws_ecr_repository.repo.repository_url}:${var.image_tag}"
26+}
27+
28+# ── 1 Security groups ───────────────────────────────────────────────────────
29+# Three groups chain the traffic path: corp network -> ALB :443, ALB ->
30+# gateway :8080, gateway -> Postgres :5432. Nothing else is reachable.
31+# Rules are separate resources (not inline) so they never fight other tooling.
32+resource "aws_security_group" "alb" {
33+ name = "claude-gateway-alb"
34+ description = "Claude gateway ALB"
35+ vpc_id = var.vpc_id
36+}
37+
38+resource "aws_security_group" "gateway" {
39+ name = "claude-gateway-svc"
40+ description = "Claude gateway service"
41+ vpc_id = var.vpc_id
42+}
43+
44+resource "aws_security_group" "db" {
45+ name = "claude-gateway-db"
46+ description = "Claude gateway Postgres"
47+ vpc_id = var.vpc_id
48+}
49+
50+resource "aws_vpc_security_group_ingress_rule" "alb_https" {
51+ security_group_id = aws_security_group.alb.id
52+ description = "HTTPS from the corporate network"
53+ ip_protocol = "tcp"
54+ from_port = 443
55+ to_port = 443
56+ cidr_ipv4 = var.corporate_cidr
57+}
58+
59+resource "aws_vpc_security_group_ingress_rule" "gateway_from_alb" {
60+ security_group_id = aws_security_group.gateway.id
61+ description = "Gateway port from the ALB"
62+ ip_protocol = "tcp"
63+ from_port = 8080
64+ to_port = 8080
65+ referenced_security_group_id = aws_security_group.alb.id
66+}
67+
68+resource "aws_vpc_security_group_ingress_rule" "db_from_gateway" {
69+ security_group_id = aws_security_group.db.id
70+ description = "Postgres from the gateway"
71+ ip_protocol = "tcp"
72+ from_port = 5432
73+ to_port = 5432
74+ referenced_security_group_id = aws_security_group.gateway.id
75+}
76+
77+# Egress: the ALB only needs to reach its targets; the gateway needs the NAT
78+# path out (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs) plus
79+# Postgres. The DB group needs no egress (security groups are stateful).
80+resource "aws_vpc_security_group_egress_rule" "alb_to_gateway" {
81+ security_group_id = aws_security_group.alb.id
82+ description = "Health checks + forwarding to gateway tasks"
83+ ip_protocol = "tcp"
84+ from_port = 8080
85+ to_port = 8080
86+ referenced_security_group_id = aws_security_group.gateway.id
87+}
88+
89+resource "aws_vpc_security_group_egress_rule" "gateway_all" {
90+ security_group_id = aws_security_group.gateway.id
91+ description = "Egress to Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs, Postgres"
92+ ip_protocol = "-1"
93+ cidr_ipv4 = "0.0.0.0/0"
94+}
95+
96+# ── 2 IAM roles (least-privilege) ───────────────────────────────────────────
97+# Task role: the gateway's runtime identity. Its ONLY permission is invoking
98+# Claude models on Bedrock — the upstream's `auth: {}` resolves to this role
99+# via the AWS default credential chain. The policy must cover both the
100+# cross-region inference-profile ARNs and the underlying foundation-model ARNs.
101+data "aws_iam_policy_document" "ecs_trust" {
102+ statement {
103+ effect = "Allow"
104+ actions = ["sts:AssumeRole"]
105+ principals {
106+ type = "Service"
107+ identifiers = ["ecs-tasks.amazonaws.com"]
108+ }
109+ }
110+}
111+
112+resource "aws_iam_role" "task" {
113+ name = var.task_role_name
114+ assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
115+}
116+
117+resource "aws_iam_role_policy" "bedrock_invoke" {
118+ name = "bedrock-invoke"
119+ role = aws_iam_role.task.id
120+ policy = jsonencode({
121+ Version = "2012-10-17"
122+ Statement = [{
123+ Effect = "Allow"
124+ Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
125+ Resource = [
126+ "arn:aws:bedrock:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:inference-profile/us.anthropic.*",
127+ "arn:aws:bedrock:*::foundation-model/anthropic.*",
128+ ]
129+ }]
130+ })
131+
132+ # The walkthrough is scoped to commercial US regions: this policy and the
133+ # gateway's built-in model catalog both use the us.anthropic.* geo-prefixed
134+ # cross-region inference profiles, which only exist in the commercial US
135+ # regions — an explicit list, not a `us-` prefix match, because GovCloud
136+ # (us-gov-*) and ISO (us-iso-*) regions share the prefix but live in
137+ # different AWS partitions where those profiles and this module's arn:aws:
138+ # ARNs are wrong. Anywhere else the deploy provisions fine and then every
139+ # model call fails. Other-region deploys must pin region-appropriate
140+ # profiles via a models: block in gateway.yaml (see the config reference's
141+ # models: guidance: https://code.claude.com/docs/en/claude-apps-gateway-config),
142+ # widen the inference-profile ARN geo prefix above, and set
143+ # allow_non_us_region = true.
144+ lifecycle {
145+ precondition {
146+ condition = var.allow_non_us_region || contains(["us-east-1", "us-east-2", "us-west-1", "us-west-2"], var.region)
147+ error_message = "region is not a commercial US region (GovCloud/ISO share the us- prefix but are different partitions), and this module's IAM policy and the built-in model catalog use the US-geo (us.anthropic.*) inference profiles. Pin your region's inference profiles in a models: block in gateway.yaml, adjust the bedrock-invoke ARN prefix, then set allow_non_us_region = true."
148+ }
149+ }
150+}
151+
152+# Execution role: the ECS agent's identity — pulls the image from ECR and
153+# injects the Secrets Manager values into the container; the gateway never
154+# uses it. AmazonECSTaskExecutionRolePolicy covers the ECR pull + awslogs;
155+# the inline policy adds read on exactly the three secrets this module
156+# creates — their full ARNs, not a name-prefix wildcard, so nothing else
157+# in a shared account (present or future) is readable through this role.
158+resource "aws_iam_role" "execution" {
159+ name = var.execution_role_name
160+ assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
161+}
162+
163+resource "aws_iam_role_policy_attachment" "execution_managed" {
164+ role = aws_iam_role.execution.name
165+ policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
166+}
167+
168+resource "aws_iam_role_policy" "secrets_read" {
169+ name = "read-gateway-secrets"
170+ role = aws_iam_role.execution.id
171+ policy = jsonencode({
172+ Version = "2012-10-17"
173+ Statement = [{
174+ Effect = "Allow"
175+ Action = "secretsmanager:GetSecretValue"
176+ Resource = [
177+ aws_secretsmanager_secret.jwt.arn,
178+ aws_secretsmanager_secret.oidc.arn,
179+ aws_secretsmanager_secret.postgres_url.arn,
180+ ]
181+ }]
182+ })
183+}
184+
185+# ── 6 ECR repository ────────────────────────────────────────────────────────
186+# NOTE: image build/push is a separate step (see README) — Terraform only makes
187+# the repo. IMMUTABLE tags + scan-on-push: the ECS service pulls whatever this
188+# repo serves under the deployed tag, so a pushed tag must never be silently
189+# re-pointed. For production, also restrict push rights on this repo to your
190+# CI / image-promotion pipeline rather than operator credentials.
191+resource "aws_ecr_repository" "repo" {
192+ name = var.ecr_repo
193+ image_tag_mutability = "IMMUTABLE"
194+ image_scanning_configuration {
195+ scan_on_push = true
196+ }
197+}
198+
199+# ── 3 RDS for PostgreSQL (private subnets, no public address) ───────────────
200+resource "aws_db_subnet_group" "db" {
201+ name = var.db_instance
202+ description = "Claude gateway"
203+ subnet_ids = var.private_subnet_ids
204+}
205+
206+# rds.force_ssl: reject plaintext connections server-side — the client-side
207+# counterpart is sslmode=verify-full in the connection string (§5). The family
208+# tracks the major version in var.db_engine_version.
209+#
210+# name_prefix + create_before_destroy: a major engine bump changes `family`,
211+# which forces replacement — with a static name that deadlocks (the new group
212+# can't be created under the taken name; the old can't be destroyed while the
213+# live instance uses it: "parameter group is currently in use"). With this
214+# shape the replacement group gets a fresh unique name, the instance is
215+# repointed, then the old group is destroyed. (The subnet group above needs
216+# neither: subnet_ids update in place and an engine bump never touches it.)
217+resource "aws_db_parameter_group" "db" {
218+ name_prefix = "${var.db_instance}-"
219+ family = "postgres${split(".", var.db_engine_version)[0]}"
220+ description = "Claude gateway - require TLS on every connection"
221+
222+ parameter {
223+ name = "rds.force_ssl"
224+ value = "1"
225+ }
226+
227+ lifecycle {
228+ create_before_destroy = true
229+ }
230+}
231+
232+# URL-safe (alphanumeric) so it drops cleanly into the connection string.
233+# nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
234+resource "random_password" "db" {
235+ length = 32
236+ special = false
237+}
238+
239+# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
240+resource "aws_db_instance" "db" {
241+ identifier = var.db_instance
242+ engine = "postgres"
243+ engine_version = var.db_engine_version
244+ instance_class = var.db_instance_class
245+ allocated_storage = var.db_allocated_storage
246+ db_name = var.db_name
247+ username = var.db_user
248+ password = random_password.db.result
249+ db_subnet_group_name = aws_db_subnet_group.db.name
250+ parameter_group_name = aws_db_parameter_group.db.name
251+ vpc_security_group_ids = [aws_security_group.db.id]
252+ publicly_accessible = false
253+ storage_encrypted = true
254+ deletion_protection = var.deletion_protection
255+ # Greenfield teardown: skip the final snapshot only once deletion protection
256+ # is deliberately turned off (the same switch — see README "Tear down").
257+ skip_final_snapshot = !var.deletion_protection
258+ final_snapshot_identifier = "${var.db_instance}-final"
259+}
260+
261+# ── 5 Secrets Manager ───────────────────────────────────────────────────────
262+# postgres-url: connection string built from the instance's private endpoint.
263+# The execution role's policy (§2) grants read on these three secrets' ARNs
264+# and nothing else.
265+#
266+# recovery_window_in_days rides the same switch as skip_final_snapshot: the
267+# secrets have fixed names, so a destroy that leaves them in the default
268+# 30-day scheduled-deletion state makes the next apply fail with a name
269+# conflict. Greenfield teardown (deletion_protection = false) deletes them
270+# immediately; a protected deployment keeps the 30-day recovery window.
271+resource "aws_secretsmanager_secret" "postgres_url" {
272+ name = var.secret_name
273+ recovery_window_in_days = var.deletion_protection ? 30 : 0
274+}
275+
276+# sslmode=verify-full: the gateway's driver (Bun.SQL) honors sslmode from the
277+# URL and verifies the server certificate chain AND hostname. The trust anchor
278+# is the AWS RDS CA bundle baked into the image at /etc/claude/rds-global-bundle.pem
279+# and loaded via NODE_EXTRA_CA_CERTS (see ../Dockerfile) — do NOT add a
280+# libpq-style `sslrootcert=` query param: the driver doesn't read it and
281+# forwards it to Postgres as a startup parameter, which the server rejects.
282+# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
283+resource "aws_secretsmanager_secret_version" "postgres_url" {
284+ secret_id = aws_secretsmanager_secret.postgres_url.id
285+ secret_string = "postgres://${var.db_user}:${random_password.db.result}@${aws_db_instance.db.address}:5432/${var.db_name}?sslmode=verify-full"
286+}
287+
288+# jwt: session signing key.
289+# nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
290+resource "random_password" "jwt" {
291+ length = 48
292+ special = false
293+}
294+
295+resource "aws_secretsmanager_secret" "jwt" {
296+ name = var.jwt_secret_name
297+ recovery_window_in_days = var.deletion_protection ? 30 : 0 # see postgres_url
298+}
299+
300+# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
301+resource "aws_secretsmanager_secret_version" "jwt" {
302+ secret_id = aws_secretsmanager_secret.jwt.id
303+ secret_string = random_password.jwt.result
304+}
305+
306+# oidc client secret: operator-provided (from the Okta OIDC web app).
307+resource "aws_secretsmanager_secret" "oidc" {
308+ name = var.oidc_secret_name
309+ recovery_window_in_days = var.deletion_protection ? 30 : 0 # see postgres_url
310+}
311+
312+# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
313+resource "aws_secretsmanager_secret_version" "oidc" {
314+ count = var.oidc_client_secret != "" ? 1 : 0
315+ secret_id = aws_secretsmanager_secret.oidc.id
316+ secret_string = var.oidc_client_secret
317+}
318+
319+# Warn (not block) at plan time when the OIDC secret value isn't set: the task
320+# definition references the secret unconditionally, so an empty value with no
321+# out-of-band version means the tasks fail to start late, at container init
322+# (ResourceInitializationError). A warning (not a precondition) keeps the
323+# documented out-of-band-version mode usable.
324+check "oidc_client_secret_set" {
325+ assert {
326+ condition = var.oidc_client_secret != ""
327+ error_message = "oidc_client_secret is empty — set it in terraform.tfvars, or add a version to the gateway-oidc-client-secret secret out-of-band before applying (the ECS tasks inject it at start and will fail without one)."
328+ }
329+}
330+
331+# ── 7 ECS Fargate service + internal ALB ────────────────────────────────────
332+resource "aws_ecs_cluster" "cluster" {
333+ name = var.cluster_name
334+}
335+
336+# The gateway's stderr carries both its audit events and operational logs.
337+# Bounded retention — without it the group keeps logs forever and cost grows
338+# unbounded; the default (90 days) is sized for audit-trail review windows.
339+resource "aws_cloudwatch_log_group" "gateway" {
340+ name = var.log_group_name
341+ retention_in_days = var.log_retention_days
342+}
343+
344+# Task definition. gateway.yaml ships INSIDE the image (unlike the GCP example,
345+# which mounts it from Secret Manager), so Terraform reads ../gateway.yaml only
346+# to (a) enforce the no-REPLACE_ME guard before a deploy and (b) stamp a hash
347+# of the config + every managed secret value into the container environment —
348+# secrets are injected at task start, so rotating one (tainting
349+# random_password.db ALTERs the DB password; a new oidc_client_secret) would
350+# otherwise leave running tasks on stale values with nothing forcing a roll.
351+# NOTE the hash only forces a roll; a config EDIT still reaches the container
352+# only via a rebuilt image — push under a new tag (the repo enforces
353+# immutability) and bump image_tag, or the roll redeploys the old config.
354+resource "aws_ecs_task_definition" "gateway" {
355+ family = var.service_name
356+ network_mode = "awsvpc"
357+ requires_compatibilities = ["FARGATE"]
358+ cpu = tostring(var.task_cpu)
359+ memory = tostring(var.task_memory)
360+ execution_role_arn = aws_iam_role.execution.arn
361+ task_role_arn = aws_iam_role.task.arn
362+
363+ runtime_platform {
364+ cpu_architecture = "X86_64" # build the image linux/amd64; ARM64 for Graviton (see ../Dockerfile)
365+ operating_system_family = "LINUX"
366+ }
367+
368+ container_definitions = jsonencode([
369+ {
370+ name = "gateway"
371+ image = local.image
372+ portMappings = [{ containerPort = 8080 }]
373+ environment = [
374+ {
375+ name = "GATEWAY_CONFIG_SHA"
376+ value = substr(sha256(join("", [
377+ local.gateway_config,
378+ random_password.db.result,
379+ random_password.jwt.result,
380+ var.oidc_client_secret,
381+ ])), 0, 16)
382+ },
383+ ]
384+ secrets = [
385+ { name = "GATEWAY_JWT_SECRET", valueFrom = aws_secretsmanager_secret.jwt.arn },
386+ { name = "OIDC_CLIENT_SECRET", valueFrom = aws_secretsmanager_secret.oidc.arn },
387+ { name = "GATEWAY_POSTGRES_URL", valueFrom = aws_secretsmanager_secret.postgres_url.arn },
388+ ]
389+ logConfiguration = {
390+ logDriver = "awslogs"
391+ options = {
392+ awslogs-group = aws_cloudwatch_log_group.gateway.name
393+ awslogs-region = data.aws_region.current.region
394+ awslogs-stream-prefix = "gateway"
395+ }
396+ }
397+ }
398+ ])
399+
400+ # Guard mirrors setup.sh's REPLACE_ME check (non-comment lines): the config
401+ # is baked into the image this task definition deploys, so a half-filled
402+ # gateway.yaml at apply time means the pushed image is half-filled too.
403+ lifecycle {
404+ precondition {
405+ condition = length([
406+ for line in split("\n", local.gateway_config) :
407+ line
408+ if !startswith(trimspace(line), "#") && strcontains(line, "REPLACE_ME")
409+ ]) == 0
410+ error_message = "gateway.yaml still has REPLACE_ME on a non-comment line — fill it in (and rebuild/push the image) before applying."
411+ }
412+ }
413+
414+ depends_on = [
415+ aws_secretsmanager_secret_version.postgres_url,
416+ aws_secretsmanager_secret_version.jwt,
417+ ]
418+}
419+
420+# Internal ALB. ip_address_type ipv4: an internal dual-stack ALB publishes
421+# public-range AAAA records, which the CLI's /login private-network check
422+# rejects. idle_timeout 3600: the 60-second default closes a streaming
423+# response at the first quiet period (long prompt processing before the first
424+# token, extended thinking with no streamed output).
425+resource "aws_lb" "gateway" {
426+ name = var.service_name
427+ internal = true
428+ load_balancer_type = "application"
429+ ip_address_type = "ipv4"
430+ subnets = var.private_subnet_ids
431+ security_groups = [aws_security_group.alb.id]
432+ idle_timeout = 3600
433+ enable_deletion_protection = var.deletion_protection
434+}
435+
436+# /readyz verifies the store is reachable, so a task that can't reach Postgres
437+# never enters rotation (the gateway also serves liveness-only /healthz — see
438+# the deploy guide's outage-behavior tradeoff).
439+resource "aws_lb_target_group" "gateway" {
440+ name = var.service_name
441+ protocol = "HTTP"
442+ port = 8080
443+ vpc_id = var.vpc_id
444+ target_type = "ip"
445+
446+ health_check {
447+ path = "/readyz"
448+ }
449+}
450+
451+resource "aws_lb_listener" "https" {
452+ load_balancer_arn = aws_lb.gateway.arn
453+ protocol = "HTTPS"
454+ port = 443
455+ # Explicit modern policy — omitting ssl_policy falls back to the legacy
456+ # ELBSecurityPolicy-2016-08 default, which still accepts TLS 1.0/1.1.
457+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
458+ certificate_arn = var.acm_certificate_arn
459+
460+ default_action {
461+ type = "forward"
462+ target_group_arn = aws_lb_target_group.gateway.arn
463+ }
464+}
465+
466+resource "aws_ecs_service" "gateway" {
467+ name = var.service_name
468+ cluster = aws_ecs_cluster.cluster.id
469+ task_definition = aws_ecs_task_definition.gateway.arn
470+ desired_count = var.desired_count
471+ launch_type = "FARGATE"
472+
473+ # Stop a rollout whose tasks keep failing (bad image, unbootable config) and
474+ # roll back to the last steady state instead of relaunching failing tasks
475+ # forever.
476+ deployment_circuit_breaker {
477+ enable = true
478+ rollback = true
479+ }
480+
481+ network_configuration {
482+ subnets = var.private_subnet_ids
483+ security_groups = [aws_security_group.gateway.id]
484+ # All egress (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs)
485+ # goes through the NAT gateway — tasks get no public IP.
486+ assign_public_ip = false
487+ }
488+
489+ load_balancer {
490+ target_group_arn = aws_lb_target_group.gateway.arn
491+ container_name = "gateway"
492+ container_port = 8080
493+ }
494+
495+ # Tasks register with the ALB at start — give a cold task (image pull +
496+ # store connect + first /readyz) time before ECS replaces it as unhealthy.
497+ health_check_grace_period_seconds = 60
498+
499+ # The listener must exist before targets register; the secrets must be
500+ # readable before the first task starts.
501+ depends_on = [
502+ aws_lb_listener.https,
503+ aws_iam_role_policy.secrets_read,
504+ aws_iam_role_policy_attachment.execution_managed,
505+ aws_secretsmanager_secret_version.postgres_url,
506+ aws_secretsmanager_secret_version.jwt,
507+ aws_secretsmanager_secret_version.oidc,
508+ aws_db_instance.db,
509+ ]
510+}
@@ -1,0 +1,34 @@
1+output "alb_dns_name" {
2+ description = "Internal ALB DNS name. Alias your gateway hostname (the host in gateway.yaml's public_url) to this in a Route 53 private hosted zone — the *.elb.amazonaws.com name itself can't carry your ACM certificate."
3+ value = aws_lb.gateway.dns_name
4+}
5+
6+output "alb_zone_id" {
7+ description = "ALB hosted zone ID, for the Route 53 alias record."
8+ value = aws_lb.gateway.zone_id
9+}
10+
11+output "image" {
12+ description = "Image the service runs (build/push this separately — see README)."
13+ value = local.image
14+}
15+
16+output "ecr_repository_url" {
17+ description = "ECR repository URL to push the gateway image to."
18+ value = aws_ecr_repository.repo.repository_url
19+}
20+
21+output "task_role_arn" {
22+ description = "Gateway runtime task role (Bedrock invoke)."
23+ value = aws_iam_role.task.arn
24+}
25+
26+output "execution_role_arn" {
27+ description = "ECS execution role (image pull + secret injection)."
28+ value = aws_iam_role.execution.arn
29+}
30+
31+output "db_endpoint" {
32+ description = "RDS private endpoint (host only; the connection string lives in the gateway-postgres-url secret)."
33+ value = aws_db_instance.db.address
34+}
@@ -1,0 +1,27 @@
1+# Copy to terraform.tfvars and edit. terraform.tfvars is gitignored (see .gitignore).
2+
3+region = "us-east-1" # a region where Bedrock serves the Claude models you need
4+
5+# Prerequisite networking (NOT created by this module): the VPC and two+ private
6+# subnets in different AZs with outbound internet via a NAT gateway.
7+vpc_id = "vpc-..."
8+private_subnet_ids = ["subnet-...a", "subnet-...b"]
9+
10+# The only source the ALB admits on 443. Must not overlap the private subnets
11+# above — hosts in the ALB subnets are trusted_proxies (gateway.yaml) and could
12+# spoof client IPs via X-Forwarded-For.
13+corporate_cidr = "10.0.0.0/8"
14+
15+# ACM certificate for your internal gateway hostname (the host in gateway.yaml's
16+# public_url), imported or issued by AWS Private CA.
17+acm_certificate_arn = "arn:aws:acm:..."
18+
19+image_tag = "<version>" # REQUIRED — the tag you build and push as linux/amd64 with
20+ # gateway.yaml baked in (setup.sh tags <version>-cfg<sha8>;
21+ # see README Deploy)
22+
23+# Okta OIDC client secret: REQUIRED — uncomment and set it (Terraform creates the
24+# secret version; the ECS tasks inject `gateway-oidc-client-secret` at start, so
25+# without a version they fail with ResourceInitializationError). Leave empty only
26+# if you add the secret version out-of-band.
27+# oidc_client_secret = "..."
@@ -1,0 +1,189 @@
1+# Inputs — mirror the env-overridable knobs in setup.sh (same defaults).
2+
3+variable "region" {
4+ description = "AWS region for everything this module creates. Pick one where Bedrock serves the Claude models you need. (The Bedrock region the gateway calls is set separately inside gateway.yaml — keep the two equal.) The walkthrough is scoped to the commercial US regions (us-east-1/us-east-2/us-west-1/us-west-2 — GovCloud and ISO regions are different partitions); see allow_non_us_region."
5+ type = string
6+ default = "us-east-1"
7+}
8+
9+variable "allow_non_us_region" {
10+ description = "The bedrock-invoke IAM policy and the gateway's built-in model catalog use the US-geo (us.anthropic.*) cross-region inference profiles, so any region outside the commercial US four (including GovCloud/ISO, which are different partitions) fails a plan-time precondition. Set true ONLY after pinning region-appropriate inference profiles via a models: block in gateway.yaml (see the config reference) and widening the ARN geo prefix in main.tf's bedrock-invoke policy."
11+ type = bool
12+ default = false
13+}
14+
15+# ── Networking inputs (prerequisites — NOT created here) ────────────────────
16+variable "vpc_id" {
17+ description = "Existing VPC ID (the walkthrough's prerequisite VPC). Unlike the GCP example, this module does not create the network."
18+ type = string
19+}
20+
21+variable "private_subnet_ids" {
22+ description = "Two+ private subnet IDs in different AZs, with outbound internet via a NAT gateway. The internal ALB, the ECS tasks, and the RDS subnet group all attach here."
23+ type = list(string)
24+ validation {
25+ condition = length(var.private_subnet_ids) >= 2
26+ error_message = "private_subnet_ids needs at least two subnets in different AZs (the internal ALB requires two)."
27+ }
28+}
29+
30+variable "corporate_cidr" {
31+ description = "Your corporate network CIDR — the only source the ALB security group admits on 443. Must not overlap private_subnet_ids: hosts there are trusted_proxies (gateway.yaml) and could spoof client IPs via X-Forwarded-For."
32+ type = string
33+}
34+
35+# ── IAM (§2) ────────────────────────────────────────────────────────────────
36+variable "task_role_name" {
37+ description = "ECS task role name (the gateway's runtime identity; its only permission is Bedrock invoke)."
38+ type = string
39+ default = "claude-gateway-task"
40+}
41+
42+variable "execution_role_name" {
43+ description = "ECS execution role name (the ECS agent's identity: pulls the image, injects the secrets)."
44+ type = string
45+ default = "claude-gateway-execution"
46+}
47+
48+# ── Image (§6) ──────────────────────────────────────────────────────────────
49+# Terraform creates the ECR repository but does NOT build/push the image (that's
50+# a docker build step — see README). It references the image by tag.
51+variable "ecr_repo" {
52+ description = "ECR repository name."
53+ type = string
54+ default = "claude-gateway"
55+}
56+
57+variable "image_tag" {
58+ description = "Image tag — the tag you built and pushed (must already exist in the repo as linux/amd64, with gateway.yaml baked in). setup.sh tags as <version>-cfg<sha8 of gateway.yaml>; see the README Deploy section for the build command."
59+ type = string
60+ validation {
61+ condition = can(regex("^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", var.image_tag))
62+ error_message = "image_tag must be a valid OCI tag — set it to the tag you pushed (the '<version>' in terraform.tfvars.example is a placeholder)."
63+ }
64+}
65+
66+variable "gateway_config_path" {
67+ description = "Path to gateway.yaml. Empty = ../gateway.yaml relative to this module. Read for the REPLACE_ME guard and the config-sha that rolls the service; the file itself ships inside the image."
68+ type = string
69+ default = ""
70+}
71+
72+# ── RDS (§3) ────────────────────────────────────────────────────────────────
73+variable "db_instance" {
74+ description = "RDS instance identifier."
75+ type = string
76+ default = "claude-gateway-db"
77+}
78+
79+variable "db_engine_version" {
80+ description = "Postgres major version. The gateway supports PostgreSQL 14 or newer; 16 is the recommended default."
81+ type = string
82+ default = "16"
83+}
84+
85+variable "db_instance_class" {
86+ description = "RDS instance class."
87+ type = string
88+ default = "db.t4g.micro"
89+}
90+
91+variable "db_allocated_storage" {
92+ description = "RDS allocated storage in GiB."
93+ type = number
94+ default = 20
95+}
96+
97+variable "db_name" {
98+ description = "Database name."
99+ type = string
100+ default = "claude_gateway"
101+}
102+
103+variable "db_user" {
104+ description = "Database master user (the gateway connects as this role)."
105+ type = string
106+ default = "gateway"
107+}
108+
109+# ── Secrets (§5) ────────────────────────────────────────────────────────────
110+# The execution role's secrets-read policy grants read on exactly these three
111+# secrets' ARNs, so renames are picked up automatically on the next apply.
112+variable "secret_name" {
113+ description = "Secrets Manager secret holding the Postgres connection string."
114+ type = string
115+ default = "gateway-postgres-url"
116+}
117+
118+variable "jwt_secret_name" {
119+ description = "Secrets Manager secret holding the session JWT signing key."
120+ type = string
121+ default = "gateway-jwt-secret"
122+}
123+
124+variable "oidc_secret_name" {
125+ description = "Secrets Manager secret holding the Okta OIDC client secret."
126+ type = string
127+ default = "gateway-oidc-client-secret"
128+}
129+
130+variable "oidc_client_secret" {
131+ description = "Okta OIDC client secret value. Leave empty to NOT manage the version via Terraform (only if you add the secret version out-of-band — without one the tasks fail to start)."
132+ type = string
133+ default = ""
134+ sensitive = true
135+}
136+
137+# ── ECS + ALB (§7) ──────────────────────────────────────────────────────────
138+variable "cluster_name" {
139+ description = "ECS cluster name."
140+ type = string
141+ default = "claude-gateway"
142+}
143+
144+variable "service_name" {
145+ description = "ECS service name (also used for the ALB and target group)."
146+ type = string
147+ default = "claude-gateway"
148+}
149+
150+variable "log_group_name" {
151+ description = "CloudWatch Logs group for the gateway's stderr (audit events + operational logs)."
152+ type = string
153+ default = "/ecs/claude-gateway"
154+}
155+
156+variable "log_retention_days" {
157+ description = "CloudWatch Logs retention in days. The group carries the gateway's audit events, so align with your audit retention policy."
158+ type = number
159+ default = 90
160+}
161+
162+variable "acm_certificate_arn" {
163+ description = "ACM certificate ARN for the internal gateway hostname (the host in gateway.yaml's public_url), served by the ALB's HTTPS listener."
164+ type = string
165+}
166+
167+variable "task_cpu" {
168+ description = "Fargate task CPU units."
169+ type = number
170+ default = 1024
171+}
172+
173+variable "task_memory" {
174+ description = "Fargate task memory (MiB)."
175+ type = number
176+ default = 2048
177+}
178+
179+variable "desired_count" {
180+ description = "ECS service desired task count. Each task opens a Postgres pool of up to 5 connections (the gateway's store.max_connections default) and db.t4g.micro caps at ~80 max_connections — keep desired_count × 5 below the DB class's limit, or raise the class before raising this."
181+ type = number
182+ default = 1
183+}
184+
185+variable "deletion_protection" {
186+ description = "Deletion protection on RDS and the ALB (and whether RDS skips the final snapshot on destroy). Keep true to avoid accidental deletion of the running deployment."
187+ type = bool
188+ default = true
189+}
@@ -1,0 +1,18 @@
1+# Provider + version pins for the Claude apps gateway ECS Fargate deployment.
2+terraform {
3+ required_version = ">= 1.5"
4+ required_providers {
5+ aws = {
6+ source = "hashicorp/aws"
7+ version = ">= 6.0, < 7.0" # 6.0 renames data.aws_region's attribute to `region`
8+ }
9+ random = {
10+ source = "hashicorp/random"
11+ version = ">= 3.5"
12+ }
13+ }
14+}
15+
16+provider "aws" {
17+ region = var.region
18+}