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