spacehub

surshar / claude-code

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

Built by Anthropic · github.com/anthropics

claude-code / examples / gateway / aws / terraform / README.md
182 lines · 9.0 KBPreviewCodeRaw
1# Claude apps gateway — Terraform (ECS Fargate)
2
3Terraform equivalent of `../setup.sh`. Lets end-users provision and manage
4the gateway with `terraform apply`. Covers the same scope ([walkthrough](https://code.claude.com/docs/en/claude-apps-gateway-on-aws) §1–7,
5ECS track): security groups → task + execution IAM roles → ECR repository →
6private-subnet RDS for PostgreSQL → Secrets Manager secrets → ECS Fargate
7service behind an internal ALB. The VPC and private subnets are walkthrough
8prerequisites, passed in as variables — unlike the GCP example, no network is
9created 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
231. **`../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`.
302. 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.
353. 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.
394. A **remote backend** for shared use (see below). State holds secrets — never commit it.
40
41## Deploy
42
43Terraform creates the ECR repository but does **not** build/push the image, so
44the apply is two passes: a targeted apply to create the repo, then build/push,
45then the full apply.
46
47```bash
48cp terraform.tfvars.example terraform.tfvars # edit it
49terraform 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.
56terraform 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):
60terraform 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):
67curl -fL --proto '=https' -o ../rds-global-bundle.pem \
68 https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
69aws ecr get-login-password --region us-east-1 \
70 | docker login --username AWS --password-stdin <account-id>.dkr.ecr.us-east-1.amazonaws.com
71docker 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> ..
74docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/claude-gateway:<version>
75
76# 3. Full apply:
77terraform apply
78```
79
80Set 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
95Tear down a trial with `terraform destroy`: set `deletion_protection = false`,
96run `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,
98so destroy would still refuse otherwise), then `terraform destroy`.
99
100The same switch drives the Secrets Manager recovery window: the three secrets
101have **fixed names**, and a secret deleted with the default 30-day recovery
102window keeps its name reserved — a later `terraform apply` would fail with a
103name conflict until the window elapses. With `deletion_protection = false` the
104destroy deletes them immediately (`recovery_window_in_days = 0`). If you
105destroyed a deployment that still had `deletion_protection = true` (or tore
106down an older copy of this module), clear the scheduled deletions before
107re-applying:
108
109```bash
110for 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
112done
113```
114
115## Guard rails
116
117Tuned 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
139The ALB is **internal** with `ip_address_type = "ipv4"` (a dual-stack internal
140ALB publishes public-range AAAA records, which the CLI's `/login`
141private-network check rejects), and its security group admits only
142`corporate_cidr` on 443. Reaching it from on-prem requires your existing
143routing into the VPC (Direct Connect / VPN) — **operator / network-team-owned**
144plumbing this module does not create.
145
146After the apply, give developers a privately resolvable hostname: in a Route 53
147private 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`
149name can't carry your ACM certificate, so use your own name.
150
151The tasks run in the private subnets with no public IP; all egress (Bedrock,
152the IdP, Secrets Manager, ECR, CloudWatch Logs) goes through the NAT gateway.
153To keep Bedrock traffic off the public path, create a `bedrock-runtime`
154interface 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
159Add a backend so state is shared and locked (and out of git):
160
161```hcl
162# backend.tf
163terraform {
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.