diff --git a/examples/gateway/aws/.dockerignore b/examples/gateway/aws/.dockerignore new file mode 100644 index 000000000..f613ee1f6 --- /dev/null +++ b/examples/gateway/aws/.dockerignore @@ -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 diff --git a/examples/gateway/aws/.gitignore b/examples/gateway/aws/.gitignore new file mode 100644 index 000000000..53199b887 --- /dev/null +++ b/examples/gateway/aws/.gitignore @@ -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 diff --git a/examples/gateway/aws/Dockerfile b/examples/gateway/aws/Dockerfile new file mode 100644 index 000000000..ac9f8b907 --- /dev/null +++ b/examples/gateway/aws/Dockerfile @@ -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"] diff --git a/examples/gateway/aws/README.md b/examples/gateway/aws/README.md new file mode 100644 index 000000000..8a4700533 --- /dev/null +++ b/examples/gateway/aws/README.md @@ -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`) | diff --git a/examples/gateway/aws/gateway.yaml.example b/examples/gateway/aws/gateway.yaml.example new file mode 100644 index 000000000..0d06d78ab --- /dev/null +++ b/examples/gateway/aws/gateway.yaml.example @@ -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:///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 } diff --git a/examples/gateway/aws/setup.sh b/examples/gateway/aws/setup.sh new file mode 100755 index 000000000..6c8ce85b4 --- /dev/null +++ b/examples/gateway/aws/setup.sh @@ -0,0 +1,964 @@ +#!/usr/bin/env bash +# +# setup.sh — AWS setup for Claude apps gateway (walkthrough §1–7, 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 from ), 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() { # + 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() { # + 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 +# {"": ""} 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 -> path in + 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=" >&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 < secrets-read.iam.json </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}" </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:-} 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:-}/oauth/callback" + echo " 3. Verify from inside your corporate network:" + echo " curl -s ${CFG_PUBLIC_URL:-}/.well-known/oauth-authorization-server" +fi + +# ---- summary ---------------------------------------------------------------- +cat < 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 /oauth/callback on the Okta app. + - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE. +EOF diff --git a/examples/gateway/aws/terraform/.gitignore b/examples/gateway/aws/terraform/.gitignore new file mode 100644 index 000000000..9525fb56b --- /dev/null +++ b/examples/gateway/aws/terraform/.gitignore @@ -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 diff --git a/examples/gateway/aws/terraform/README.md b/examples/gateway/aws/terraform/README.md new file mode 100644 index 000000000..0775e4860 --- /dev/null +++ b/examples/gateway/aws/terraform/README.md @@ -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) §1–7, +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 .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 .dkr.ecr.us-east-1.amazonaws.com/claude-gateway: .. +docker push .dkr.ecr.us-east-1.amazonaws.com/claude-gateway: + +# 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 + `-cfg`) +- **`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 = "" + 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 `/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. diff --git a/examples/gateway/aws/terraform/main.tf b/examples/gateway/aws/terraform/main.tf new file mode 100644 index 000000000..c984577d3 --- /dev/null +++ b/examples/gateway/aws/terraform/main.tf @@ -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, + ] +} diff --git a/examples/gateway/aws/terraform/outputs.tf b/examples/gateway/aws/terraform/outputs.tf new file mode 100644 index 000000000..5c91a46f9 --- /dev/null +++ b/examples/gateway/aws/terraform/outputs.tf @@ -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 +} diff --git a/examples/gateway/aws/terraform/terraform.tfvars.example b/examples/gateway/aws/terraform/terraform.tfvars.example new file mode 100644 index 000000000..eac83daa6 --- /dev/null +++ b/examples/gateway/aws/terraform/terraform.tfvars.example @@ -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 = "" # REQUIRED — the tag you build and push as linux/amd64 with + # gateway.yaml baked in (setup.sh tags -cfg; + # 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 = "..." diff --git a/examples/gateway/aws/terraform/variables.tf b/examples/gateway/aws/terraform/variables.tf new file mode 100644 index 000000000..1f12081b3 --- /dev/null +++ b/examples/gateway/aws/terraform/variables.tf @@ -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 -cfg; 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 '' 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 +} diff --git a/examples/gateway/aws/terraform/versions.tf b/examples/gateway/aws/terraform/versions.tf new file mode 100644 index 000000000..11a5fd327 --- /dev/null +++ b/examples/gateway/aws/terraform/versions.tf @@ -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 +}