Compare commits

..

4 Commits

Author SHA1 Message Date
GitHub Actions
ac062f33ab chore: Update CHANGELOG.md and feed.xml 2026-07-21 21:35:04 +00:00
Roy Arsan
c4dbd740a7 Merge pull request #79898 from anthropics/royarsan/gateway-aws-example
Add Claude apps gateway on AWS example deployment assets
2026-07-21 20:39:13 +01:00
Roy Arsan
843297f6b1 Add Claude apps gateway on AWS example deployment assets
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.
2026-07-21 19:22:25 +00:00
GitHub Actions
b799fcaf9f chore: Update CHANGELOG.md and feed.xml 2026-07-21 16:58:24 +00:00
15 changed files with 2292 additions and 34 deletions

View File

@@ -1,5 +1,28 @@
# Changelog
## 2.1.217
- Added emoji shortcode autocomplete in the prompt input: type `:heart:` to insert ❤️, or `:hea` for suggestions — disable with the `emojiCompletionEnabled` setting
- Added warnings when transcript writes are failing (e.g. disk full) or when session saving is off due to an inherited environment variable, instead of losing transcripts silently
- Fixed a memory leak where truncated MCP tool outputs kept the full untruncated result in memory for the rest of the session
- Fixed Windows auto-update failures that could leave `claude.exe` missing; failed updates now restore the preserved executable automatically
- Fixed background session isolation not canonicalizing symlinked working directories, which could let sessions escape their workspace folder
- Fixed auto-compact never triggering for Claude Opus 4.8 on Bedrock and `/compact` failing once over the limit
- Fixed corporate mTLS, TLS-verify, OAuth scope, and proxy settings being ignored in Claude Desktop sessions
- Fixed screen reader mode's startup announcement being cut off by the first prompt render, and the thinking status row re-rendering every few seconds to update elapsed time and token counts
- Fixed managed settings that set `OTEL_EXPORTER_OTLP_ENDPOINT` not governing all signals — lower-scope signal-specific overrides no longer redirect telemetry away from the managed endpoint
- Fixed `--resume`/`--continue` and `/resume` failing with a TypeError when a transcript has a malformed attachment entry
- Fixed Remote Control sessions not showing a pending permission prompt or dialog to viewers that connected after it appeared
- Fixed background shells sometimes becoming impossible to stop after a session is sent to the background (`/background` or `←`) or when the session exits on a heavily loaded machine, most visible on Windows
- Fixed a `CLAUDE.md` or `SKILL.md` paths frontmatter value with many brace groups OOM-killing or stalling the CLI at startup — brace expansion is now budget-bounded
- Fixed the transcript preview sitting flush against the input area when attaching to a starting background session; it now leaves the same one-line gap as the live layout, so the transcript no longer shifts when the session takes over
- Improved footer PR badge links to be clickable hyperlinks even when terminal support can't be detected (e.g. over ssh/tmux); set `FORCE_HYPERLINK=0` to opt out
- Changed the login-expiry warning to appear 3 days before expiry instead of 5
- Capped the frontend-design plugin suggestion tip at 3 lifetime impressions instead of repeating indefinitely
- Added a cap on concurrently-running subagents (default 20, override with `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS`) so one message can't fan out unbounded background agents
- Changed subagents to no longer spawn nested subagents by default; set `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` to allow deeper nesting
- Fixed `--max-budget-usd` not stopping background subagents: once the cap is reached, new spawns are denied and running background agents are halted
## 2.1.216
- Added `sandbox.filesystem.disabled` setting to skip filesystem isolation while keeping network egress control
@@ -234,6 +257,7 @@
- Added `vimInsertModeRemaps` setting: map two-key insert-mode sequences like `jj` to Escape in vim mode
- Added `CLAUDE_CODE_PROCESS_WRAPPER`: agent view and the background service now honor a corporate launcher by running every Claude Code self-spawn through a required wrapper executable
- Added mouse-click support for multi-select menus and "Other" input rows in fullscreen mode
- Changed the Fable 5 usage-credits consent prompt to start with the decline option focused
- Fixed fast mode staying off after switching back to a model that supports it — it now restores automatically when enabled in settings
- Fixed replies typed to a background agent being lost when delivery fails — the text is now saved and delivered when the session restarts
- Fixed background-session attach failing permanently ("Couldn't start the background daemon") after an update replaced the binary a running `claude agents` process was launched from

View File

@@ -0,0 +1,19 @@
# Keep secrets and generated artifacts out of the build context. The Dockerfile
# COPYs the binary, gateway.yaml (unlike the GCP example, the config is baked
# into the image — ECS injects only the secrets it references, as env vars),
# and the RDS CA bundle. BuildKit (the default builder) only syncs the
# referenced COPY sources anyway, so this is a denylist for the classic
# builder (DOCKER_BUILDKIT=0) and a conventional signal that the .gitignore'd
# secrets in this directory aren't part of the image build.
terraform/
**/.terraform/
*.tfstate*
terraform.tfvars
secrets/
*.pem
# The RDS CA bundle is public trust-anchor material (no secret), and the
# Dockerfile COPYs it — carve it out of the *.pem exclusion above.
!rds-global-bundle.pem
*.iam.json
claude.download
claude.bad

18
examples/gateway/aws/.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Local, environment-specific config — copy gateway.yaml.example -> gateway.yaml
# (gateway.yaml.example IS committed; your filled-in gateway.yaml is not)
gateway.yaml
# Secrets / credentials — never commit. Also covers rds-global-bundle.pem:
# not a secret, but downloaded by setup.sh when absent (delete it to refresh
# after an RDS CA rotation), so it stays out of git.
secrets/
*.pem
# Scratch IAM policy documents written by setup.sh (no secrets, but generated)
*.iam.json
# Vendored release binary — download per release (see setup.sh DIST_URL).
# claude.bad is a checksum-mismatched binary that setup.sh set aside.
claude
claude.download
claude.bad

View File

@@ -0,0 +1,67 @@
# Runtime image for `claude gateway`.
#
# This image does NOT build the binary. It expects a prebuilt native
# linux-x64 `claude` executable in the build context — the Claude Code release
# binary, which includes the `gateway` subcommand. setup.sh places it at
# ./claude (downloading and checksum-verifying it via DIST_URL/DIST_SHA256 if
# missing). Override CLAUDE_BINARY to point at a different path.
#
# Unlike the GCP example (which mounts the config from Secret Manager at
# runtime), this image BAKES gateway.yaml in at /etc/claude/gateway.yaml — on
# ECS the task definition injects only the secrets the YAML references, as env
# vars. gateway.yaml therefore must be fully filled in (no REPLACE_ME) before
# building; setup.sh enforces this. A config edit means a rebuild under a new
# tag. The file contains no secret values — every credential resolves at boot
# via ${ENV_VAR} expansion.
#
# The image also bakes in the AWS RDS CA bundle (rds-global-bundle.pem —
# setup.sh downloads it from https://truststore.pki.rds.amazonaws.com before
# the build) and trusts it via NODE_EXTRA_CA_CERTS, so the store connection
# string's `?sslmode=verify-full` verifies the RDS server certificate chain
# and hostname. NOTE the gateway's driver reads `sslmode` from the URL but NOT
# a libpq-style `sslrootcert=` param — the CA must come from this env var.
#
# Build:
# docker build --platform=linux/amd64 --provenance=false \
# --build-arg CLAUDE_BINARY=./claude -t claude-gateway .
#
# (For Fargate on ARM64/Graviton: build --platform=linux/arm64 with the
# linux-arm64 binary and set the task definition's cpuArchitecture to ARM64.)
#
# Run:
# docker run --rm -p 8080:8080 \
# -e OIDC_CLIENT_SECRET -e GATEWAY_JWT_SECRET -e GATEWAY_POSTGRES_URL \
# claude-gateway
ARG CLAUDE_BINARY=./claude
ARG GATEWAY_CONFIG=./gateway.yaml
ARG RDS_CA_BUNDLE=./rds-global-bundle.pem
# distroless/cc provides glibc + libstdc++ (required by the Bun-compiled
# native binary). The :nonroot tag runs as uid/gid 65532. Pinned by digest so
# the build never silently takes new upstream bytes (the digest is the
# multi-arch OCI index, so --platform still selects amd64/arm64). To refresh
# the pin after reviewing upstream changes:
# docker manifest inspect -v gcr.io/distroless/cc-debian12:nonroot # prints the index digest
FROM gcr.io/distroless/cc-debian12:nonroot@sha256:ce0d66bc0f64aae46e6a03add867b07f42cc7b8799c949c2e898057b7f75a151
ARG CLAUDE_BINARY
ARG GATEWAY_CONFIG
ARG RDS_CA_BUNDLE
COPY --chmod=0755 ${CLAUDE_BINARY} /usr/local/bin/claude
# WORKDIR pre-creates /etc/claude with 0755 — without it, COPY --chmod would
# also stamp the auto-created parent directory 0644 (no execute bit), making
# the config unreadable for the nonroot user.
WORKDIR /etc/claude
COPY --chmod=0644 ${GATEWAY_CONFIG} /etc/claude/gateway.yaml
COPY --chmod=0644 ${RDS_CA_BUNDLE} /etc/claude/rds-global-bundle.pem
WORKDIR /
ENV CLAUDE_CONFIG_DIR=/tmp/.claude
# Trust anchor for the store's sslmode=verify-full (see header comment).
ENV NODE_EXTRA_CA_CERTS=/etc/claude/rds-global-bundle.pem
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/usr/local/bin/claude", "gateway", "--config", "/etc/claude/gateway.yaml"]

View File

@@ -0,0 +1,19 @@
# Claude apps gateway on AWS
Reference deployment artifacts for running Claude apps gateway on AWS with
Amazon Bedrock as the upstream: ECS on Fargate or EKS, Amazon RDS for
PostgreSQL, AWS Secrets Manager, and IAM-role auth to Bedrock.
These files are provided as a working example rather than a supported production
deployment. Adapt them to your own environment.
- **Walkthrough**: https://code.claude.com/docs/en/claude-apps-gateway-on-aws
- **Related**: AWS-maintained samples for various customer environments at
https://github.com/aws-samples/anthropic-on-aws/tree/main/claude-apps-gateway
| File | Purpose |
|---|---|
| `setup.sh` | Scripts the walkthrough end to end via the `aws` CLI |
| `Dockerfile` | Runtime image for the `claude gateway` binary (bakes in `gateway.yaml`) |
| `gateway.yaml.example` | Gateway config template, AWS-shaped (Bedrock upstream, Okta IdP) |
| `terraform/` | Provisions the full architecture (two-pass apply — see `terraform/README.md`) |

View File

@@ -0,0 +1,174 @@
# gateway.yaml.example — Claude apps gateway config template, AWS-shaped (walkthrough §4).
#
# Okta IdP + Bedrock upstream, following the walkthrough at
# https://code.claude.com/docs/en/claude-apps-gateway-on-aws. The active sections
# below are a strict subset of the full configuration reference at
# https://code.claude.com/docs/en/claude-apps-gateway-config; optional keys are
# included commented-out.
#
# USAGE — this is the shippable TEMPLATE. Copy it to gateway.yaml and fill it in:
# cp gateway.yaml.example gateway.yaml
# setup.sh and terraform/ read gateway.yaml (your filled-in copy, which is
# gitignored). Unlike the GCP example it is NOT published to a secret store:
# the Dockerfile bakes it into the image at /etc/claude/gateway.yaml — the
# container ENTRYPOINT runs `claude gateway --config /etc/claude/gateway.yaml`.
# It holds no secret values; a config edit means an image rebuild (setup.sh
# tags images with a hash of this file, so a re-run rebuilds automatically).
#
# Secret expansion: ${ENV_VAR} reads an env var; ${file:/path} reads a mounted file.
# On ECS, the task definition injects the JWT / OIDC / Postgres secrets as ENV
# VARS via its `secrets` field (valueFrom -> Secrets Manager ARN). On EKS you
# may mount them as files instead and use ${file:/secrets/...}.
#
# BEFORE BUILD — replace every REPLACE_ME placeholder below (setup.sh refuses to
# build the image while any remain — the config is baked in, so a half-filled
# config would ship), and create the referenced secrets:
# gateway-jwt-secret (setup.sh generates this)
# gateway-oidc-client-secret (from the Okta admin console OIDC web app)
# gateway-postgres-url (setup.sh generates this)
# ── Listener ─────────────────────────────────────────────────────────────────
listen:
host: 0.0.0.0
port: 8080 # the target group forwards ALB :443 -> :8080
# Required. Fixes the IdP redirect_uri, the OIDC discovery doc, and the
# gateway-token issuer so none are derived from the client-controlled Host
# header (X-Forwarded-Host/-Proto are likewise never trusted). Set it to the
# internal hostname you picked in the prerequisites — the Route 53 private
# zone name your ACM certificate covers (e.g.
# https://claude-gateway.internal.example.com). Unlike Cloud Run there is no
# first-deploy placeholder dance: you choose the hostname up front, alias it
# to the internal ALB after the deploy, and register the same host's
# /oauth/callback on the Okta app.
public_url: REPLACE_ME
# Register this exact redirect URI on the Okta OIDC web application:
# https://<public_url host>/oauth/callback
#
# Behind the internal ALB every request arrives via the load balancer, so the
# gateway sees ALB-node peer IPs for all developers — set trusted_proxies so
# X-Forwarded-For from those proxies is trusted and per-IP rate limiting /
# audit IPs record the real client. ALB nodes take addresses from the subnets
# the ALB is attached to, so list those subnets' CIDRs (the private subnets
# from the prerequisites).
#
# NOTE: listing the ALB subnets' CIDRs trusts every host in those subnets as a
# proxy — any co-located workload that can reach the ALB can then spoof the
# client IP via X-Forwarded-For (audit logs, per-IP rate limits, IP
# allowlists). Keep the ALB :443 ingress source (CORP_CIDR / corporate_cidr)
# from overlapping these subnets, and don't share the subnets with untrusted
# workloads.
trusted_proxies: [REPLACE_ME] # e.g. [10.0.1.0/24, 10.0.2.0/24]
#
# Alternative — terminate TLS in the gateway itself instead of at the ALB:
# tls:
# cert: /certs/gateway.crt
# key: /certs/gateway.key
# ── Identity provider — Okta ─────────────────────────────────────────────────
oidc:
issuer: REPLACE_ME # e.g. https://example.okta.com (or your custom auth server URL)
client_id: REPLACE_ME # Okta OIDC web app client ID (not secret)
client_secret: ${OIDC_CLIENT_SECRET} # EKS file mounts: ${file:/secrets/oidc-client-secret}
allowed_email_domains: [REPLACE_ME] # e.g. [example.com] — reject id_tokens outside your org
# The Okta org authorization server returns a thin id_token that omits email
# and groups; the gateway fills them from /userinfo.
userinfo_fallback: true
# offline_access yields refresh tokens (silent renewal + the deprovision
# leash); Okta emits groups only when the `groups` scope is requested AND the
# app's groups claim filter allows them (Okta admin console -> the app's
# Sign On tab -> OpenID Connect ID Token -> Groups claim filter).
scopes: [openid, profile, email, offline_access, groups]
# groups_claim: groups # Okta default. Entra app roles=roles; see the config reference
# ca_cert_pem: ${file:/secrets/idp-ca.pem} # only for an IdP behind a private CA
# ── Sessions ─────────────────────────────────────────────────────────────────
session:
jwt_secret: ${GATEWAY_JWT_SECRET} # >= 32 bytes; openssl rand -base64 32
# Okta issues refresh tokens (offline_access above), so sessions renew
# silently and this mainly bounds deprovision latency. 8 is a sane default;
# lower toward 1 for tighter revocation. Array form rotates keys:
# [new, old] (index 0 signs, all verify).
ttl_hours: 8
# ── Store (REQUIRED — the gateway refuses to boot without it) ─────────────────
store:
postgres_url: ${GATEWAY_POSTGRES_URL} # private-subnet RDS; built with ?sslmode=verify-full by setup.sh
# (the image trusts the RDS CA bundle via NODE_EXTRA_CA_CERTS — see Dockerfile)
# ── Upstreams — Amazon Bedrock ───────────────────────────────────────────────
upstreams:
- provider: bedrock
# Must equal the region you provision in (setup.sh's AWS_REGION /
# terraform's region): the IAM policy's inference-profile ARNs are scoped
# to that region, and Bedrock model access is enabled there (cross-region
# us.anthropic.* profiles need access in every spanned region). NOTE: the
# walkthrough is scoped to US regions — the built-in model catalog maps to
# us.anthropic.* (US-geo) profiles; a non-US region also needs a models:
# list below (see the model catalog section).
region: REPLACE_ME # e.g. us-east-1
auth: {} # AWS default credential chain: ECS task role / IRSA on EKS (preferred — no static keys)
# base_url: https://bedrock-runtime.us-east-1.amazonaws.com # bedrock-runtime interface VPC endpoint, to keep model traffic off the public path
# Add more upstreams for failover (tried top→bottom on 5xx/timeout/501): a
# second region, or an anthropic/vertex fallback. See
# https://code.claude.com/docs/en/claude-apps-gateway.
# ── Telemetry fan-out (OPTIONAL) ─────────────────────────────────────────────
# The CLI sends OTLP/HTTP to the gateway; the gateway fans out, stamping
# user.id/user.email/user.groups server-side. On AWS, point at an OpenTelemetry
# Collector (e.g. the AWS Distro for OpenTelemetry -> CloudWatch / Managed
# Prometheus). When forward_to and public_url are both configured the gateway
# pushes CLAUDE_CODE_ENABLE_TELEMETRY and the OTEL exporter selectors to every
# client automatically — no per-developer config needed.
# telemetry:
# forward_to:
# - url: https://otel-collector.internal.example.com:4318
# headers:
# Authorization: ${file:/secrets/otlp-token}
# metrics: true # safe aggregate counters (default)
# logs: false # carries bash commands / tool inputs — opt in deliberately
# traces: false
# ── RBAC + managed settings (OPTIONAL; first-match-wins, top -> bottom) ───────
# With Okta as IdP, match on the group names the `groups` scope emits (subject
# to the app's groups claim filter), or on email_domain.
# managed:
# policies:
# - match: { groups: [engineering] }
# cli:
# availableModels: [claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5]
# permissions: { deny: ["Read(./.env)", "Read(./secrets/**)"] }
# - match: {} # catch-all floor — keep LAST
# cli:
# availableModels: [claude-sonnet-4-6, claude-haiku-4-5]
# ── Admin API (OPTIONAL — enables db-mode runtime config + spend caps) ───────
# admin_groups needs a groups claim — Okta provides one via the `groups` scope
# above — or use the bootstrap keys below instead. Named keys for attribution
# in the audit log; 32-char minimum on key values. On ECS add these to the task
# definition's `secrets` field (valueFrom -> a Secrets Manager ARN), same as the
# JWT/OIDC/Postgres secrets above; on EKS you may use ${file:...}.
# admin:
# write_keys:
# - id: terraform
# key: ${GATEWAY_ADMIN_WRITE_KEY}
# read_keys:
# - id: reporting
# key: ${GATEWAY_ADMIN_READ_KEY}
# # admin_groups: [platform-finops] # Okta group names via the groups scope
# ── Model catalog (OPTIONAL for US regions) ──────────────────────────────────
# Default true: every built-in Claude model is exposed and auto-translated per
# upstream (the built-in table already maps to us.anthropic.* cross-region
# inference profiles). Set false + a models: list to pin IDs (e.g. an
# application or provisioned-throughput inference-profile ARN).
# NON-US REGIONS: the built-in us.anthropic.* mappings do not exist outside
# the US geo — set auto_include_builtin_models: false and list your region's
# inference profiles (eu.anthropic.*, apac.anthropic.*, ...) here, and widen
# the geo prefix in the deploy's bedrock-invoke IAM policy to match. See the
# models: guidance in the config reference:
# https://code.claude.com/docs/en/claude-apps-gateway-config
# auto_include_builtin_models: true
# models:
# - id: claude-opus-4-8
# label: Claude Opus 4.8
# upstream_model: { bedrock: us.anthropic.claude-opus-4-8 }

964
examples/gateway/aws/setup.sh Executable file
View File

@@ -0,0 +1,964 @@
#!/usr/bin/env bash
#
# setup.sh — AWS setup for Claude apps gateway (walkthrough §17, ECS track).
#
# Provisions, in this order: the three security groups (§1), the task +
# execution IAM roles (§2), the gateway container image in Amazon ECR (§6),
# an RDS for PostgreSQL instance in the private subnets with no public
# address (§3), the JWT + postgres-url secrets (§5), and an ECS Fargate
# service behind an internal Application Load Balancer (§7).
#
# gateway.yaml (§4 of the walkthrough) is BAKED INTO THE IMAGE on this track —
# the task definition injects only the secrets it references, as env vars — so
# the config step here lives inside the image build (§6): the build is gated on
# a fully filled-in gateway.yaml and the image tag carries a hash of it, so a
# config edit triggers a rebuild on the next run.
#
# Section markers (§N) below map to the walkthrough:
# https://code.claude.com/docs/en/claude-apps-gateway-on-aws
#
# Covers here: security groups (§1) -> IAM roles + Bedrock model-access note (§2)
# -> build & push image, config baked in (§6 + §4) -> DB subnet group
# + RDS instance (§3) -> jwt + postgres-url secrets (§5) -> ECS
# cluster/task definition/service + internal ALB (§7, ECS Fargate tab).
# Not covered: EKS track (§7's EKS tab) — ECS Fargate is the lower-friction path here.
# Bedrock model access (§2) — console-only; the script reminds you.
# Route 53 alias — see the next steps it prints. Client MDM
# push (§8) is covered by the walkthrough, not this script.
#
# Idempotent: existing resources are detected and skipped, so it is safe to re-run.
# Reuse is by NAME, so a pre-existing resource may not match what this script
# would have created: reuse that would change the exposure model is fatal (an
# ALB that is not internal/in ${VPC_ID}); upsert-able settings are converged on
# every run; other posture drift (extra security group ingress, a public or
# unencrypted RDS instance, wrong-VPC target group) is checked and warned
# about, never silently adopted.
# Override any default below via environment variable, e.g. `AWS_REGION=us-west-2 ./setup.sh`.
set -euo pipefail
# ---- configuration (env-overridable) ----------------------------------------
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)
ACCOUNT_ID="${ACCOUNT_ID:-$(aws sts get-caller-identity --query Account --output text 2>/dev/null || true)}"
VPC_ID="${VPC_ID:-}" # REQUIRED — the VPC from the prerequisites
PRIVATE_SUBNETS="${PRIVATE_SUBNETS:-}" # REQUIRED — two+ private subnet IDs in different AZs, space-separated
CORP_CIDR="${CORP_CIDR:-}" # REQUIRED — your corporate network CIDR (ALB :443 ingress source)
# Must not overlap PRIVATE_SUBNETS: hosts there are trusted_proxies (gateway.yaml) and could spoof client IPs via X-Forwarded-For.
# §1 security groups
ALB_SG_NAME="${ALB_SG_NAME:-claude-gateway-alb}"
GW_SG_NAME="${GW_SG_NAME:-claude-gateway-svc}"
DB_SG_NAME="${DB_SG_NAME:-claude-gateway-db}"
# §2 IAM roles (task role = the gateway's runtime AWS identity; execution role
# = the ECS agent's identity for pulling the image and injecting secrets)
TASK_ROLE="${TASK_ROLE:-claude-gateway-task}"
EXEC_ROLE="${EXEC_ROLE:-claude-gateway-execution}"
# §6 image
ECR_REPO="${ECR_REPO:-claude-gateway}" # ECR repository name
VERSION="${VERSION:-}" # REQUIRED — the gateway release tag you build and push (e.g. the linux-x64 binary's version)
DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # prebuilt linux-x64 Claude Code release binary (includes the gateway subcommand)
DIST_URL="${DIST_URL:-}" # optional: download URL, used only if $CLAUDE_BINARY is missing
DIST_SHA256="${DIST_SHA256:-}" # REQUIRED with DIST_URL: expected sha256 of the binary (verified fail-closed)
DIST_SHA256="${DIST_SHA256,,}" # normalize to lowercase — openssl emits lowercase hex; some tools (PowerShell Get-FileHash) publish uppercase
# Obtain DIST_SHA256 out-of-band — never from the server that serves DIST_URL.
# For binaries from the standard Claude Code release channel, verify the
# release's GPG-signed manifest.json and copy the platform checksum from it:
# https://code.claude.com/docs/en/setup#binary-integrity-and-code-signing
# For any other distribution channel, use the checksum published alongside the
# download link on that channel.
GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §4 config file — BAKED into the image
RDS_CA_BUNDLE="${RDS_CA_BUNDLE:-./rds-global-bundle.pem}" # RDS CA trust anchor — BAKED into the image (downloaded below if missing)
# Official AWS RDS truststore. AWS rotates this bundle (new regional CAs get
# appended), so no checksum is pinned — a pinned hash would break on every
# rotation. The script downloads it only when absent (an existing file is never
# re-downloaded); to pick up a rotation, delete the file — and since the image
# tag hashes only gateway.yaml, also bump VERSION or set IMAGE_TAG so the
# next run rebuilds rather than reusing the existing tag. Operators who want
# to pin may pre-place a reviewed copy at ${RDS_CA_BUNDLE}.
RDS_CA_BUNDLE_URL="${RDS_CA_BUNDLE_URL:-https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem}"
REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
# §3 RDS
DB_SUBNET_GROUP="${DB_SUBNET_GROUP:-claude-gateway-db}"
DB_PARAM_GROUP="${DB_PARAM_GROUP:-claude-gateway-db}" # carries rds.force_ssl=1 (server-side TLS enforcement)
DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
DB_CLASS="${DB_CLASS:-db.t4g.micro}"
DB_STORAGE_GB="${DB_STORAGE_GB:-20}"
DB_NAME="${DB_NAME:-claude_gateway}"
DB_USER="${DB_USER:-gateway}"
# PG14+ supported; 16 is the recommended default (matches terraform/'s).
# Always pinned: the instance's engine version and the parameter group's
# family must name the same major, so both derive from this one value.
DB_ENGINE_VERSION="${DB_ENGINE_VERSION:-16}"
SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Okta OIDC web app)
# NOTE: the execution role's secrets-read policy (§2) is built from these
# three names, one per-secret ARN prefix each — a rename is picked up on the
# next run (put-role-policy is an upsert).
# §7 ECS + internal ALB deploy
CLUSTER="${CLUSTER:-claude-gateway}"
SERVICE="${SERVICE:-claude-gateway}"
TASK_FAMILY="${TASK_FAMILY:-claude-gateway}"
LOG_GROUP="${LOG_GROUP:-/ecs/claude-gateway}"
LOG_RETENTION_DAYS="${LOG_RETENTION_DAYS:-90}" # CloudWatch retention — the group carries the gateway's audit events, so align with your audit retention policy
ALB_NAME="${ALB_NAME:-claude-gateway}"
TG_NAME="${TG_NAME:-claude-gateway}"
# Explicit modern TLS policy — omitting it falls back to the legacy
# ELBSecurityPolicy-2016-08 default, which still accepts TLS 1.0/1.1.
ALB_SSL_POLICY="${ALB_SSL_POLICY:-ELBSecurityPolicy-TLS13-1-2-2021-06}"
ACM_CERT_ARN="${ACM_CERT_ARN:-}" # REQUIRED for deploy — ACM cert for your internal gateway hostname
TASK_CPU="${TASK_CPU:-1024}"
TASK_MEMORY="${TASK_MEMORY:-2048}"
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)
DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no ECS/ALB deploy
# ---- helpers ----------------------------------------------------------------
log() { printf '\n==> %s\n' "$*"; }
skip() { printf ' (exists) %s\n' "$*"; }
curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
# authorize-security-group-ingress is NOT idempotent (re-adding a rule errors),
# so tolerate exactly the duplicate-rule error and fail on anything else.
authorize_ingress() {
local out
if out="$(aws ec2 authorize-security-group-ingress "$@" 2>&1)"; then
return 0
elif grep -q 'InvalidPermission.Duplicate' <<<"${out}"; then
skip "ingress rule already present"
else
printf '%s\n' "${out}" >&2
return 1
fi
}
# Security-group lookup by name within the VPC; prints the GroupId or "None".
sg_id() {
aws ec2 describe-security-groups \
--filters "Name=group-name,Values=$1" "Name=vpc-id,Values=${VPC_ID}" \
--query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || echo None
}
# Name-based reuse can adopt a pre-existing group carrying ingress this script
# never added. Audit after the intended rule is ensured: each group's traffic
# path is exactly one rule (tcp <port> from <cidr-or-source-group>), so anything
# else is flagged on stderr. Non-fatal — an extra rule may be a deliberate
# operator addition — but every one widens the path, so it must be visible.
warn_unexpected_ingress() { # <group-id> <group-name> <port> <expected cidr or source group-id>
local perms
if ! perms="$(aws ec2 describe-security-groups --group-ids "$1" \
--query 'SecurityGroups[0].IpPermissions' --output json 2>/dev/null)"; then
echo " WARN — could not audit ingress rules on $2 ($1)." >&2
return 0
fi
# `|| echo` keeps a parse hiccup non-fatal — this audit must never abort a run.
_SG_ID="$1" _SG_NAME="$2" _SG_PORT="$3" _SG_EXPECTED="$4" python3 -c "
import json, os, sys
perms = json.load(sys.stdin) or []
port, expected = int(os.environ[\"_SG_PORT\"]), os.environ[\"_SG_EXPECTED\"]
extras = []
for p in perms:
proto, lo, hi = p.get(\"IpProtocol\"), p.get(\"FromPort\"), p.get(\"ToPort\")
scope_ok = proto == \"tcp\" and lo == port and hi == port
sources = (
[r.get(\"CidrIp\", \"?\") for r in p.get(\"IpRanges\", [])]
+ [r.get(\"CidrIpv6\", \"?\") for r in p.get(\"Ipv6Ranges\", [])]
+ [r.get(\"GroupId\", \"?\") for r in p.get(\"UserIdGroupPairs\", [])]
+ [r.get(\"PrefixListId\", \"?\") for r in p.get(\"PrefixListIds\", [])]
)
extras += [(proto, lo, hi, s) for s in sources if not (scope_ok and s == expected)]
if extras:
name, gid = os.environ[\"_SG_NAME\"], os.environ[\"_SG_ID\"]
print(f\" WARN — security group {name} ({gid}) has ingress beyond the intended rule\", file=sys.stderr)
print(f\" (tcp {port} from {expected}) — review it; remove anything you did not add deliberately:\", file=sys.stderr)
for proto, lo, hi, src in extras:
scope = \"all traffic\" if proto == \"-1\" else (f\"{proto} {lo}\" if lo == hi else f\"{proto} {lo}-{hi}\")
print(f\" {scope} from {src}\", file=sys.stderr)
" <<<"${perms}" || echo " WARN — could not audit ingress rules on $2 ($1)." >&2
}
secret_arn() {
aws secretsmanager describe-secret --secret-id "$1" \
--query ARN --output text 2>/dev/null || true
}
# Existence check that fails closed: 0 = exists, 1 = definitively absent
# (ResourceNotFoundException), anything else ABORTS the run. Gating on a bare
# exit status would let a transient failure (throttle, expired token, network
# blip) masquerade as "secret missing" — and the missing-secret branches below
# do destructive work (the §3 self-heal resets the DB password), so they must
# run only on a definitive not-found.
secret_exists() { # <secret-id>
local out
if out="$(aws secretsmanager describe-secret --secret-id "$1" 2>&1 >/dev/null)"; then
return 0
elif grep -q 'ResourceNotFoundException' <<<"${out}"; then
return 1
else
echo "ERROR: could not determine whether secret $1 exists (transient AWS error?):" >&2
printf '%s\n' "${out}" >&2
echo " Refusing to guess — re-run once the call succeeds." >&2
exit 1
fi
}
# Secret values must never appear on a process argv (argv is world-readable
# via /proc and routinely recorded by EDR/auditd), so every aws call that
# carries one takes it via --cli-input-json file://<0600 temp file> instead —
# explicit flags on the same command line override/merge with the JSON, so
# only the secret parameter needs to live in the file. secret_json writes
# {"<Key>": "<value>"} to a fresh temp file and returns the path in the named
# variable (printf -v, not command substitution — a subshell would lose the
# SECRET_TMP_FILES bookkeeping below): the value crosses into python3 via the
# environment (never argv) and json.dumps escapes it, so any characters
# survive. Callers rm -f the file as soon as the aws call returns; the EXIT
# trap sweeps whatever an aborted run leaves.
SECRET_TMP_FILES=()
cleanup_secret_tmp() { rm -f "${SECRET_TMP_FILES[@]+"${SECRET_TMP_FILES[@]}"}"; }
trap cleanup_secret_tmp EXIT
secret_json() { # secret_json <outvar> <JsonKey> <value> -> path in <outvar>
local file
file="$(mktemp)" # mktemp creates 0600
chmod 600 "${file}" # belt and braces if TMPDIR overrides umask semantics
SECRET_TMP_FILES+=("${file}")
_JSON_KEY="$2" _JSON_VALUE="$3" python3 -c \
'import json, os; print(json.dumps({os.environ["_JSON_KEY"]: os.environ["_JSON_VALUE"]}))' \
> "${file}"
printf -v "$1" '%s' "${file}"
}
for required in AWS_REGION ACCOUNT_ID VPC_ID PRIVATE_SUBNETS CORP_CIDR VERSION; do
if [[ -z "${!required}" ]]; then
echo "ERROR: ${required} is not set." >&2
case "${required}" in
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 ;;
ACCOUNT_ID) echo " Could not resolve it from STS — is the AWS CLI authenticated? (aws sts get-caller-identity)" >&2 ;;
VPC_ID) echo " Set it to the VPC from the prerequisites, e.g. export VPC_ID=vpc-..." >&2 ;;
PRIVATE_SUBNETS) echo " Set it to two+ private subnet IDs in different AZs, e.g. export PRIVATE_SUBNETS='subnet-a subnet-b'" >&2 ;;
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 ;;
VERSION) echo " Set it to the gateway release version — it tags the image you build and push, e.g. export VERSION=<version>" >&2 ;;
esac
exit 1
fi
done
# The walkthrough (and this bundle) is scoped to commercial US regions: the
# task role's Bedrock policy (§2) and the gateway's built-in model catalog
# both use the us.anthropic.* geo-prefixed cross-region inference profiles,
# which only exist in the commercial US regions — an explicit list, not a
# `us-*` prefix match, because GovCloud (us-gov-*) and ISO (us-iso-*) regions
# share the prefix but live in different AWS partitions where those profiles
# and this bundle's arn:aws: ARNs are wrong. Anywhere else the deploy
# provisions fine and then every model call fails. Other-region deploys must
# pin region-appropriate inference profiles via a models: block in
# gateway.yaml (see the config reference:
# https://code.claude.com/docs/en/claude-apps-gateway-config) and adjust the
# inference-profile ARN prefix in bedrock-invoke.iam.json below — set
# ALLOW_NON_US_REGION=1 once that's done to proceed.
case "${AWS_REGION}" in
us-east-1|us-east-2|us-west-1|us-west-2) ;;
*)
if [[ "${ALLOW_NON_US_REGION:-0}" != "1" ]]; then
echo "ERROR: AWS_REGION=${AWS_REGION} is not a commercial US region, but this bundle's IAM policy" >&2
echo " and model IDs use the US-geo (us.anthropic.*) cross-region inference profiles" >&2
echo " (GovCloud/ISO regions are different partitions — the profiles and arn:aws: ARNs" >&2
echo " here do not exist there)." >&2
echo " Either deploy to us-east-1/us-east-2/us-west-1/us-west-2, or pin region-appropriate" >&2
echo " inference profiles in a models: block in gateway.yaml" >&2
echo " (https://code.claude.com/docs/en/claude-apps-gateway-config), adjust the" >&2
echo " inference-profile ARN in the bedrock-invoke policy, and re-run with" >&2
echo " ALLOW_NON_US_REGION=1." >&2
exit 1
fi
;;
esac
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required (it JSON-escapes secret values for --cli-input-json; the AWS CLI itself ships on Python)." >&2
exit 1
fi
# shellcheck disable=SC2086 # PRIVATE_SUBNETS is intentionally word-split everywhere below
set -- ${PRIVATE_SUBNETS}
if (( $# < 2 )); then
echo "ERROR: PRIVATE_SUBNETS must list at least two subnets in different AZs (the internal ALB requires two)." >&2
exit 1
fi
# Normalize whatever whitespace (spaces, tabs, newlines) separates the list —
# `set --` above word-split on IFS, so join those same words with commas
# rather than only converting single spaces.
SUBNETS_CSV="$(printf '%s,' "$@")"; SUBNETS_CSV="${SUBNETS_CSV%,}"
log "Account: ${ACCOUNT_ID} Region: ${AWS_REGION} VPC: ${VPC_ID}"
# ---- 1 Security groups -----------------------------------------------------
# Three groups chain the traffic path (walkthrough §1): corp network -> ALB :443,
# ALB -> gateway :8080, gateway -> Postgres :5432. Nothing else is reachable.
log "Creating security groups (§1)"
ALB_SG="$(sg_id "${ALB_SG_NAME}")"
if [[ "${ALB_SG}" != "None" ]]; then
skip "security group ${ALB_SG_NAME} (${ALB_SG})"
else
ALB_SG="$(aws ec2 create-security-group --group-name "${ALB_SG_NAME}" \
--description "Claude gateway ALB" --vpc-id "${VPC_ID}" \
--query GroupId --output text)"
fi
GW_SG="$(sg_id "${GW_SG_NAME}")"
if [[ "${GW_SG}" != "None" ]]; then
skip "security group ${GW_SG_NAME} (${GW_SG})"
else
GW_SG="$(aws ec2 create-security-group --group-name "${GW_SG_NAME}" \
--description "Claude gateway service" --vpc-id "${VPC_ID}" \
--query GroupId --output text)"
fi
DB_SG="$(sg_id "${DB_SG_NAME}")"
if [[ "${DB_SG}" != "None" ]]; then
skip "security group ${DB_SG_NAME} (${DB_SG})"
else
DB_SG="$(aws ec2 create-security-group --group-name "${DB_SG_NAME}" \
--description "Claude gateway Postgres" --vpc-id "${VPC_ID}" \
--query GroupId --output text)"
fi
authorize_ingress --group-id "${ALB_SG}" --protocol tcp --port 443 --cidr "${CORP_CIDR}"
authorize_ingress --group-id "${GW_SG}" --protocol tcp --port 8080 --source-group "${ALB_SG}"
authorize_ingress --group-id "${DB_SG}" --protocol tcp --port 5432 --source-group "${GW_SG}"
# Flag any ingress beyond the three rules above (pre-existing groups may carry more).
warn_unexpected_ingress "${ALB_SG}" "${ALB_SG_NAME}" 443 "${CORP_CIDR}"
warn_unexpected_ingress "${GW_SG}" "${GW_SG_NAME}" 8080 "${ALB_SG}"
warn_unexpected_ingress "${DB_SG}" "${DB_SG_NAME}" 5432 "${GW_SG}"
# ---- 2 IAM roles ------------------------------------------------------------
# Task role: the gateway's runtime identity — its ONLY permission is invoking
# Claude models on Bedrock (the upstream's `auth: {}` resolves to this role via
# the AWS default credential chain). The policy must cover both the cross-region
# inference-profile ARNs and the underlying foundation-model ARNs.
# Execution role: the ECS agent's identity — pulls the image from ECR and
# injects the Secrets Manager values; the gateway never uses it.
log "Creating IAM roles ${TASK_ROLE} + ${EXEC_ROLE} (§2)"
cat > ecs-trust.iam.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
cat > bedrock-invoke.iam.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": [
"arn:aws:bedrock:${AWS_REGION}:${ACCOUNT_ID}:inference-profile/us.anthropic.*",
"arn:aws:bedrock:*::foundation-model/anthropic.*"
]
}]
}
EOF
# One ARN per secret (never a bare gateway-* wildcard, which would also match
# unrelated secrets in a shared account). The trailing -?????? matches exactly
# the random 6-character suffix Secrets Manager appends to every secret's ARN
# (AWS's documented pattern; a trailing -* would be a plain prefix glob and
# also match longer names like ${SECRET_NAME}-prod) — the exact ARNs aren't
# knowable here because the role is created before the secrets are.
cat > secrets-read.iam.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": [
"arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${JWT_SECRET_NAME}-??????",
"arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${OIDC_SECRET_NAME}-??????",
"arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${SECRET_NAME}-??????"
]
}]
}
EOF
if aws iam get-role --role-name "${TASK_ROLE}" >/dev/null 2>&1; then
skip "role ${TASK_ROLE}"
else
aws iam create-role --role-name "${TASK_ROLE}" \
--assume-role-policy-document file://ecs-trust.iam.json >/dev/null
fi
# put-role-policy is an upsert — safe to re-run (it also picks up region changes).
aws iam put-role-policy --role-name "${TASK_ROLE}" \
--policy-name bedrock-invoke --policy-document file://bedrock-invoke.iam.json
if aws iam get-role --role-name "${EXEC_ROLE}" >/dev/null 2>&1; then
skip "role ${EXEC_ROLE}"
else
aws iam create-role --role-name "${EXEC_ROLE}" \
--assume-role-policy-document file://ecs-trust.iam.json >/dev/null
fi
# attach-role-policy is idempotent (re-attaching is a no-op).
aws iam attach-role-policy --role-name "${EXEC_ROLE}" \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam put-role-policy --role-name "${EXEC_ROLE}" \
--policy-name read-gateway-secrets --policy-document file://secrets-read.iam.json
echo " NOTE: Bedrock model access is console-only — enable it for the Claude models"
echo " you need (Bedrock console -> Model access), and submit the one-time use"
echo " case form for the account. Cross-region inference profiles"
echo " (us.anthropic.*) need access in EACH region the profile spans."
# ---- 6 Build & push image to Amazon ECR (config baked in — §6 + §4) ---------
log "Ensuring ECR repository and image (§6)"
if aws ecr describe-repositories --repository-names "${ECR_REPO}" >/dev/null 2>&1; then
skip "ECR repository ${ECR_REPO}"
# Integrity-critical settings: converge on re-runs so a pre-existing MUTABLE repo can't slip through.
aws ecr put-image-tag-mutability --repository-name "${ECR_REPO}" \
--image-tag-mutability IMMUTABLE >/dev/null
aws ecr put-image-scanning-configuration --repository-name "${ECR_REPO}" \
--image-scanning-configuration scanOnPush=true >/dev/null
else
# IMMUTABLE tags + scan-on-push: the ECS service pulls whatever this repo
# serves under the deployed tag, so a pushed tag must never be silently
# re-pointed. For production, also restrict push rights on this repo to your
# CI / image-promotion pipeline rather than operator credentials — this
# walkthrough pushes directly for simplicity.
aws ecr create-repository --repository-name "${ECR_REPO}" \
--image-tag-mutability IMMUTABLE \
--image-scanning-configuration scanOnPush=true >/dev/null
fi
# The config is baked into the image, so the build is gated the way the GCP
# example gates its config-secret publish: gateway.yaml must exist and be fully
# filled in (REPLACE_ME checked on non-comment lines so commented examples and
# the file's header don't trip the guard). The tag carries a hash of the config
# so an edit produces a NEW tag (required by tag immutability) and a re-run
# rebuilds automatically.
IMAGE=""
if [[ ! -f "${GATEWAY_YAML}" ]]; then
echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§4)."
elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
echo " Fill them in, then re-run to build the image (the config is baked in)."
else
CONFIG_SHA="$(sha_of "${GATEWAY_YAML}" | cut -c1-8)"
IMAGE_TAG="${IMAGE_TAG:-${VERSION}-cfg${CONFIG_SHA}}"
IMAGE="${REGISTRY}/${ECR_REPO}:${IMAGE_TAG}"
# Image is the expensive, already-done step: skip the build+push entirely if
# the tag already exists in the registry.
if aws ecr describe-images --repository-name "${ECR_REPO}" \
--image-ids "imageTag=${IMAGE_TAG}" >/dev/null 2>&1; then
skip "image ${IMAGE}"
else
# When the expected checksum is known, verify a PRE-EXISTING binary too:
# the [[ ! -f ]] guard below otherwise trusts whatever is on disk, so a
# stale binary from an earlier VERSION (or a tampered one) would be baked
# into the image silently. On mismatch, set it aside (never delete — the
# mismatch may be a typo'd DIST_SHA256, not a bad binary) and fall through
# to the fail-closed download path. Without DIST_SHA256 the operator-
# provided-binary flow is unchanged — no checksum was declared, so none is
# checked.
QUARANTINED_SHA=""
if [[ -n "${DIST_SHA256}" && -f "${CLAUDE_BINARY}" ]]; then
existing_sha="$(sha_of "${CLAUDE_BINARY}")"
if [[ "${existing_sha}" != "${DIST_SHA256}" ]]; then
log "Existing ${CLAUDE_BINARY} sha256 ${existing_sha} does not match DIST_SHA256 — setting it aside as ${CLAUDE_BINARY}.bad"
mv -f "${CLAUDE_BINARY}" "${CLAUDE_BINARY}.bad"
QUARANTINED_SHA="${existing_sha}"
fi
fi
if [[ ! -f "${CLAUDE_BINARY}" ]]; then
if [[ -n "${DIST_URL}" ]]; then
# Fail closed: never download an executable we can't verify.
if [[ -z "${DIST_SHA256}" ]]; then
echo "ERROR: DIST_SHA256 must be set when DIST_URL is used — refusing to download an unverified binary." >&2
echo " Set DIST_SHA256 to the expected sha256 of the binary at DIST_URL, obtained out-of-band:" >&2
echo " for standard-release binaries, from the release's GPG-signed manifest.json (verify the" >&2
echo " manifest signature first — see code.claude.com/docs/en/setup#binary-integrity-and-code-signing);" >&2
echo " otherwise from the channel that published the download link, never from the download server." >&2
exit 1
fi
log "Downloading gateway binary from ${DIST_URL}"
# Download to a temp path and only mv into place after the checksum
# verifies, so an interrupted download can't leave a partial CLAUDE_BINARY
# that the [[ ! -f ]] guard above would skip — and silently push — on re-run.
# Refuse plaintext/protocol-downgrade; only follow HTTPS redirects.
dl_tmp="${CLAUDE_BINARY}.download"
rm -f "${dl_tmp}"
curl_https -fL -o "${dl_tmp}" "${DIST_URL}"
actual_sha="$(sha_of "${dl_tmp}")"
if [[ "${actual_sha}" != "${DIST_SHA256}" ]]; then
echo "ERROR: checksum mismatch for ${dl_tmp} (expected ${DIST_SHA256}, got ${actual_sha}) — refusing to build." >&2
rm -f "${dl_tmp}"
exit 1
fi
log "Verified binary sha256 ${actual_sha}"
chmod +x "${dl_tmp}"
mv -f "${dl_tmp}" "${CLAUDE_BINARY}"
else
echo "ERROR: build binary not found at ${CLAUDE_BINARY} and DIST_URL is not set." >&2
if [[ -n "${QUARANTINED_SHA}" ]]; then
echo " The binary that WAS there had sha256 ${QUARANTINED_SHA}, which does not match" >&2
echo " DIST_SHA256=${DIST_SHA256} — it was preserved as ${CLAUDE_BINARY}.bad." >&2
echo " If DIST_SHA256 was a typo, fix it and move the file back:" >&2
echo " mv '${CLAUDE_BINARY}.bad' '${CLAUDE_BINARY}'" >&2
echo " Otherwise treat that file as untrusted and obtain a verified binary." >&2
fi
echo " Provide the prebuilt linux-x64 Claude Code release binary at that path" >&2
echo " or set DIST_URL to its download URL (see the walkthrough, §6)." >&2
exit 1
fi
fi
# The RDS CA bundle is baked into the image as the trust anchor for the
# connection string's sslmode=verify-full (§3/§5). Fail closed: no bundle,
# no build. AWS rotates the bundle, so no checksum is pinned (see the
# RDS_CA_BUNDLE_URL comment up top); the sanity check below catches an
# error page or truncated download.
if [[ ! -f "${RDS_CA_BUNDLE}" ]]; then
log "Downloading RDS CA bundle from ${RDS_CA_BUNDLE_URL}"
curl_https -fL -o "${RDS_CA_BUNDLE}" "${RDS_CA_BUNDLE_URL}"
fi
if ! grep -q 'BEGIN CERTIFICATE' "${RDS_CA_BUNDLE}" \
|| (( "$(wc -c < "${RDS_CA_BUNDLE}")" < 10000 )); then
echo "ERROR: ${RDS_CA_BUNDLE} does not look like the RDS CA bundle (missing PEM blocks or implausibly small) — refusing to build." >&2
echo " Delete it and re-run to re-download, or place the bundle from ${RDS_CA_BUNDLE_URL} there yourself." >&2
exit 1
fi
log "Building and pushing ${IMAGE}"
aws ecr get-login-password --region "${AWS_REGION}" \
| docker login --username AWS --password-stdin "${REGISTRY}"
# The task definition below runs linux/amd64 (cpuArchitecture X86_64);
# --platform forces it (e.g. when building on an Apple Silicon Mac), and
# --provenance=false keeps buildx from wrapping the result in an OCI image
# index that some pullers reject. For Fargate on ARM64 (Graviton), build
# linux/arm64 with the linux-arm64 binary and set cpuArchitecture to ARM64.
docker build --platform=linux/amd64 --provenance=false \
-f "${DOCKERFILE}" \
--build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" \
--build-arg GATEWAY_CONFIG="${GATEWAY_YAML}" \
--build-arg RDS_CA_BUNDLE="${RDS_CA_BUNDLE}" \
-t "${IMAGE}" .
docker push "${IMAGE}"
fi
fi
# ---- 3 RDS for PostgreSQL (private subnets, no public address) --------------
log "Creating DB subnet group ${DB_SUBNET_GROUP} (§3)"
if aws rds describe-db-subnet-groups --db-subnet-group-name "${DB_SUBNET_GROUP}" >/dev/null 2>&1; then
skip "DB subnet group ${DB_SUBNET_GROUP}"
else
# shellcheck disable=SC2086 # subnet IDs are separate arguments by design
aws rds create-db-subnet-group --db-subnet-group-name "${DB_SUBNET_GROUP}" \
--db-subnet-group-description "Claude gateway" --subnet-ids ${PRIVATE_SUBNETS} >/dev/null
fi
# Parameter group with rds.force_ssl=1: the server side of TLS enforcement —
# the client side is sslmode=verify-full in the connection string (§5). The
# family must match the engine major version, so it derives from the same
# DB_ENGINE_VERSION that create-db-instance pins below.
log "Ensuring DB parameter group ${DB_PARAM_GROUP} (rds.force_ssl=1)"
PG_FAMILY="postgres${DB_ENGINE_VERSION%%.*}"
if aws rds describe-db-parameter-groups --db-parameter-group-name "${DB_PARAM_GROUP}" >/dev/null 2>&1; then
skip "DB parameter group ${DB_PARAM_GROUP}"
else
aws rds create-db-parameter-group --db-parameter-group-name "${DB_PARAM_GROUP}" \
--db-parameter-group-family "${PG_FAMILY}" \
--description "Claude gateway - require TLS on every connection" >/dev/null
fi
# modify-db-parameter-group is an upsert — applied every run so a pre-existing
# group converges too. rds.force_ssl is dynamic; no reboot needed.
aws rds modify-db-parameter-group --db-parameter-group-name "${DB_PARAM_GROUP}" \
--parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=immediate" >/dev/null
# hex (not base64) keeps the password URL-safe for the connection string below.
# The password reaches every aws call via --cli-input-json (never argv — see
# the secret_json helper); explicit flags merge with (and would override) the
# JSON, so only the password lives in the temp file.
log "Creating RDS instance ${DB_INSTANCE} (private subnets, --no-publicly-accessible)"
DB_PASSWORD=""
DB_POSTURE="$(aws rds describe-db-instances --db-instance-identifier "${DB_INSTANCE}" \
--query 'DBInstances[0].[PubliclyAccessible,StorageEncrypted]' --output text 2>/dev/null || true)"
if [[ -n "${DB_POSTURE}" ]]; then
# Name-based reuse: a pre-existing instance may not carry the posture this
# script would have created it with. Non-fatal (the operator may be migrating
# an existing DB on purpose), but drift from the guide's baseline must be seen.
read -r DB_PUBLIC DB_ENCRYPTED <<<"${DB_POSTURE}"
if [[ "${DB_PUBLIC}" == "True" ]]; then
echo " WARN — RDS instance ${DB_INSTANCE} is PubliclyAccessible; this script would have" >&2
echo " created it with --no-publicly-accessible. Fix: aws rds modify-db-instance" >&2
echo " --db-instance-identifier ${DB_INSTANCE} --no-publicly-accessible --apply-immediately" >&2
fi
if [[ "${DB_ENCRYPTED}" == "False" ]]; then
echo " WARN — RDS instance ${DB_INSTANCE} has StorageEncrypted=false; this script would" >&2
echo " have created it with --storage-encrypted (encryption cannot be enabled in" >&2
echo " place — restore an encrypted snapshot copy to migrate)." >&2
fi
if secret_exists "${SECRET_NAME}"; then
skip "instance ${DB_INSTANCE} (password unchanged; secret not rewritten)"
else
# Self-heal: a previous run died after creating the instance but before
# writing the connection-string secret, losing the only copy of the
# password. The secret is the password's only consumer, so resetting it is
# safe and keeps re-runs able to recover from any partial state.
# secret_exists (not a bare exit-status check) gates this: only a
# definitive ResourceNotFoundException may trigger a password reset.
# ORDERING INVARIANT: the secret write (§5 below) is the heal's commit
# point — everything that can fail must happen BEFORE it, so a crash at
# any point leaves the secret still missing and the next run simply
# repeats the heal. Writing the secret first would invert that: a crash
# between secret write and modify-db-instance would leave an existing
# secret whose password the DB never received, and every later run would
# skip the heal while the gateway can't connect.
# NOTE: the parameter group is attached on create only — an instance that
# predates it keeps its current group (attach via modify-db-instance
# --db-parameter-group-name yourself if you want force_ssl retrofitted).
log "Instance ${DB_INSTANCE} exists but secret ${SECRET_NAME} is missing — resetting password"
DB_PASSWORD="$(openssl rand -hex 24)"
pw_json=""; secret_json pw_json MasterUserPassword "${DB_PASSWORD}"
aws rds modify-db-instance --db-instance-identifier "${DB_INSTANCE}" \
--cli-input-json "file://${pw_json}" --apply-immediately >/dev/null
rm -f "${pw_json}"
fi
else
DB_PASSWORD="$(openssl rand -hex 24)"
pw_json=""; secret_json pw_json MasterUserPassword "${DB_PASSWORD}"
aws rds create-db-instance --db-instance-identifier "${DB_INSTANCE}" \
--engine postgres --engine-version "${DB_ENGINE_VERSION}" \
--db-instance-class "${DB_CLASS}" \
--allocated-storage "${DB_STORAGE_GB}" --db-name "${DB_NAME}" \
--master-username "${DB_USER}" --cli-input-json "file://${pw_json}" \
--db-subnet-group-name "${DB_SUBNET_GROUP}" \
--db-parameter-group-name "${DB_PARAM_GROUP}" \
--vpc-security-group-ids "${DB_SG}" \
--no-publicly-accessible \
--storage-encrypted >/dev/null
rm -f "${pw_json}"
fi
log "Waiting for ${DB_INSTANCE} to become available (first creation takes ~10 min)"
aws rds wait db-instance-available --db-instance-identifier "${DB_INSTANCE}"
DB_HOST="$(aws rds describe-db-instances --db-instance-identifier "${DB_INSTANCE}" \
--query 'DBInstances[0].Endpoint.Address' --output text)"
# ---- 5 Connection string + JWT secret -> Secrets Manager --------------------
# No per-secret IAM grants are needed: the execution role's read-gateway-secrets
# policy (§2) names each of the three secrets by its ARN prefix.
# Secret values go to aws via --cli-input-json temp files, never argv.
if [[ -n "${DB_PASSWORD}" ]]; then
# RDS private endpoint (guide §3); the gateway connects directly over the
# VPC — the DB security group only admits ${GW_SG_NAME}.
# sslmode=verify-full: the gateway's driver honors sslmode from the URL and
# verifies the RDS certificate chain AND hostname against the CA bundle the
# image trusts via NODE_EXTRA_CA_CERTS (see the Dockerfile). Do NOT add a
# libpq-style `sslrootcert=` query param — the driver doesn't read it and
# forwards it to Postgres as a startup parameter, which the server rejects.
CONN="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}?sslmode=verify-full"
log "Storing connection string in Secrets Manager secret ${SECRET_NAME} (§5)"
conn_json=""; secret_json conn_json SecretString "${CONN}"
if secret_exists "${SECRET_NAME}"; then
aws secretsmanager put-secret-value --secret-id "${SECRET_NAME}" \
--cli-input-json "file://${conn_json}" >/dev/null
else
aws secretsmanager create-secret --name "${SECRET_NAME}" \
--cli-input-json "file://${conn_json}" >/dev/null
fi
rm -f "${conn_json}"
else
log "Skipping postgres-url secret write (instance already existed, password not available this run)"
fi
# JWT signing secret — generated once (re-runs do NOT rotate it).
log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
if secret_exists "${JWT_SECRET_NAME}"; then
skip "secret ${JWT_SECRET_NAME}"
else
jwt_json=""; secret_json jwt_json SecretString "$(openssl rand -base64 32)"
aws secretsmanager create-secret --name "${JWT_SECRET_NAME}" \
--cli-input-json "file://${jwt_json}" >/dev/null
rm -f "${jwt_json}"
fi
# OIDC client secret — operator-created (the script can't generate it; it comes
# from the Okta OIDC web application). Checked here so the deploy step below can
# gate on it with a clear message instead of a raw ECS secret-injection failure.
OIDC_ARN="$(secret_arn "${OIDC_SECRET_NAME}")"
# ---- 7 ECS Fargate service + internal ALB ----------------------------------
# Self-gating: deploy only once its inputs exist (image pushed — i.e.
# gateway.yaml was filled in — plus the operator-provided OIDC client secret
# and the ACM certificate for the internal hostname). On a first run these are
# usually missing and it cleanly skips.
ALB_DNS=""
missing=""
[[ -n "${IMAGE}" ]] || missing="${missing} image(fill ${GATEWAY_YAML})"
[[ -n "${OIDC_ARN}" ]] || missing="${missing} ${OIDC_SECRET_NAME}"
[[ -n "${ACM_CERT_ARN}" ]] || missing="${missing} ACM_CERT_ARN"
SECRET_ARN="$(secret_arn "${SECRET_NAME}")"
JWT_ARN="$(secret_arn "${JWT_SECRET_NAME}")"
[[ -n "${SECRET_ARN}" ]] || missing="${missing} ${SECRET_NAME}"
[[ -n "${JWT_ARN}" ]] || missing="${missing} ${JWT_SECRET_NAME}"
if [[ "${DEPLOY}" != "1" ]]; then
log "Skipping ECS/ALB deploy (DEPLOY=${DEPLOY}) (§7)"
elif [[ -n "${missing// }" ]]; then
log "Skipping ECS/ALB deploy — missing input(s):${missing} (§7)"
echo " Fill ${GATEWAY_YAML} and re-run to build the image; create ${OIDC_SECRET_NAME}"
echo " from the Okta client secret; set ACM_CERT_ARN to the certificate for your"
echo " internal gateway hostname. Then re-run to deploy."
else
log "Creating ECS cluster ${CLUSTER} and log group ${LOG_GROUP} (§7)"
if [[ "$(aws ecs describe-clusters --clusters "${CLUSTER}" \
--query 'clusters[0].status' --output text 2>/dev/null)" == "ACTIVE" ]]; then
skip "cluster ${CLUSTER}"
else
aws ecs create-cluster --cluster-name "${CLUSTER}" >/dev/null
fi
# The gateway's stderr carries both its audit events and operational logs.
if aws logs describe-log-groups --log-group-name-prefix "${LOG_GROUP}" \
--query 'logGroups[?logGroupName==`'"${LOG_GROUP}"'`]' --output text 2>/dev/null | grep -q .; then
skip "log group ${LOG_GROUP}"
else
aws logs create-log-group --log-group-name "${LOG_GROUP}"
fi
# Retention is a separate API (create-log-group has no retention flag) and an
# upsert — applied every run so pre-existing groups converge too. Without it
# the group keeps logs forever and cost grows unbounded.
aws logs put-retention-policy --log-group-name "${LOG_GROUP}" \
--retention-in-days "${LOG_RETENTION_DAYS}"
# Task definition: the task role carries the Bedrock permission; the
# execution role injects the secrets. Registering is an append (a new
# revision) — the service below always points at the latest.
log "Registering task definition ${TASK_FAMILY}"
taskdef_tmp="$(mktemp)"
cat > "${taskdef_tmp}" <<EOF
{
"family": "${TASK_FAMILY}",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "${TASK_CPU}",
"memory": "${TASK_MEMORY}",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::${ACCOUNT_ID}:role/${EXEC_ROLE}",
"taskRoleArn": "arn:aws:iam::${ACCOUNT_ID}:role/${TASK_ROLE}",
"containerDefinitions": [
{
"name": "gateway",
"image": "${IMAGE}",
"portMappings": [{ "containerPort": 8080 }],
"secrets": [
{ "name": "GATEWAY_JWT_SECRET", "valueFrom": "${JWT_ARN}" },
{ "name": "OIDC_CLIENT_SECRET", "valueFrom": "${OIDC_ARN}" },
{ "name": "GATEWAY_POSTGRES_URL", "valueFrom": "${SECRET_ARN}" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "gateway"
}
}
}
]
}
EOF
aws ecs register-task-definition --cli-input-json "file://${taskdef_tmp}" >/dev/null
rm -f "${taskdef_tmp}"
# Internal ALB. --ip-address-type ipv4: an internal dual-stack ALB publishes
# public-range AAAA records, which the CLI's /login private-network check
# rejects.
log "Creating internal ALB ${ALB_NAME} + target group + HTTPS listener"
read -r ALB_ARN ALB_SCHEME ALB_VPC ALB_IP_TYPE <<<"$(aws elbv2 describe-load-balancers --names "${ALB_NAME}" \
--query 'LoadBalancers[0].[LoadBalancerArn,Scheme,VpcId,IpAddressType]' --output text 2>/dev/null || true)"
if [[ -n "${ALB_ARN}" && "${ALB_ARN}" != "None" ]]; then
# Reuse is by name, and scheme/VPC are immutable on an ALB — so posture is
# asserted, fail-closed: attaching the gateway to an internet-facing or
# wrong-VPC load balancer would change the exposure model, not just drift.
if [[ "${ALB_SCHEME}" != "internal" || "${ALB_VPC}" != "${VPC_ID}" ]]; then
echo "ERROR: load balancer ${ALB_NAME} exists but is not the internal ALB this script expects:" >&2
echo " scheme=${ALB_SCHEME} (need internal), vpc=${ALB_VPC} (need ${VPC_ID})." >&2
echo " Refusing to deploy the gateway behind it. Delete that load balancer, or set" >&2
echo " ALB_NAME to an unused name, then re-run." >&2
exit 1
fi
skip "load balancer ${ALB_NAME} (internal, ${ALB_VPC})"
# ip-address-type IS mutable (unlike scheme/VPC) — converge a reused
# dualstack ALB back to ipv4, matching the Terraform sibling: dual-stack
# publishes public-range AAAA records that /login rejects (see above).
if [[ "${ALB_IP_TYPE}" != "ipv4" ]]; then
aws elbv2 set-ip-address-type --load-balancer-arn "${ALB_ARN}" \
--ip-address-type ipv4 >/dev/null
fi
else
# shellcheck disable=SC2086
ALB_ARN="$(aws elbv2 create-load-balancer --name "${ALB_NAME}" \
--scheme internal --type application --ip-address-type ipv4 \
--subnets ${PRIVATE_SUBNETS} --security-groups "${ALB_SG}" \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)"
fi
# The ALB closes a connection after 60 seconds with no data by default, which
# cuts off streams during quiet periods (long prompt processing before the
# first token, extended thinking). Attribute setting is idempotent.
aws elbv2 modify-load-balancer-attributes --load-balancer-arn "${ALB_ARN}" \
--attributes Key=idle_timeout.timeout_seconds,Value=3600 >/dev/null
read -r TG_ARN TG_VPC <<<"$(aws elbv2 describe-target-groups --names "${TG_NAME}" \
--query 'TargetGroups[0].[TargetGroupArn,VpcId]' --output text 2>/dev/null || true)"
if [[ -n "${TG_ARN}" && "${TG_ARN}" != "None" ]]; then
skip "target group ${TG_NAME}"
# VPC is immutable on a target group; a wrong-VPC one can't reach the tasks.
if [[ "${TG_VPC}" != "${VPC_ID}" ]]; then
echo " WARN — target group ${TG_NAME} is in ${TG_VPC}, not ${VPC_ID}; the service's tasks" >&2
echo " will not become healthy behind it. Delete it or set TG_NAME to an unused" >&2
echo " name, then re-run." >&2
fi
else
# /readyz verifies the store is reachable, so a task that can't reach
# Postgres never enters rotation (the gateway also serves liveness-only
# /healthz — see the deploy guide's outage-behavior tradeoff).
TG_ARN="$(aws elbv2 create-target-group --name "${TG_NAME}" \
--protocol HTTP --port 8080 --vpc-id "${VPC_ID}" --target-type ip \
--health-check-path /readyz \
--query 'TargetGroups[0].TargetGroupArn' --output text)"
fi
# Select the HTTPS:443 listener specifically — a reused ALB may carry other
# listeners (say HTTP:80); those stay untouched, and the 443 listener is
# still created when it's the one that's missing.
# shellcheck disable=SC2016 # backticks are JMESPath literals, not expansion
LISTENER_ARN="$(aws elbv2 describe-listeners --load-balancer-arn "${ALB_ARN}" \
--query 'Listeners[?Port==`443`]|[0].ListenerArn' --output text 2>/dev/null || true)"
if [[ -n "${LISTENER_ARN}" && "${LISTENER_ARN}" != "None" ]]; then
skip "HTTPS:443 listener on ${ALB_NAME}"
# Converge everything this script owns on pre-existing listeners
# (modify-listener is an upsert): the TLS policy (so re-runs pick up an
# ALB_SSL_POLICY change, and listeners created before this script pinned
# one lose the legacy default), the certificate (so a changed ACM_CERT_ARN
# — e.g. a renewal under a new ARN — is not silently ignored), and the
# default action (so the listener always forwards to this target group).
aws elbv2 modify-listener --listener-arn "${LISTENER_ARN}" \
--ssl-policy "${ALB_SSL_POLICY}" \
--certificates "CertificateArn=${ACM_CERT_ARN}" \
--default-actions "Type=forward,TargetGroupArn=${TG_ARN}" >/dev/null
else
aws elbv2 create-listener --load-balancer-arn "${ALB_ARN}" \
--protocol HTTPS --port 443 \
--ssl-policy "${ALB_SSL_POLICY}" \
--certificates "CertificateArn=${ACM_CERT_ARN}" \
--default-actions "Type=forward,TargetGroupArn=${TG_ARN}" >/dev/null
fi
# Service: created once, then rolled forward — a re-run points it at the
# latest task-definition revision (which carries the current image tag, and
# therefore the current gateway.yaml) and forces a new deployment.
log "Creating/updating ECS service ${SERVICE} (Fargate, private subnets, no public IP)"
svc_status="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \
--query 'services[0].status' --output text 2>/dev/null || true)"
if [[ "${svc_status}" == "ACTIVE" ]]; then
aws ecs update-service --cluster "${CLUSTER}" --service "${SERVICE}" \
--task-definition "${TASK_FAMILY}" --desired-count "${DESIRED_COUNT}" \
--deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
--health-check-grace-period-seconds 60 \
--force-new-deployment >/dev/null
echo " service updated to the latest task-definition revision."
else
# All egress (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs) goes
# through the NAT gateway — assignPublicIp stays DISABLED.
# The deployment circuit breaker stops a rollout whose tasks keep failing
# (bad image, unbootable config) and rolls back to the last steady state
# instead of relaunching failing tasks forever. The health-check grace
# period gives a cold task (image pull + store connect + first /readyz)
# time before ECS counts it unhealthy — without it the circuit breaker can
# declare the very first rollout failed (matches terraform/'s
# health_check_grace_period_seconds).
aws ecs create-service --cluster "${CLUSTER}" --service-name "${SERVICE}" \
--task-definition "${TASK_FAMILY}" --desired-count "${DESIRED_COUNT}" \
--launch-type FARGATE \
--deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
--health-check-grace-period-seconds 60 \
--network-configuration "awsvpcConfiguration={subnets=[${SUBNETS_CSV}],securityGroups=[${GW_SG}],assignPublicIp=DISABLED}" \
--load-balancers "targetGroupArn=${TG_ARN},containerName=gateway,containerPort=8080" >/dev/null
fi
ALB_DNS="$(aws elbv2 describe-load-balancers --load-balancer-arns "${ALB_ARN}" \
--query 'LoadBalancers[0].DNSName' --output text)"
log "Internal ALB DNS: ${ALB_DNS}"
# Post-deploy smoke check: the ALB is internal (unreachable from this
# machine), but target health is visible through the API — poll until the
# /readyz health check passes. Non-fatal; a cold task needs a minute or two
# (image pull + store connect).
log "Smoke check: polling target health on ${TG_NAME} (health check: GET /readyz)"
tg_state="unknown"
for _ in $(seq 1 24); do
tg_state="$(aws elbv2 describe-target-health --target-group-arn "${TG_ARN}" \
--query 'TargetHealthDescriptions[0].TargetHealth.State' --output text 2>/dev/null || true)"
[[ "${tg_state}" == "healthy" ]] && break
sleep 10
done
if [[ "${tg_state}" == "healthy" ]]; then
echo " OK — a gateway task is healthy behind the ALB (store reachable)."
else
echo " WARN — last target state: ${tg_state:-none}; the task may still be starting."
echo " Check the service events and the gateway's logs:"
echo " aws ecs describe-services --cluster ${CLUSTER} --services ${SERVICE} --query 'services[0].events[:5]'"
echo " aws logs tail ${LOG_GROUP} --since 10m"
fi
# public_url is baked into the image, so verify the operator's chosen
# hostname is in place (the redirect URI and discovery doc derive from it).
CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
| head -1 \
| sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
|| true)"
CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
echo " 1. In your Route 53 private hosted zone, alias the host of"
echo " ${CFG_PUBLIC_URL:-<public_url>} to the ALB: ${ALB_DNS}"
echo " (the ALB's own *.elb.amazonaws.com name can't carry your ACM certificate)."
echo " 2. Register this redirect URI on the Okta OIDC web app: ${CFG_PUBLIC_URL:-<public_url>}/oauth/callback"
echo " 3. Verify from inside your corporate network:"
echo " curl -s ${CFG_PUBLIC_URL:-<public_url>}/.well-known/oauth-authorization-server"
fi
# ---- summary ----------------------------------------------------------------
cat <<EOF
==> Done.
Security groups ${ALB_SG_NAME}=${ALB_SG} ${GW_SG_NAME}=${GW_SG} ${DB_SG_NAME}=${DB_SG}
IAM roles ${TASK_ROLE} (bedrock-invoke), ${EXEC_ROLE} (pull + secrets)
Image ${IMAGE:-(not built yet — fill ${GATEWAY_YAML})}
RDS instance ${DB_INSTANCE} -> ${DB_HOST}
Database / user ${DB_NAME} / ${DB_USER}
Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${OIDC_SECRET_NAME}$( [[ -n "${OIDC_ARN}" ]] || printf ' (MISSING — create it)' )
ECS service ${CLUSTER}/${SERVICE} behind ${ALB_DNS:-(not deployed yet)}
Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-aws):
- Create the one operator-provided secret (from the Okta OIDC web app). Put the
client secret in a 0600 file first — passing it as a literal argument would
leave it readable in the process table and in audit/EDR logs:
aws secretsmanager create-secret --name ${OIDC_SECRET_NAME} \\
--secret-string file:///path/to/okta-client-secret.txt
- Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh builds the
image (config baked in) and deploys once the secret and ACM_CERT_ARN exist.
- Enable Bedrock model access in the console for the Claude models you need (per
region the us.anthropic.* profiles span) and submit the one-time use case form.
- Alias your internal hostname (gateway.yaml public_url) to the ALB in a Route 53
private hosted zone, and register <public_url>/oauth/callback on the Okta app.
- The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
EOF

View File

@@ -0,0 +1,19 @@
# Never commit state (contains secrets) or local var files
*.tfstate
*.tfstate.*
.terraform/
terraform.tfvars
*.auto.tfvars
crash.log
# The lock file holds no secrets. It's ignored here so consumers who copy this
# example into their own repo generate (and commit) their own platform-complete
# lock at first init — committing one from this repo would carry only one
# platform's provider hashes. In your copy, drop this line and commit the lock
# produced by:
# terraform providers lock -platform=linux_amd64 -platform=linux_arm64 \
# -platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64
# versions.tf pins by range only, so without a committed lock the registry
# serves the newest in-range build; a platform-complete lock gives hash
# continuity across machines/CI and makes provider upgrades reviewable diffs.
.terraform.lock.hcl

View File

@@ -0,0 +1,182 @@
# Claude apps gateway — Terraform (ECS Fargate)
Terraform equivalent of `../setup.sh`. Lets end-users provision and manage
the gateway with `terraform apply`. Covers the same scope ([walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-aws) §17,
ECS track): security groups → task + execution IAM roles → ECR repository →
private-subnet RDS for PostgreSQL → Secrets Manager secrets → ECS Fargate
service behind an internal ALB. The VPC and private subnets are walkthrough
prerequisites, passed in as variables — unlike the GCP example, no network is
created here.
## Files
| File | Purpose |
|------|---------|
| `versions.tf` | Provider pins (aws, random) |
| `variables.tf` | All inputs (defaults match `setup.sh`'s) |
| `main.tf` | Resources |
| `outputs.tf` | ALB DNS name + zone ID, image, roles, DB endpoint |
| `terraform.tfvars.example` | Copy to `terraform.tfvars` and edit |
## Prerequisites
1. **`../gateway.yaml` created and FULLY filled in** — copy the template first:
`cp ../gateway.yaml.example ../gateway.yaml`, then replace every `REPLACE_ME`
(Terraform reads this file and enforces no `REPLACE_ME` via a precondition).
Unlike the GCP example there is no placeholder-first-pass: the config is
**baked into the image**, and `public_url` is your own internal hostname,
which you choose up front (you already hold its ACM certificate).
`gateway.yaml` is gitignored; the committed template is `gateway.yaml.example`.
2. The **prebuilt linux-x64 `claude` binary at `../claude`** — the Claude Code
release binary, which includes the `gateway` subcommand (see the
[walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-aws)).
See `../setup.sh`'s `DIST_URL`/`DIST_SHA256` download path for a
checksum-verified fetch.
3. A **VPC with two+ private subnets** in different AZs and NAT egress, an **ACM
certificate** for your internal gateway hostname, and **Bedrock model access**
enabled in the console (cross-region `us.anthropic.*` profiles need it in each
region the profile spans), with the one-time use case form submitted.
4. A **remote backend** for shared use (see below). State holds secrets — never commit it.
## Deploy
Terraform creates the ECR repository but does **not** build/push the image, so
the apply is two passes: a targeted apply to create the repo, then build/push,
then the full apply.
```bash
cp terraform.tfvars.example terraform.tfvars # edit it
terraform init
# Pin providers in your copy (once, then commit .terraform.lock.hcl and drop
# its .gitignore line): versions.tf pins by range only, so without a committed
# lock the registry serves the newest in-range build — a platform-complete
# lock gives hash continuity across machines/CI and makes provider upgrades
# reviewable diffs.
terraform providers lock -platform=linux_amd64 -platform=linux_arm64 \
-platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64
# 1. Create just the ECR repository (the -target warning is expected):
terraform apply -target=aws_ecr_repository.repo
# 2. Build and push the image (gateway.yaml and the RDS CA bundle are baked in;
# the COPY sources are context-relative — the build context `..` is aws/, so
# `claude`, `gateway.yaml`, and `rds-global-bundle.pem`).
# The CA bundle is the trust anchor for the connection string's
# sslmode=verify-full (AWS rotates it; download it when absent — don't commit it):
curl -fL --proto '=https' -o ../rds-global-bundle.pem \
https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin <account-id>.dkr.ecr.us-east-1.amazonaws.com
docker build --platform=linux/amd64 --provenance=false \
-f ../Dockerfile --build-arg CLAUDE_BINARY=claude --build-arg GATEWAY_CONFIG=gateway.yaml \
-t <account-id>.dkr.ecr.us-east-1.amazonaws.com/claude-gateway:<version> ..
docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/claude-gateway:<version>
# 3. Full apply:
terraform apply
```
Set in `terraform.tfvars`:
- `region`, `vpc_id`, `private_subnet_ids`, `corporate_cidr`
- `acm_certificate_arn` — the certificate for your internal gateway hostname
(`gateway.yaml`'s `public_url` host), served by the ALB's HTTPS listener
- `image_tag` (after building/pushing — step 2 above). The repo enforces
**immutable tags**, so a `gateway.yaml` edit means a rebuild under a **new**
tag and an `image_tag` bump (`../setup.sh` automates this by tagging
`<version>-cfg<sha8-of-gateway.yaml>`)
- **`oidc_client_secret`** — required (the ECS tasks inject `latest` of this
secret at start; with no version they fail with
`ResourceInitializationError`). Terraform creates the secret + version from it.
## Tear down
Tear down a trial with `terraform destroy`: set `deletion_protection = false`,
run `terraform apply` to record that on RDS and the ALB (and to flip RDS to
`skip_final_snapshot` — the provider checks the value in **state**, not config,
so destroy would still refuse otherwise), then `terraform destroy`.
The same switch drives the Secrets Manager recovery window: the three secrets
have **fixed names**, and a secret deleted with the default 30-day recovery
window keeps its name reserved — a later `terraform apply` would fail with a
name conflict until the window elapses. With `deletion_protection = false` the
destroy deletes them immediately (`recovery_window_in_days = 0`). If you
destroyed a deployment that still had `deletion_protection = true` (or tore
down an older copy of this module), clear the scheduled deletions before
re-applying:
```bash
for s in gateway-postgres-url gateway-jwt-secret gateway-oidc-client-secret; do
aws secretsmanager delete-secret --secret-id "$s" --force-delete-without-recovery
done
```
## Guard rails
Tuned so accidental deletion is hard but greenfield teardown stays easy:
- `deletion_protection = true` (variable, default true) on RDS and the ALB —
blocks accidental deletion; set `false` when you intend to `terraform destroy`.
The same switch controls RDS `skip_final_snapshot`, so a protected instance
always leaves a final snapshot.
- ECR tags are **immutable** and **scanned on push** — a deployed tag can never
be silently re-pointed at different bytes. For production, also restrict push
rights on the repo to your CI / image-promotion pipeline rather than operator
credentials.
- The IAM roles carry only the walkthrough's least-privilege documents: Bedrock
invoke on the Anthropic model ARNs (task role) and `secretsmanager:GetSecretValue`
on exactly the three secrets this module creates (by ARN) plus the AWS-managed
ECS execution policy (execution role). Inline policies are scoped to these
roles, so nothing else in the account is touched.
- TLS everywhere it terminates: the ALB listener pins
`ELBSecurityPolicy-TLS13-1-2-2021-06` (no TLS 1.0/1.1), and the store
connection uses `sslmode=verify-full` against the RDS CA bundle baked into
the image, with `rds.force_ssl=1` enforcing TLS server-side.
## Private access
The ALB is **internal** with `ip_address_type = "ipv4"` (a dual-stack internal
ALB publishes public-range AAAA records, which the CLI's `/login`
private-network check rejects), and its security group admits only
`corporate_cidr` on 443. Reaching it from on-prem requires your existing
routing into the VPC (Direct Connect / VPN) — **operator / network-team-owned**
plumbing this module does not create.
After the apply, give developers a privately resolvable hostname: in a Route 53
private hosted zone, alias the host of `gateway.yaml`'s `public_url` to the ALB
(`alb_dns_name` / `alb_zone_id` outputs). The ALB's own `*.elb.amazonaws.com`
name can't carry your ACM certificate, so use your own name.
The tasks run in the private subnets with no public IP; all egress (Bedrock,
the IdP, Secrets Manager, ECR, CloudWatch Logs) goes through the NAT gateway.
To keep Bedrock traffic off the public path, create a `bedrock-runtime`
interface VPC endpoint and point the upstream's `base_url` at it (see
`../gateway.yaml.example`); the IdP still needs internet egress.
## Remote state (recommended for teams)
Add a backend so state is shared and locked (and out of git):
```hcl
# backend.tf
terraform {
backend "s3" {
bucket = "<your-tf-state-bucket>"
key = "claude-gateway/ecs"
region = "us-east-1"
use_lockfile = true # S3-native locking (Terraform >= 1.10); or set dynamodb_table
}
}
```
## After deploy
- `terraform output alb_dns_name` / `alb_zone_id` — create the Route 53 alias.
- Register `<public_url>/oauth/callback` on the Okta OIDC web app and make sure
`../gateway.yaml` `public_url` matches the host you aliased.
- Notes: Terraform does not build the image. To ship a new gateway version **or
a config edit**, rerun the docker build/push under a new tag and bump
`image_tag` — secrets-only rotations roll the service without a rebuild (the
task definition stamps a hash of the managed secret values), but a
`gateway.yaml` edit reaches the container only through the rebuilt image.

View File

@@ -0,0 +1,510 @@
# Claude apps gateway on ECS Fargate — Terraform equivalent of setup.sh.
# Section markers (§N) map to setup.sh and the walkthrough:
# https://code.claude.com/docs/en/claude-apps-gateway-on-aws
#
# Unlike the GCP example this module does NOT create the network — the VPC and
# private subnets are walkthrough prerequisites, passed in as variables.
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# Read (not created) so a typo'd VPC or subnet ID fails the plan up front
# instead of half-applying.
data "aws_vpc" "this" {
id = var.vpc_id
}
data "aws_subnet" "private" {
for_each = toset(var.private_subnet_ids)
id = each.value
}
locals {
config_path = var.gateway_config_path != "" ? var.gateway_config_path : "${path.module}/../gateway.yaml"
gateway_config = file(local.config_path)
image = "${aws_ecr_repository.repo.repository_url}:${var.image_tag}"
}
# ── 1 Security groups ───────────────────────────────────────────────────────
# Three groups chain the traffic path: corp network -> ALB :443, ALB ->
# gateway :8080, gateway -> Postgres :5432. Nothing else is reachable.
# Rules are separate resources (not inline) so they never fight other tooling.
resource "aws_security_group" "alb" {
name = "claude-gateway-alb"
description = "Claude gateway ALB"
vpc_id = var.vpc_id
}
resource "aws_security_group" "gateway" {
name = "claude-gateway-svc"
description = "Claude gateway service"
vpc_id = var.vpc_id
}
resource "aws_security_group" "db" {
name = "claude-gateway-db"
description = "Claude gateway Postgres"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
description = "HTTPS from the corporate network"
ip_protocol = "tcp"
from_port = 443
to_port = 443
cidr_ipv4 = var.corporate_cidr
}
resource "aws_vpc_security_group_ingress_rule" "gateway_from_alb" {
security_group_id = aws_security_group.gateway.id
description = "Gateway port from the ALB"
ip_protocol = "tcp"
from_port = 8080
to_port = 8080
referenced_security_group_id = aws_security_group.alb.id
}
resource "aws_vpc_security_group_ingress_rule" "db_from_gateway" {
security_group_id = aws_security_group.db.id
description = "Postgres from the gateway"
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
referenced_security_group_id = aws_security_group.gateway.id
}
# Egress: the ALB only needs to reach its targets; the gateway needs the NAT
# path out (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs) plus
# Postgres. The DB group needs no egress (security groups are stateful).
resource "aws_vpc_security_group_egress_rule" "alb_to_gateway" {
security_group_id = aws_security_group.alb.id
description = "Health checks + forwarding to gateway tasks"
ip_protocol = "tcp"
from_port = 8080
to_port = 8080
referenced_security_group_id = aws_security_group.gateway.id
}
resource "aws_vpc_security_group_egress_rule" "gateway_all" {
security_group_id = aws_security_group.gateway.id
description = "Egress to Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs, Postgres"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
}
# ── 2 IAM roles (least-privilege) ───────────────────────────────────────────
# Task role: the gateway's runtime identity. Its ONLY permission is invoking
# Claude models on Bedrock — the upstream's `auth: {}` resolves to this role
# via the AWS default credential chain. The policy must cover both the
# cross-region inference-profile ARNs and the underlying foundation-model ARNs.
data "aws_iam_policy_document" "ecs_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "task" {
name = var.task_role_name
assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
}
resource "aws_iam_role_policy" "bedrock_invoke" {
name = "bedrock-invoke"
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
Resource = [
"arn:aws:bedrock:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:inference-profile/us.anthropic.*",
"arn:aws:bedrock:*::foundation-model/anthropic.*",
]
}]
})
# The walkthrough is scoped to commercial US regions: this policy and the
# gateway's built-in model catalog both use the us.anthropic.* geo-prefixed
# cross-region inference profiles, which only exist in the commercial US
# regions — an explicit list, not a `us-` prefix match, because GovCloud
# (us-gov-*) and ISO (us-iso-*) regions share the prefix but live in
# different AWS partitions where those profiles and this module's arn:aws:
# ARNs are wrong. Anywhere else the deploy provisions fine and then every
# model call fails. Other-region deploys must pin region-appropriate
# profiles via a models: block in gateway.yaml (see the config reference's
# models: guidance: https://code.claude.com/docs/en/claude-apps-gateway-config),
# widen the inference-profile ARN geo prefix above, and set
# allow_non_us_region = true.
lifecycle {
precondition {
condition = var.allow_non_us_region || contains(["us-east-1", "us-east-2", "us-west-1", "us-west-2"], var.region)
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."
}
}
}
# Execution role: the ECS agent's identity — pulls the image from ECR and
# injects the Secrets Manager values into the container; the gateway never
# uses it. AmazonECSTaskExecutionRolePolicy covers the ECR pull + awslogs;
# the inline policy adds read on exactly the three secrets this module
# creates — their full ARNs, not a name-prefix wildcard, so nothing else
# in a shared account (present or future) is readable through this role.
resource "aws_iam_role" "execution" {
name = var.execution_role_name
assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
}
resource "aws_iam_role_policy_attachment" "execution_managed" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "secrets_read" {
name = "read-gateway-secrets"
role = aws_iam_role.execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = [
aws_secretsmanager_secret.jwt.arn,
aws_secretsmanager_secret.oidc.arn,
aws_secretsmanager_secret.postgres_url.arn,
]
}]
})
}
# ── 6 ECR repository ────────────────────────────────────────────────────────
# NOTE: image build/push is a separate step (see README) — Terraform only makes
# the repo. IMMUTABLE tags + scan-on-push: the ECS service pulls whatever this
# repo serves under the deployed tag, so a pushed tag must never be silently
# re-pointed. For production, also restrict push rights on this repo to your
# CI / image-promotion pipeline rather than operator credentials.
resource "aws_ecr_repository" "repo" {
name = var.ecr_repo
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# ── 3 RDS for PostgreSQL (private subnets, no public address) ───────────────
resource "aws_db_subnet_group" "db" {
name = var.db_instance
description = "Claude gateway"
subnet_ids = var.private_subnet_ids
}
# rds.force_ssl: reject plaintext connections server-side — the client-side
# counterpart is sslmode=verify-full in the connection string (§5). The family
# tracks the major version in var.db_engine_version.
#
# name_prefix + create_before_destroy: a major engine bump changes `family`,
# which forces replacement — with a static name that deadlocks (the new group
# can't be created under the taken name; the old can't be destroyed while the
# live instance uses it: "parameter group is currently in use"). With this
# shape the replacement group gets a fresh unique name, the instance is
# repointed, then the old group is destroyed. (The subnet group above needs
# neither: subnet_ids update in place and an engine bump never touches it.)
resource "aws_db_parameter_group" "db" {
name_prefix = "${var.db_instance}-"
family = "postgres${split(".", var.db_engine_version)[0]}"
description = "Claude gateway - require TLS on every connection"
parameter {
name = "rds.force_ssl"
value = "1"
}
lifecycle {
create_before_destroy = true
}
}
# URL-safe (alphanumeric) so it drops cleanly into the connection string.
# nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
resource "random_password" "db" {
length = 32
special = false
}
# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
resource "aws_db_instance" "db" {
identifier = var.db_instance
engine = "postgres"
engine_version = var.db_engine_version
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
db_name = var.db_name
username = var.db_user
password = random_password.db.result
db_subnet_group_name = aws_db_subnet_group.db.name
parameter_group_name = aws_db_parameter_group.db.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false
storage_encrypted = true
deletion_protection = var.deletion_protection
# Greenfield teardown: skip the final snapshot only once deletion protection
# is deliberately turned off (the same switch — see README "Tear down").
skip_final_snapshot = !var.deletion_protection
final_snapshot_identifier = "${var.db_instance}-final"
}
# ── 5 Secrets Manager ───────────────────────────────────────────────────────
# postgres-url: connection string built from the instance's private endpoint.
# The execution role's policy (§2) grants read on these three secrets' ARNs
# and nothing else.
#
# recovery_window_in_days rides the same switch as skip_final_snapshot: the
# secrets have fixed names, so a destroy that leaves them in the default
# 30-day scheduled-deletion state makes the next apply fail with a name
# conflict. Greenfield teardown (deletion_protection = false) deletes them
# immediately; a protected deployment keeps the 30-day recovery window.
resource "aws_secretsmanager_secret" "postgres_url" {
name = var.secret_name
recovery_window_in_days = var.deletion_protection ? 30 : 0
}
# sslmode=verify-full: the gateway's driver (Bun.SQL) honors sslmode from the
# URL and verifies the server certificate chain AND hostname. The trust anchor
# is the AWS RDS CA bundle baked into the image at /etc/claude/rds-global-bundle.pem
# and loaded via NODE_EXTRA_CA_CERTS (see ../Dockerfile) — do NOT add a
# libpq-style `sslrootcert=` query param: the driver doesn't read it and
# forwards it to Postgres as a startup parameter, which the server rejects.
# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
resource "aws_secretsmanager_secret_version" "postgres_url" {
secret_id = aws_secretsmanager_secret.postgres_url.id
secret_string = "postgres://${var.db_user}:${random_password.db.result}@${aws_db_instance.db.address}:5432/${var.db_name}?sslmode=verify-full"
}
# jwt: session signing key.
# nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
resource "random_password" "jwt" {
length = 48
special = false
}
resource "aws_secretsmanager_secret" "jwt" {
name = var.jwt_secret_name
recovery_window_in_days = var.deletion_protection ? 30 : 0 # see postgres_url
}
# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
resource "aws_secretsmanager_secret_version" "jwt" {
secret_id = aws_secretsmanager_secret.jwt.id
secret_string = random_password.jwt.result
}
# oidc client secret: operator-provided (from the Okta OIDC web app).
resource "aws_secretsmanager_secret" "oidc" {
name = var.oidc_secret_name
recovery_window_in_days = var.deletion_protection ? 30 : 0 # see postgres_url
}
# nosemgrep: terraform-aws-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote S3 backend (see README "Remote state")
resource "aws_secretsmanager_secret_version" "oidc" {
count = var.oidc_client_secret != "" ? 1 : 0
secret_id = aws_secretsmanager_secret.oidc.id
secret_string = var.oidc_client_secret
}
# Warn (not block) at plan time when the OIDC secret value isn't set: the task
# definition references the secret unconditionally, so an empty value with no
# out-of-band version means the tasks fail to start late, at container init
# (ResourceInitializationError). A warning (not a precondition) keeps the
# documented out-of-band-version mode usable.
check "oidc_client_secret_set" {
assert {
condition = var.oidc_client_secret != ""
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)."
}
}
# ── 7 ECS Fargate service + internal ALB ────────────────────────────────────
resource "aws_ecs_cluster" "cluster" {
name = var.cluster_name
}
# The gateway's stderr carries both its audit events and operational logs.
# Bounded retention — without it the group keeps logs forever and cost grows
# unbounded; the default (90 days) is sized for audit-trail review windows.
resource "aws_cloudwatch_log_group" "gateway" {
name = var.log_group_name
retention_in_days = var.log_retention_days
}
# Task definition. gateway.yaml ships INSIDE the image (unlike the GCP example,
# which mounts it from Secret Manager), so Terraform reads ../gateway.yaml only
# to (a) enforce the no-REPLACE_ME guard before a deploy and (b) stamp a hash
# of the config + every managed secret value into the container environment —
# secrets are injected at task start, so rotating one (tainting
# random_password.db ALTERs the DB password; a new oidc_client_secret) would
# otherwise leave running tasks on stale values with nothing forcing a roll.
# NOTE the hash only forces a roll; a config EDIT still reaches the container
# only via a rebuilt image — push under a new tag (the repo enforces
# immutability) and bump image_tag, or the roll redeploys the old config.
resource "aws_ecs_task_definition" "gateway" {
family = var.service_name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = tostring(var.task_cpu)
memory = tostring(var.task_memory)
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
runtime_platform {
cpu_architecture = "X86_64" # build the image linux/amd64; ARM64 for Graviton (see ../Dockerfile)
operating_system_family = "LINUX"
}
container_definitions = jsonencode([
{
name = "gateway"
image = local.image
portMappings = [{ containerPort = 8080 }]
environment = [
{
name = "GATEWAY_CONFIG_SHA"
value = substr(sha256(join("", [
local.gateway_config,
random_password.db.result,
random_password.jwt.result,
var.oidc_client_secret,
])), 0, 16)
},
]
secrets = [
{ name = "GATEWAY_JWT_SECRET", valueFrom = aws_secretsmanager_secret.jwt.arn },
{ name = "OIDC_CLIENT_SECRET", valueFrom = aws_secretsmanager_secret.oidc.arn },
{ name = "GATEWAY_POSTGRES_URL", valueFrom = aws_secretsmanager_secret.postgres_url.arn },
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.gateway.name
awslogs-region = data.aws_region.current.region
awslogs-stream-prefix = "gateway"
}
}
}
])
# Guard mirrors setup.sh's REPLACE_ME check (non-comment lines): the config
# is baked into the image this task definition deploys, so a half-filled
# gateway.yaml at apply time means the pushed image is half-filled too.
lifecycle {
precondition {
condition = length([
for line in split("\n", local.gateway_config) :
line
if !startswith(trimspace(line), "#") && strcontains(line, "REPLACE_ME")
]) == 0
error_message = "gateway.yaml still has REPLACE_ME on a non-comment line — fill it in (and rebuild/push the image) before applying."
}
}
depends_on = [
aws_secretsmanager_secret_version.postgres_url,
aws_secretsmanager_secret_version.jwt,
]
}
# Internal ALB. ip_address_type ipv4: an internal dual-stack ALB publishes
# public-range AAAA records, which the CLI's /login private-network check
# rejects. idle_timeout 3600: the 60-second default closes a streaming
# response at the first quiet period (long prompt processing before the first
# token, extended thinking with no streamed output).
resource "aws_lb" "gateway" {
name = var.service_name
internal = true
load_balancer_type = "application"
ip_address_type = "ipv4"
subnets = var.private_subnet_ids
security_groups = [aws_security_group.alb.id]
idle_timeout = 3600
enable_deletion_protection = var.deletion_protection
}
# /readyz verifies the store is reachable, so a task that can't reach Postgres
# never enters rotation (the gateway also serves liveness-only /healthz — see
# the deploy guide's outage-behavior tradeoff).
resource "aws_lb_target_group" "gateway" {
name = var.service_name
protocol = "HTTP"
port = 8080
vpc_id = var.vpc_id
target_type = "ip"
health_check {
path = "/readyz"
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.gateway.arn
protocol = "HTTPS"
port = 443
# Explicit modern policy — omitting ssl_policy falls back to the legacy
# ELBSecurityPolicy-2016-08 default, which still accepts TLS 1.0/1.1.
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.acm_certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.gateway.arn
}
}
resource "aws_ecs_service" "gateway" {
name = var.service_name
cluster = aws_ecs_cluster.cluster.id
task_definition = aws_ecs_task_definition.gateway.arn
desired_count = var.desired_count
launch_type = "FARGATE"
# Stop a rollout whose tasks keep failing (bad image, unbootable config) and
# roll back to the last steady state instead of relaunching failing tasks
# forever.
deployment_circuit_breaker {
enable = true
rollback = true
}
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.gateway.id]
# All egress (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs)
# goes through the NAT gateway — tasks get no public IP.
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.gateway.arn
container_name = "gateway"
container_port = 8080
}
# Tasks register with the ALB at start — give a cold task (image pull +
# store connect + first /readyz) time before ECS replaces it as unhealthy.
health_check_grace_period_seconds = 60
# The listener must exist before targets register; the secrets must be
# readable before the first task starts.
depends_on = [
aws_lb_listener.https,
aws_iam_role_policy.secrets_read,
aws_iam_role_policy_attachment.execution_managed,
aws_secretsmanager_secret_version.postgres_url,
aws_secretsmanager_secret_version.jwt,
aws_secretsmanager_secret_version.oidc,
aws_db_instance.db,
]
}

View File

@@ -0,0 +1,34 @@
output "alb_dns_name" {
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."
value = aws_lb.gateway.dns_name
}
output "alb_zone_id" {
description = "ALB hosted zone ID, for the Route 53 alias record."
value = aws_lb.gateway.zone_id
}
output "image" {
description = "Image the service runs (build/push this separately — see README)."
value = local.image
}
output "ecr_repository_url" {
description = "ECR repository URL to push the gateway image to."
value = aws_ecr_repository.repo.repository_url
}
output "task_role_arn" {
description = "Gateway runtime task role (Bedrock invoke)."
value = aws_iam_role.task.arn
}
output "execution_role_arn" {
description = "ECS execution role (image pull + secret injection)."
value = aws_iam_role.execution.arn
}
output "db_endpoint" {
description = "RDS private endpoint (host only; the connection string lives in the gateway-postgres-url secret)."
value = aws_db_instance.db.address
}

View File

@@ -0,0 +1,27 @@
# Copy to terraform.tfvars and edit. terraform.tfvars is gitignored (see .gitignore).
region = "us-east-1" # a region where Bedrock serves the Claude models you need
# Prerequisite networking (NOT created by this module): the VPC and two+ private
# subnets in different AZs with outbound internet via a NAT gateway.
vpc_id = "vpc-..."
private_subnet_ids = ["subnet-...a", "subnet-...b"]
# The only source the ALB admits on 443. Must not overlap the private subnets
# above — hosts in the ALB subnets are trusted_proxies (gateway.yaml) and could
# spoof client IPs via X-Forwarded-For.
corporate_cidr = "10.0.0.0/8"
# ACM certificate for your internal gateway hostname (the host in gateway.yaml's
# public_url), imported or issued by AWS Private CA.
acm_certificate_arn = "arn:aws:acm:..."
image_tag = "<version>" # REQUIRED — the tag you build and push as linux/amd64 with
# gateway.yaml baked in (setup.sh tags <version>-cfg<sha8>;
# see README Deploy)
# Okta OIDC client secret: REQUIRED — uncomment and set it (Terraform creates the
# secret version; the ECS tasks inject `gateway-oidc-client-secret` at start, so
# without a version they fail with ResourceInitializationError). Leave empty only
# if you add the secret version out-of-band.
# oidc_client_secret = "..."

View File

@@ -0,0 +1,189 @@
# Inputs — mirror the env-overridable knobs in setup.sh (same defaults).
variable "region" {
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."
type = string
default = "us-east-1"
}
variable "allow_non_us_region" {
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."
type = bool
default = false
}
# ── Networking inputs (prerequisites — NOT created here) ────────────────────
variable "vpc_id" {
description = "Existing VPC ID (the walkthrough's prerequisite VPC). Unlike the GCP example, this module does not create the network."
type = string
}
variable "private_subnet_ids" {
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."
type = list(string)
validation {
condition = length(var.private_subnet_ids) >= 2
error_message = "private_subnet_ids needs at least two subnets in different AZs (the internal ALB requires two)."
}
}
variable "corporate_cidr" {
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."
type = string
}
# ── IAM (§2) ────────────────────────────────────────────────────────────────
variable "task_role_name" {
description = "ECS task role name (the gateway's runtime identity; its only permission is Bedrock invoke)."
type = string
default = "claude-gateway-task"
}
variable "execution_role_name" {
description = "ECS execution role name (the ECS agent's identity: pulls the image, injects the secrets)."
type = string
default = "claude-gateway-execution"
}
# ── Image (§6) ──────────────────────────────────────────────────────────────
# Terraform creates the ECR repository but does NOT build/push the image (that's
# a docker build step — see README). It references the image by tag.
variable "ecr_repo" {
description = "ECR repository name."
type = string
default = "claude-gateway"
}
variable "image_tag" {
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."
type = string
validation {
condition = can(regex("^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", var.image_tag))
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)."
}
}
variable "gateway_config_path" {
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."
type = string
default = ""
}
# ── RDS (§3) ────────────────────────────────────────────────────────────────
variable "db_instance" {
description = "RDS instance identifier."
type = string
default = "claude-gateway-db"
}
variable "db_engine_version" {
description = "Postgres major version. The gateway supports PostgreSQL 14 or newer; 16 is the recommended default."
type = string
default = "16"
}
variable "db_instance_class" {
description = "RDS instance class."
type = string
default = "db.t4g.micro"
}
variable "db_allocated_storage" {
description = "RDS allocated storage in GiB."
type = number
default = 20
}
variable "db_name" {
description = "Database name."
type = string
default = "claude_gateway"
}
variable "db_user" {
description = "Database master user (the gateway connects as this role)."
type = string
default = "gateway"
}
# ── Secrets (§5) ────────────────────────────────────────────────────────────
# The execution role's secrets-read policy grants read on exactly these three
# secrets' ARNs, so renames are picked up automatically on the next apply.
variable "secret_name" {
description = "Secrets Manager secret holding the Postgres connection string."
type = string
default = "gateway-postgres-url"
}
variable "jwt_secret_name" {
description = "Secrets Manager secret holding the session JWT signing key."
type = string
default = "gateway-jwt-secret"
}
variable "oidc_secret_name" {
description = "Secrets Manager secret holding the Okta OIDC client secret."
type = string
default = "gateway-oidc-client-secret"
}
variable "oidc_client_secret" {
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)."
type = string
default = ""
sensitive = true
}
# ── ECS + ALB (§7) ──────────────────────────────────────────────────────────
variable "cluster_name" {
description = "ECS cluster name."
type = string
default = "claude-gateway"
}
variable "service_name" {
description = "ECS service name (also used for the ALB and target group)."
type = string
default = "claude-gateway"
}
variable "log_group_name" {
description = "CloudWatch Logs group for the gateway's stderr (audit events + operational logs)."
type = string
default = "/ecs/claude-gateway"
}
variable "log_retention_days" {
description = "CloudWatch Logs retention in days. The group carries the gateway's audit events, so align with your audit retention policy."
type = number
default = 90
}
variable "acm_certificate_arn" {
description = "ACM certificate ARN for the internal gateway hostname (the host in gateway.yaml's public_url), served by the ALB's HTTPS listener."
type = string
}
variable "task_cpu" {
description = "Fargate task CPU units."
type = number
default = 1024
}
variable "task_memory" {
description = "Fargate task memory (MiB)."
type = number
default = 2048
}
variable "desired_count" {
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."
type = number
default = 1
}
variable "deletion_protection" {
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."
type = bool
default = true
}

View File

@@ -0,0 +1,18 @@
# Provider + version pins for the Claude apps gateway ECS Fargate deployment.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0" # 6.0 renames data.aws_region's attribute to `region`
}
random = {
source = "hashicorp/random"
version = ">= 3.5"
}
}
}
provider "aws" {
region = var.region
}

View File

@@ -6,7 +6,33 @@
<author><name>Anthropic</name></author>
<link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md"/>
<link rel="self" type="application/atom+xml" href="https://raw.githubusercontent.com/anthropics/claude-code/main/feed.xml"/>
<updated>2026-07-20T22:13:53Z</updated>
<updated>2026-07-21T21:35:04Z</updated>
<entry>
<id>https://github.com/anthropics/claude-code/releases/tag/v2.1.217</id>
<title>Claude Code v2.1.217</title>
<link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/releases/tag/v2.1.217"/>
<updated>2026-07-21T21:35:04Z</updated>
<content type="html">&lt;p&gt;• Added emoji shortcode autocomplete in the prompt input: type :heart: to insert ❤️, or :hea for suggestions — disable with the emojiCompletionEnabled setting&lt;/p&gt;
&lt;p&gt;• Added warnings when transcript writes are failing (e.g. disk full) or when session saving is off due to an inherited environment variable, instead of losing transcripts silently&lt;/p&gt;
&lt;p&gt;• Fixed a memory leak where truncated MCP tool outputs kept the full untruncated result in memory for the rest of the session&lt;/p&gt;
&lt;p&gt;• Fixed Windows auto-update failures that could leave claude.exe missing; failed updates now restore the preserved executable automatically&lt;/p&gt;
&lt;p&gt;• Fixed background session isolation not canonicalizing symlinked working directories, which could let sessions escape their workspace folder&lt;/p&gt;
&lt;p&gt;• Fixed auto-compact never triggering for Claude Opus 4.8 on Bedrock and /compact failing once over the limit&lt;/p&gt;
&lt;p&gt;• Fixed corporate mTLS, TLS-verify, OAuth scope, and proxy settings being ignored in Claude Desktop sessions&lt;/p&gt;
&lt;p&gt;• Fixed screen reader mode's startup announcement being cut off by the first prompt render, and the thinking status row re-rendering every few seconds to update elapsed time and token counts&lt;/p&gt;
&lt;p&gt;• Fixed managed settings that set OTEL_EXPORTER_OTLP_ENDPOINT not governing all signals — lower-scope signal-specific overrides no longer redirect telemetry away from the managed endpoint&lt;/p&gt;
&lt;p&gt;• Fixed --resume/--continue and /resume failing with a TypeError when a transcript has a malformed attachment entry&lt;/p&gt;
&lt;p&gt;• Fixed Remote Control sessions not showing a pending permission prompt or dialog to viewers that connected after it appeared&lt;/p&gt;
&lt;p&gt;• Fixed background shells sometimes becoming impossible to stop after a session is sent to the background (/background or ←) or when the session exits on a heavily loaded machine, most visible on Windows&lt;/p&gt;
&lt;p&gt;• Fixed a CLAUDE.md or SKILL.md paths frontmatter value with many brace groups OOM-killing or stalling the CLI at startup — brace expansion is now budget-bounded&lt;/p&gt;
&lt;p&gt;• Fixed the transcript preview sitting flush against the input area when attaching to a starting background session; it now leaves the same one-line gap as the live layout, so the transcript no longer shifts when the session takes over&lt;/p&gt;
&lt;p&gt;• Improved footer PR badge links to be clickable hyperlinks even when terminal support can't be detected (e.g. over ssh/tmux); set FORCE_HYPERLINK=0 to opt out&lt;/p&gt;
&lt;p&gt;• Changed the login-expiry warning to appear 3 days before expiry instead of 5&lt;/p&gt;
&lt;p&gt;• Capped the frontend-design plugin suggestion tip at 3 lifetime impressions instead of repeating indefinitely&lt;/p&gt;
&lt;p&gt;• Added a cap on concurrently-running subagents (default 20, override with CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS) so one message can't fan out unbounded background agents&lt;/p&gt;
&lt;p&gt;• Changed subagents to no longer spawn nested subagents by default; set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH to allow deeper nesting&lt;/p&gt;
&lt;p&gt;• Fixed --max-budget-usd not stopping background subagents: once the cap is reached, new spawns are denied and running background agents are halted&lt;/p&gt;</content>
</entry>
<entry>
<id>https://github.com/anthropics/claude-code/releases/tag/v2.1.216</id>
<title>Claude Code v2.1.216</title>
@@ -265,6 +291,7 @@
&lt;p&gt;• Added vimInsertModeRemaps setting: map two-key insert-mode sequences like jj to Escape in vim mode&lt;/p&gt;
&lt;p&gt;• Added CLAUDE_CODE_PROCESS_WRAPPER: agent view and the background service now honor a corporate launcher by running every Claude Code self-spawn through a required wrapper executable&lt;/p&gt;
&lt;p&gt;• Added mouse-click support for multi-select menus and "Other" input rows in fullscreen mode&lt;/p&gt;
&lt;p&gt;• Changed the Fable 5 usage-credits consent prompt to start with the decline option focused&lt;/p&gt;
&lt;p&gt;• Fixed fast mode staying off after switching back to a model that supports it — it now restores automatically when enabled in settings&lt;/p&gt;
&lt;p&gt;• Fixed replies typed to a background agent being lost when delivery fails — the text is now saved and delivered when the session restarts&lt;/p&gt;
&lt;p&gt;• Fixed background-session attach failing permanently ("Couldn't start the background daemon") after an update replaced the binary a running claude agents process was launched from&lt;/p&gt;
@@ -579,37 +606,4 @@
<updated>2026-06-30T17:56:29Z</updated>
<content type="html">&lt;p&gt;• Introducing Claude Sonnet 5: now the default model in Claude Code, with a native 1M-token context window and promotional pricing of $2/$10 per Mtok through August 31. Update to version 2.1.197 for access. https://www.anthropic.com/news/claude-sonnet-5&lt;/p&gt;</content>
</entry>
<entry>
<id>https://github.com/anthropics/claude-code/releases/tag/v2.1.196</id>
<title>Claude Code v2.1.196</title>
<link rel="alternate" type="text/html" href="https://github.com/anthropics/claude-code/releases/tag/v2.1.196"/>
<updated>2026-06-29T23:27:24Z</updated>
<content type="html">&lt;p&gt;• Added support for organization default models — admins set it in the org console; it shows as "Org default" (or "Role default") in /model when you haven't picked one yourself&lt;/p&gt;
&lt;p&gt;• Added readable default names for sessions at start, making them easier to identify and message&lt;/p&gt;
&lt;p&gt;• Added clickable file attachments in chat — Cmd/Ctrl-click reveals the file in Finder/Explorer&lt;/p&gt;
&lt;p&gt;• Security: claude mcp list/get no longer spawn .mcp.json servers that a repo self-approved via a committed .claude/settings.json; untrusted workspaces show ⏸ Pending approval&lt;/p&gt;
&lt;p&gt;• Fixed waking a background job permanently deleting its conversation and re-running the original prompt when the transcript probe misread a real transcript; the file is now set aside, never deleted&lt;/p&gt;
&lt;p&gt;• Fixed the rate-limit warning flickering off and rate-limit telemetry being over-counted when multiple parallel requests were in flight at the moment a usage limit was hit&lt;/p&gt;
&lt;p&gt;• Fixed duplicate recap lines after a background session's turn: a schema-rejected StructuredOutput attempt no longer renders alongside its retry&lt;/p&gt;
&lt;p&gt;• Fixed PowerShell git diff/git grep, egrep/fgrep, and quoted search patterns containing | being reported as failures when they exit 1, matching Bash behavior&lt;/p&gt;
&lt;p&gt;• Fixed multiple claude agents side panel issues: keyboard focus getting stuck when opening an agent, background jobs losing their subagent types on every open, and sessions showing incorrect status while actively running&lt;/p&gt;
&lt;p&gt;• Fixed claude agents --dangerously-skip-permissions silently falling back to auto mode instead of showing the bypass disclaimer and applying bypass mode to spawned agents&lt;/p&gt;
&lt;p&gt;• Fixed mid-turn crash recovery for Remote sessions — sessions interrupted by a server restart now auto-resume on the next worker&lt;/p&gt;
&lt;p&gt;• Fixed sessions moved with /cd reappearing in the old directory's resume list after a non-graceful exit when the old path contained special characters&lt;/p&gt;
&lt;p&gt;• Fixed claude plugin validate skipping local plugins whose source is "." and stopping after the first error class&lt;/p&gt;
&lt;p&gt;• Fixed Esc Esc at an idle prompt not opening the rewind menu (regression); use Ctrl+C or Ctrl+X Ctrl+K to stop background agents&lt;/p&gt;
&lt;p&gt;• Fixed MCP OAuth requesting the authorization server's full scopes_supported catalog when no scope is specified, causing invalid_scope failures on GitLab self-hosted and other enterprise IdPs&lt;/p&gt;
&lt;p&gt;• Fixed /context showing 0 tokens for all tool groups on Bedrock&lt;/p&gt;
&lt;p&gt;• Fixed /deep-research misreporting verifier failures as "all claims refuted" instead of unverified&lt;/p&gt;
&lt;p&gt;• Fixed plugin dependency version pins not being honored when the marketplace was added as a local folder path backed by a git repo&lt;/p&gt;
&lt;p&gt;• Fixed claude agents session status: completed rows no longer flip between "Done" and "Needs your input", stalled agents are now labeled "Needs attention", and results that mention a PR show a clickable link&lt;/p&gt;
&lt;p&gt;• Fixed voice dictation swallowing spaces and spuriously starting a recording during very fast typing when voice mode is enabled&lt;/p&gt;
&lt;p&gt;• Improved background session reliability: long-running commands and workflows now survive the session's process being stopped, restarted, or updated — including on Windows, where background shells are handed off instead of being killed&lt;/p&gt;
&lt;p&gt;• Improved background agents: workers killed by a daemon restart are now automatically resumed from where they left off the next time the agents view opens&lt;/p&gt;
&lt;p&gt;• Improved /code-review workflow: merged five cleanup finders into one, cutting token usage by roughly 25%&lt;/p&gt;
&lt;p&gt;• Reduced per-frame rendering work in the terminal UI by skipping no-op subtree walks during streaming&lt;/p&gt;
&lt;p&gt;• The streaming idle watchdog is now on by default for all providers — it aborts and retries when a response stream produces no events for 5 minutes. Set CLAUDE_ENABLE_STREAM_WATCHDOG=0 to disable.&lt;/p&gt;
&lt;p&gt;• Remote Control is now disabled when ANTHROPIC_BASE_URL points at a non-Anthropic host, matching the existing behavior under CLAUDE_CODE_USE_BEDROCK/_VERTEX/_FOUNDRY&lt;/p&gt;
&lt;p&gt;• Changed opening the agents view from a foreground session to require a single ← press instead of two, matching the behavior in background sessions&lt;/p&gt;</content>
</entry>
</feed>