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 / main.tf
510 lines · 21.4 KBRaw
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
8data "aws_caller_identity" "current" {}
9data "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.
13data "aws_vpc" "this" {
14 id = var.vpc_id
15}
16
17data "aws_subnet" "private" {
18 for_each = toset(var.private_subnet_ids)
19 id = each.value
20}
21
22locals {
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.
32resource "aws_security_group" "alb" {
33 name = "claude-gateway-alb"
34 description = "Claude gateway ALB"
35 vpc_id = var.vpc_id
36}
37
38resource "aws_security_group" "gateway" {
39 name = "claude-gateway-svc"
40 description = "Claude gateway service"
41 vpc_id = var.vpc_id
42}
43
44resource "aws_security_group" "db" {
45 name = "claude-gateway-db"
46 description = "Claude gateway Postgres"
47 vpc_id = var.vpc_id
48}
49
50resource "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
59resource "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
68resource "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).
80resource "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
89resource "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.
101data "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
112resource "aws_iam_role" "task" {
113 name = var.task_role_name
114 assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
115}
116
117resource "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.
158resource "aws_iam_role" "execution" {
159 name = var.execution_role_name
160 assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
161}
162
163resource "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
168resource "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.
191resource "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) ───────────────
200resource "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.)
217resource "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")
234resource "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")
240resource "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.
271resource "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")
283resource "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")
290resource "random_password" "jwt" {
291 length = 48
292 special = false
293}
294
295resource "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")
301resource "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).
307resource "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")
313resource "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.
324check "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 ────────────────────────────────────
332resource "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.
339resource "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.
354resource "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).
425resource "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).
439resource "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
451resource "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
466resource "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}