SaylorTwift HF Staff commited on
Commit
7f0ec95
·
verified ·
1 Parent(s): 776286d

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/commands/commit-push-pr.md +19 -0
  2. .claude/commands/dedupe.md +27 -0
  3. .claude/commands/triage-issue.md +74 -0
  4. .gitattributes +1 -1
  5. .github/ISSUE_TEMPLATE/bug_report.yml +188 -0
  6. .github/ISSUE_TEMPLATE/config.yml +14 -0
  7. .github/ISSUE_TEMPLATE/documentation.yml +117 -0
  8. .github/ISSUE_TEMPLATE/feature_request.yml +132 -0
  9. .github/ISSUE_TEMPLATE/model_behavior.yml +220 -0
  10. .github/workflows/auto-close-duplicates.yml +31 -0
  11. .github/workflows/backfill-duplicate-comments.yml +44 -0
  12. .github/workflows/claude-dedupe-issues.yml +94 -0
  13. .github/workflows/claude-issue-triage.yml +47 -0
  14. .github/workflows/claude.yml +44 -0
  15. .github/workflows/issue-lifecycle-comment.yml +27 -0
  16. .github/workflows/issue-opened-dispatch.yml +28 -0
  17. .github/workflows/lock-closed-issues.yml +77 -0
  18. .github/workflows/log-issue-events.yml +40 -0
  19. .github/workflows/mod-tests.yml +40 -0
  20. .github/workflows/non-write-users-check.yml +47 -0
  21. .github/workflows/remove-autoclose-label.yml +42 -0
  22. .github/workflows/sweep.yml +31 -0
  23. demo.gif +3 -0
  24. examples/gateway/aws/.dockerignore +19 -0
  25. examples/gateway/aws/.gitignore +18 -0
  26. examples/gateway/aws/Dockerfile +67 -0
  27. examples/gateway/aws/README.md +19 -0
  28. examples/gateway/aws/gateway.yaml.example +174 -0
  29. examples/gateway/aws/setup.sh +964 -0
  30. examples/gateway/aws/terraform/.gitignore +19 -0
  31. examples/gateway/aws/terraform/README.md +182 -0
  32. examples/gateway/aws/terraform/main.tf +510 -0
  33. examples/gateway/aws/terraform/outputs.tf +34 -0
  34. examples/gateway/aws/terraform/terraform.tfvars.example +27 -0
  35. examples/gateway/aws/terraform/variables.tf +189 -0
  36. examples/gateway/aws/terraform/versions.tf +18 -0
  37. examples/gateway/gcp/.dockerignore +13 -0
  38. examples/gateway/gcp/.gitignore +12 -0
  39. examples/gateway/gcp/Dockerfile +35 -0
  40. examples/gateway/gcp/README.md +17 -0
  41. examples/gateway/gcp/gateway.yaml.example +156 -0
  42. examples/gateway/gcp/setup.sh +558 -0
  43. examples/gateway/gcp/terraform/.gitignore +13 -0
  44. examples/gateway/gcp/terraform/README.md +160 -0
  45. examples/gateway/gcp/terraform/main.tf +399 -0
  46. examples/gateway/gcp/terraform/outputs.tf +34 -0
  47. examples/gateway/gcp/terraform/terraform.tfvars.example +26 -0
  48. examples/gateway/gcp/terraform/variables.tf +184 -0
  49. examples/gateway/gcp/terraform/versions.tf +19 -0
  50. examples/hooks/bash_command_validator_example.py +83 -0
.claude/commands/commit-push-pr.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ allowed-tools: Bash(git checkout --branch:*), Bash(git add:*), Bash(git status:*), Bash(git push:*), Bash(git commit:*), Bash(gh pr create:*)
3
+ description: Commit, push, and open a PR
4
+ ---
5
+
6
+ ## Context
7
+
8
+ - Current git status: !`git status`
9
+ - Current git diff (staged and unstaged changes): !`git diff HEAD`
10
+ - Current branch: !`git branch --show-current`
11
+
12
+ ## Your task
13
+
14
+ Based on the above changes:
15
+ 1. Create a new branch if on main
16
+ 2. Create a single commit with an appropriate message
17
+ 3. Push the branch to origin
18
+ 4. Create a pull request using `gh pr create`
19
+ 5. You have the capability to call multiple tools in a single response. You MUST do all of the above in a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.
.claude/commands/dedupe.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ allowed-tools: Bash(./scripts/gh.sh:*), Bash(./scripts/comment-on-duplicates.sh:*)
3
+ description: Find duplicate GitHub issues
4
+ ---
5
+
6
+ Find up to 3 likely duplicate issues for a given GitHub issue.
7
+
8
+ To do this, follow these steps precisely:
9
+
10
+ 1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
11
+ 2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
12
+ 3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
13
+ 4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
14
+ 5. Finally, use the comment script to post duplicates:
15
+ ```
16
+ ./scripts/comment-on-duplicates.sh --potential-duplicates <dup1> <dup2> <dup3>
17
+ ```
18
+
19
+ Notes (be sure to tell this to your agents, too):
20
+
21
+ - Use `./scripts/gh.sh` to interact with Github, rather than web fetch or raw `gh`. Examples:
22
+ - `./scripts/gh.sh issue view 123` — view an issue
23
+ - `./scripts/gh.sh issue view 123 --comments` — view with comments
24
+ - `./scripts/gh.sh issue list --state open --limit 20` — list issues
25
+ - `./scripts/gh.sh search issues "query" --limit 10` — search for issues
26
+ - Do not use other tools, beyond `./scripts/gh.sh` and the comment script (eg. don't use other MCP servers, file edit, etc.)
27
+ - Make a todo list first
.claude/commands/triage-issue.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ allowed-tools: Bash(./scripts/gh.sh:*),Bash(./scripts/edit-issue-labels.sh:*)
3
+ description: Triage GitHub issues by analyzing and applying labels
4
+ ---
5
+
6
+ You're an issue triage assistant. Analyze the issue and manage labels.
7
+
8
+ IMPORTANT: Don't post any comments or messages to the issue. Your only actions are adding or removing labels.
9
+
10
+ Context:
11
+
12
+ $ARGUMENTS
13
+
14
+ TOOLS:
15
+ - `./scripts/gh.sh` — wrapper for `gh` CLI. Only supports these subcommands and flags:
16
+ - `./scripts/gh.sh label list` — fetch all available labels
17
+ - `./scripts/gh.sh label list --limit 100` — fetch with limit
18
+ - `./scripts/gh.sh issue view 123` — read issue title, body, and labels
19
+ - `./scripts/gh.sh issue view 123 --comments` — read the conversation
20
+ - `./scripts/gh.sh issue list --state open --limit 20` — list issues
21
+ - `./scripts/gh.sh search issues "query"` — find similar or duplicate issues
22
+ - `./scripts/gh.sh search issues "query" --limit 10` — search with limit
23
+ - `./scripts/edit-issue-labels.sh --add-label LABEL --remove-label LABEL` — add or remove labels (issue number is read from the workflow event)
24
+
25
+ TASK:
26
+
27
+ 1. Run `./scripts/gh.sh label list` to fetch the available labels. You may ONLY use labels from this list. Never invent new labels.
28
+ 2. Run `./scripts/gh.sh issue view ISSUE_NUMBER` to read the issue details.
29
+ 3. Run `./scripts/gh.sh issue view ISSUE_NUMBER --comments` to read the conversation.
30
+
31
+ **If EVENT is "issues" (new issue):**
32
+
33
+ 4. First, check if this issue is actually about Claude Code.
34
+ - Look for Claude Code signals in the issue BODY: a `Claude Code Version` field or `claude --version` output, references to the `claude` CLI command, terminal sessions, the VS Code/JetBrains extensions, `CLAUDE.md` files, `.claude/` directories, MCP servers, Cowork, Remote Control, or the web UI at claude.ai/code. If ANY such signal is present, this IS a Claude Code issue — proceed to step 5.
35
+ - Only if NO Claude Code signals are present: check whether a different Anthropic product (claude.ai chat, Claude Desktop/Mobile apps, the raw Anthropic API/SDK, or account billing with no CLI involvement) is the *subject* of the complaint, not merely mentioned for context. If so, apply `invalid` and stop. If ambiguous, proceed to step 5 WITHOUT applying `invalid`.
36
+ - The body text is authoritative. If a form dropdown (e.g. Platform) contradicts evidence in the body, trust the body — dropdowns are often mis-selected.
37
+
38
+ 5. Analyze and apply category labels:
39
+ - Type (bug, enhancement, question, etc.)
40
+ - Technical areas and platform
41
+ - Check for duplicates with `./scripts/gh.sh search issues`. Only mark as duplicate of OPEN issues.
42
+
43
+ 6. Evaluate lifecycle labels:
44
+ - `needs-repro` (bugs only, 7 days): Bug reports without clear steps to reproduce. A good repro has specific, followable steps that someone else could use to see the same issue.
45
+ Do NOT apply if the user already provided error messages, logs, file paths, or a description of what they did. Don't require a specific format — narrative descriptions count.
46
+ For model behavior issues (e.g. "Claude does X when it should do Y"), don't require traditional repro steps — examples and patterns are sufficient.
47
+ - `needs-info` (bugs only, 7 days): The issue needs something from the community before it can progress — e.g. error messages, versions, environment details, or answers to follow-up questions. Don't apply to questions or enhancements.
48
+ Do NOT apply if the user already provided version, environment, and error details. If the issue just needs engineering investigation, that's not `needs-info`.
49
+
50
+ Issues with these labels are automatically closed after the timeout if there's no response.
51
+ The goal is to avoid issues lingering without a clear next step.
52
+
53
+ 7. Apply all selected labels:
54
+ `./scripts/edit-issue-labels.sh --add-label "label1" --add-label "label2"`
55
+
56
+ **If EVENT is "issue_comment" (comment on existing issue):**
57
+
58
+ 4. Evaluate lifecycle labels based on the full conversation:
59
+ - If the issue has `stale` or `autoclose`, remove the label — a new human comment means the issue is still active:
60
+ `./scripts/edit-issue-labels.sh --remove-label "stale" --remove-label "autoclose"`
61
+ - If the issue has `needs-repro` or `needs-info` and the missing information has now been provided, remove the label:
62
+ `./scripts/edit-issue-labels.sh --remove-label "needs-repro"`
63
+ - If the issue doesn't have lifecycle labels but clearly needs them (e.g., a maintainer asked for repro steps or more details), add the appropriate label.
64
+ - Comments like "+1", "me too", "same here", or emoji reactions are NOT the missing information. Only remove `needs-repro` or `needs-info` when substantive details are actually provided.
65
+ - Do NOT add or remove category labels (bug, enhancement, etc.) on comment events.
66
+
67
+ GUIDELINES:
68
+ - ONLY use labels from `./scripts/gh.sh label list` — never create or guess label names
69
+ - DO NOT post any comments to the issue
70
+ - Be conservative with lifecycle labels — only apply when clearly warranted
71
+ - Only apply lifecycle labels (`needs-repro`, `needs-info`) to bugs — never to questions or enhancements
72
+ - When in doubt, don't apply a lifecycle label — false positives are worse than missing labels
73
+ - On new issues (EVENT "issues"), always apply exactly one of `bug`, `enhancement`, `question`, `invalid`, or `duplicate`. If unsure, pick the closest fit — an imperfect category label is better than none.
74
+ - On comment events, it's okay to make no changes if nothing applies.
.gitattributes CHANGED
@@ -1,2 +1,2 @@
1
  * text=auto eol=lf
2
- *.sh text eol=lf
 
1
  * text=auto eol=lf
2
+ *.sh text eol=lfdemo.gif filter=lfs diff=lfs merge=lfs -text
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 🐛 Bug Report
2
+ description: Report a bug or unexpected behavior in Claude Code
3
+ title: "[BUG] "
4
+ labels:
5
+ - bug
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ Thanks for taking the time to report this bug! Please fill out the sections below to help us understand and fix the issue.
11
+
12
+ Before submitting, please check:
13
+ - You're using the [latest version](https://www.npmjs.com/package/@anthropic-ai/claude-code?activeTab=versions) of Claude Code (`claude --version`)
14
+ - This issue hasn't already been reported by searching [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug).
15
+ - This is a bug, not a feature request or support question
16
+
17
+ - type: checkboxes
18
+ id: preflight
19
+ attributes:
20
+ label: Preflight Checklist
21
+ description: Please confirm before submitting
22
+ options:
23
+ - label: I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) and this hasn't been reported yet
24
+ required: true
25
+ - label: This is a single bug report (please file separate reports for different bugs)
26
+ required: true
27
+ - label: I am using the latest version of Claude Code
28
+ required: true
29
+
30
+ - type: textarea
31
+ id: actual
32
+ attributes:
33
+ label: What's Wrong?
34
+ description: Describe what's happening that shouldn't be
35
+ placeholder: |
36
+ When I try to create a Python file, Claude shows an error "EACCES: permission denied" and the file isn't created.
37
+
38
+ The command fails immediately after accepting the file write permission...
39
+ validations:
40
+ required: true
41
+
42
+ - type: textarea
43
+ id: expected
44
+ attributes:
45
+ label: What Should Happen?
46
+ description: Describe the expected behavior
47
+ placeholder: Claude should create a Python script file successfully without errors
48
+ validations:
49
+ required: true
50
+
51
+ - type: textarea
52
+ id: error_output
53
+ attributes:
54
+ label: Error Messages/Logs
55
+ description: If you see any error messages, paste them here
56
+ placeholder: |
57
+ Paste any error output, stack traces, or relevant logs here.
58
+ This will be automatically formatted as code.
59
+ render: shell
60
+ validations:
61
+ required: false
62
+
63
+ - type: textarea
64
+ id: reproduction
65
+ attributes:
66
+ label: Steps to Reproduce
67
+ description: |
68
+ Please provide clear, numbered steps that anyone can follow to reproduce the issue.
69
+ **Important**: Include any necessary code, file contents, or context needed to reproduce the bug.
70
+ If the issue involves specific files or code, please create a minimal example.
71
+ placeholder: |
72
+ 1. Create a file `test.py` with this content:
73
+ ```python
74
+ def hello():
75
+ print("test")
76
+ ```
77
+ 2. Run `claude "add type hints to test.py"`
78
+ 3. When prompted for file access, accept
79
+ 4. Error appears: "Unable to parse..."
80
+
81
+ Note: The bug only happens with Python files containing...
82
+ validations:
83
+ required: true
84
+
85
+ - type: dropdown
86
+ id: model
87
+ attributes:
88
+ label: Claude Model
89
+ description: Which model were you using? (Run `/model` to check)
90
+ options:
91
+ - Sonnet (default)
92
+ - Opus
93
+ - Not sure / Multiple models
94
+ - Other
95
+ validations:
96
+ required: false
97
+
98
+ - type: dropdown
99
+ id: regression
100
+ attributes:
101
+ label: Is this a regression?
102
+ description: Did this work in a previous version?
103
+ options:
104
+ - "Yes, this worked in a previous version"
105
+ - "No, this never worked"
106
+ - "I don't know"
107
+ validations:
108
+ required: true
109
+
110
+ - type: input
111
+ id: working_version
112
+ attributes:
113
+ label: Last Working Version
114
+ description: If this is a regression, which version last worked? This helps expedite a fix.
115
+ placeholder: "e.g., 1.0.100"
116
+ validations:
117
+ required: false
118
+
119
+ - type: input
120
+ id: version
121
+ attributes:
122
+ label: Claude Code Version
123
+ description: Run `claude --version` and paste the output
124
+ placeholder: "e.g., 1.0.123 (Claude Code)"
125
+ validations:
126
+ required: true
127
+
128
+ - type: dropdown
129
+ id: platform
130
+ attributes:
131
+ label: Platform
132
+ description: Which API platform are you using?
133
+ options:
134
+ - Anthropic API
135
+ - AWS Bedrock
136
+ - Google Vertex AI
137
+ - Other
138
+ validations:
139
+ required: true
140
+
141
+ - type: dropdown
142
+ id: os
143
+ attributes:
144
+ label: Operating System
145
+ options:
146
+ - macOS
147
+ - Windows
148
+ - Ubuntu/Debian Linux
149
+ - Other Linux
150
+ - Other
151
+ validations:
152
+ required: true
153
+
154
+ - type: dropdown
155
+ id: terminal
156
+ attributes:
157
+ label: Terminal/Shell
158
+ description: Which terminal are you using?
159
+ options:
160
+ - Terminal.app (macOS)
161
+ - Warp
162
+ - Cursor
163
+ - iTerm2
164
+ - IntelliJ IDEA terminal
165
+ - VS Code integrated terminal
166
+ - PyCharm terminal
167
+ - Windows Terminal
168
+ - PowerShell
169
+ - WSL (Windows Subsystem for Linux)
170
+ - Xterm
171
+ - Non-interactive/CI environment
172
+ - Other
173
+ validations:
174
+ required: true
175
+
176
+ - type: textarea
177
+ id: additional
178
+ attributes:
179
+ label: Additional Information
180
+ description: |
181
+ Anything else that might help us understand the issue?
182
+ - Screenshots (drag and drop images here)
183
+ - Configuration files
184
+ - Related files or code
185
+ - Links to repositories demonstrating the issue
186
+ placeholder: Any additional context, screenshots, or information...
187
+ validations:
188
+ required: false
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: 💬 Discord Community
4
+ url: https://anthropic.com/discord
5
+ about: Get help, ask questions, and chat with other Claude Code users
6
+ - name: 📖 Documentation
7
+ url: https://docs.claude.com/en/docs/claude-code
8
+ about: Read the official documentation and guides
9
+ - name: 🎓 Getting Started Guide
10
+ url: https://docs.claude.com/en/docs/claude-code/quickstart
11
+ about: New to Claude Code? Start here
12
+ - name: 🔧 Troubleshooting Guide
13
+ url: https://docs.claude.com/en/docs/claude-code/troubleshooting
14
+ about: Common issues and how to fix them
.github/ISSUE_TEMPLATE/documentation.yml ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 📚 Documentation Issue
2
+ description: Report missing, unclear, or incorrect documentation
3
+ title: "[DOCS] "
4
+ labels:
5
+ - documentation
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ ## Help us improve our documentation!
11
+
12
+ Good documentation is crucial for a great developer experience. Please let us know what's missing or confusing.
13
+
14
+ - type: dropdown
15
+ id: doc_type
16
+ attributes:
17
+ label: Documentation Type
18
+ description: What kind of documentation issue is this?
19
+ options:
20
+ - Missing documentation (feature not documented)
21
+ - Unclear/confusing documentation
22
+ - Incorrect/outdated documentation
23
+ - Typo or formatting issue
24
+ - Missing code examples
25
+ - Broken links
26
+ - Other
27
+ validations:
28
+ required: true
29
+
30
+ - type: input
31
+ id: location
32
+ attributes:
33
+ label: Documentation Location
34
+ description: Where did you encounter this issue? Provide a URL if possible
35
+ placeholder: "e.g., https://docs.anthropic.com/en/docs/claude-code/getting-started"
36
+ validations:
37
+ required: false
38
+
39
+ - type: input
40
+ id: section
41
+ attributes:
42
+ label: Section/Topic
43
+ description: Which specific section or topic needs improvement?
44
+ placeholder: "e.g., MCP Server Configuration section"
45
+ validations:
46
+ required: true
47
+
48
+ - type: textarea
49
+ id: current
50
+ attributes:
51
+ label: Current Documentation
52
+ description: |
53
+ What does the documentation currently say?
54
+ Quote the specific text if applicable.
55
+ placeholder: |
56
+ The docs currently say:
57
+ "To configure MCP servers, add them to your configuration..."
58
+
59
+ But it doesn't explain...
60
+ validations:
61
+ required: false
62
+
63
+ - type: textarea
64
+ id: issue
65
+ attributes:
66
+ label: What's Wrong or Missing?
67
+ description: Explain what's incorrect, unclear, or missing
68
+ placeholder: |
69
+ The documentation doesn't explain how to:
70
+ - Configure multiple MCP servers
71
+ - Handle authentication
72
+ - Debug connection issues
73
+
74
+ The example code doesn't work because...
75
+ validations:
76
+ required: true
77
+
78
+ - type: textarea
79
+ id: suggested
80
+ attributes:
81
+ label: Suggested Improvement
82
+ description: How should the documentation be improved? Provide suggested text if possible
83
+ placeholder: |
84
+ The documentation should include:
85
+
86
+ 1. A complete example showing...
87
+ 2. Explanation of common errors like...
88
+ 3. Step-by-step guide for...
89
+
90
+ Suggested text:
91
+ "To configure multiple MCP servers, create an array in your settings..."
92
+ validations:
93
+ required: true
94
+
95
+ - type: dropdown
96
+ id: impact
97
+ attributes:
98
+ label: Impact
99
+ description: How much does this documentation issue affect users?
100
+ options:
101
+ - High - Prevents users from using a feature
102
+ - Medium - Makes feature difficult to understand
103
+ - Low - Minor confusion or inconvenience
104
+ validations:
105
+ required: true
106
+
107
+ - type: textarea
108
+ id: additional
109
+ attributes:
110
+ label: Additional Context
111
+ description: |
112
+ - Screenshots showing the issue
113
+ - Links to related documentation
114
+ - Examples from other projects that do this well
115
+ placeholder: Any additional information that would help...
116
+ validations:
117
+ required: false
.github/ISSUE_TEMPLATE/feature_request.yml ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: ✨ Feature Request
2
+ description: Suggest a new feature or enhancement for Claude Code
3
+ title: "[FEATURE] "
4
+ labels:
5
+ - enhancement
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ ## Thanks for suggesting a feature!
11
+
12
+ We love hearing ideas from our community. Please help us understand your use case by filling out the sections below.
13
+ Before submitting, please check if this feature has already been requested.
14
+
15
+ - type: checkboxes
16
+ id: preflight
17
+ attributes:
18
+ label: Preflight Checklist
19
+ options:
20
+ - label: I have searched [existing requests](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20label%3Aenhancement) and this feature hasn't been requested yet
21
+ required: true
22
+ - label: This is a single feature request (not multiple features)
23
+ required: true
24
+
25
+ - type: textarea
26
+ id: problem
27
+ attributes:
28
+ label: Problem Statement
29
+ description: |
30
+ What problem are you trying to solve? Why do you need this feature?
31
+ Focus on the problem, not the solution. Help us understand your workflow.
32
+ placeholder: |
33
+ I often need to work with multiple projects simultaneously, but Claude Code doesn't support...
34
+
35
+ When I'm debugging code, I find it difficult to...
36
+
37
+ The current workflow requires me to manually...
38
+ validations:
39
+ required: true
40
+
41
+ - type: textarea
42
+ id: solution
43
+ attributes:
44
+ label: Proposed Solution
45
+ description: |
46
+ How would you like this to work? Describe the ideal user experience.
47
+ Be specific about how you'd interact with this feature.
48
+ placeholder: |
49
+ I'd like to be able to run `claude --workspace project1,project2` to...
50
+
51
+ There should be a command or setting that allows...
52
+
53
+ The interface should show...
54
+ validations:
55
+ required: true
56
+
57
+ - type: textarea
58
+ id: alternatives
59
+ attributes:
60
+ label: Alternative Solutions
61
+ description: |
62
+ What alternatives have you considered or tried?
63
+ Are there workarounds you're currently using?
64
+ placeholder: |
65
+ I've tried using multiple terminal windows but...
66
+
67
+ Currently I work around this by...
68
+
69
+ Other tools solve this by...
70
+ validations:
71
+ required: false
72
+
73
+ - type: dropdown
74
+ id: priority
75
+ attributes:
76
+ label: Priority
77
+ description: How important is this feature to your workflow?
78
+ options:
79
+ - Critical - Blocking my work
80
+ - High - Significant impact on productivity
81
+ - Medium - Would be very helpful
82
+ - Low - Nice to have
83
+ validations:
84
+ required: true
85
+
86
+ - type: dropdown
87
+ id: category
88
+ attributes:
89
+ label: Feature Category
90
+ description: What area does this feature relate to?
91
+ options:
92
+ - CLI commands and flags
93
+ - Interactive mode (TUI)
94
+ - File operations
95
+ - API and model interactions
96
+ - MCP server integration
97
+ - Performance and speed
98
+ - Configuration and settings
99
+ - Developer tools/SDK
100
+ - Documentation
101
+ - Other
102
+ validations:
103
+ required: true
104
+
105
+ - type: textarea
106
+ id: use_case
107
+ attributes:
108
+ label: Use Case Example
109
+ description: |
110
+ Provide a concrete, real-world example of when you'd use this feature.
111
+ Walk us through a scenario step-by-step.
112
+ placeholder: |
113
+ Example scenario:
114
+ 1. I'm working on a React app with a Node.js backend
115
+ 2. I need to make changes to both frontend and backend
116
+ 3. With this feature, I could...
117
+ 4. This would save me time because...
118
+ validations:
119
+ required: false
120
+
121
+ - type: textarea
122
+ id: additional
123
+ attributes:
124
+ label: Additional Context
125
+ description: |
126
+ - Screenshots or mockups of the proposed feature
127
+ - Links to similar features in other tools
128
+ - Technical considerations or constraints
129
+ - Any other relevant information
130
+ placeholder: Add any other context, mockups, or examples here...
131
+ validations:
132
+ required: false
.github/ISSUE_TEMPLATE/model_behavior.yml ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 🤖 Model Behavior Issue
2
+ description: Report unexpected Claude model behavior, incorrect actions, or permission violations
3
+ title: "[MODEL] "
4
+ labels:
5
+ - model
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ ## Report Unexpected Model Behavior
11
+
12
+ Use this template when Claude does something unexpected, makes unwanted changes, or behaves inconsistently with your instructions.
13
+
14
+ **This is for:** Unexpected actions, file modifications outside scope, ignoring instructions, making assumptions
15
+ **NOT for:** Crashes, API errors, or installation issues (use Bug Report instead)
16
+
17
+ - type: checkboxes
18
+ id: preflight
19
+ attributes:
20
+ label: Preflight Checklist
21
+ description: Please confirm before submitting
22
+ options:
23
+ - label: I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Amodel) for similar behavior reports
24
+ required: true
25
+ - label: This report does NOT contain sensitive information (API keys, passwords, etc.)
26
+ required: true
27
+
28
+ - type: dropdown
29
+ id: behavior_type
30
+ attributes:
31
+ label: Type of Behavior Issue
32
+ description: What category best describes the unexpected behavior?
33
+ options:
34
+ - Claude modified files I didn't ask it to modify
35
+ - Claude accessed files outside the working directory
36
+ - Claude ignored my instructions or configuration
37
+ - Claude reverted/undid previous changes without asking
38
+ - Claude made incorrect assumptions about my project
39
+ - Claude refused a reasonable request
40
+ - Claude's behavior changed between sessions
41
+ - Subagent behaved unexpectedly
42
+ - Other unexpected behavior
43
+ validations:
44
+ required: true
45
+
46
+ - type: textarea
47
+ id: what_you_asked
48
+ attributes:
49
+ label: What You Asked Claude to Do
50
+ description: Provide the exact prompt or command you gave
51
+ placeholder: |
52
+ I asked: "Update the README.md file to add installation instructions"
53
+
54
+ Or I ran: `claude "fix the bug in auth.js"`
55
+ validations:
56
+ required: true
57
+
58
+ - type: textarea
59
+ id: what_claude_did
60
+ attributes:
61
+ label: What Claude Actually Did
62
+ description: Describe step-by-step what Claude did instead
63
+ placeholder: |
64
+ 1. Claude read README.md
65
+ 2. Instead of updating it, Claude deleted the entire file
66
+ 3. Created a new README from scratch with different content
67
+ 4. Also modified package.json without being asked
68
+ 5. Changed .gitignore file
69
+ validations:
70
+ required: true
71
+
72
+ - type: textarea
73
+ id: expected_behavior
74
+ attributes:
75
+ label: Expected Behavior
76
+ description: What should Claude have done?
77
+ placeholder: |
78
+ Claude should have:
79
+ 1. Read the existing README.md
80
+ 2. Added an "Installation" section
81
+ 3. Only modified that single file
82
+ 4. Not touched any other files
83
+ validations:
84
+ required: true
85
+
86
+ - type: textarea
87
+ id: files_affected
88
+ attributes:
89
+ label: Files Affected
90
+ description: |
91
+ List all files that were accessed or modified (even if you didn't expect them to be)
92
+ placeholder: |
93
+ Modified:
94
+ - README.md (deleted and recreated)
95
+ - package.json (version bumped - not requested)
96
+ - .gitignore (added entries - not requested)
97
+
98
+ Read (unexpectedly):
99
+ - /Users/me/.ssh/config
100
+ - ../../../parent-directory/secrets.env
101
+ render: shell
102
+ validations:
103
+ required: false
104
+
105
+ - type: dropdown
106
+ id: permission_mode
107
+ attributes:
108
+ label: Permission Mode
109
+ description: What permission settings were active?
110
+ options:
111
+ - Accept Edits was ON (auto-accepting changes)
112
+ - Accept Edits was OFF (manual approval required)
113
+ - I don't know / Not sure
114
+ validations:
115
+ required: true
116
+
117
+ - type: dropdown
118
+ id: reproducible
119
+ attributes:
120
+ label: Can You Reproduce This?
121
+ description: Does this happen consistently?
122
+ options:
123
+ - Yes, every time with the same prompt
124
+ - Sometimes (intermittent)
125
+ - No, only happened once
126
+ - Haven't tried to reproduce
127
+ validations:
128
+ required: true
129
+
130
+ - type: textarea
131
+ id: reproduction_steps
132
+ attributes:
133
+ label: Steps to Reproduce
134
+ description: If reproducible, provide minimal steps
135
+ placeholder: |
136
+ 1. Create a new directory with a simple README.md
137
+ 2. Ask Claude Code to "improve the README"
138
+ 3. Claude will delete and recreate the file instead of editing
139
+ validations:
140
+ required: false
141
+
142
+ - type: dropdown
143
+ id: model
144
+ attributes:
145
+ label: Claude Model
146
+ description: Which model were you using? (Run `/model` to check)
147
+ options:
148
+ - Sonnet
149
+ - Opus
150
+ - Haiku
151
+ - Not sure
152
+ - Other
153
+ validations:
154
+ required: true
155
+
156
+ - type: textarea
157
+ id: conversation_log
158
+ attributes:
159
+ label: Relevant Conversation
160
+ description: |
161
+ Include relevant parts of Claude's responses, especially where it explains what it's doing
162
+ placeholder: |
163
+ Claude said: "I'll help you update the README. Let me first delete the old one and create a fresh version..."
164
+
165
+ [Then proceeded to delete without asking for confirmation]
166
+ render: markdown
167
+ validations:
168
+ required: false
169
+
170
+ - type: dropdown
171
+ id: impact
172
+ attributes:
173
+ label: Impact
174
+ description: How severe was the impact of this behavior?
175
+ options:
176
+ - Critical - Data loss or corrupted project
177
+ - High - Significant unwanted changes
178
+ - Medium - Extra work to undo changes
179
+ - Low - Minor inconvenience
180
+ validations:
181
+ required: true
182
+
183
+ - type: input
184
+ id: version
185
+ attributes:
186
+ label: Claude Code Version
187
+ description: Run `claude --version` and paste the output
188
+ placeholder: "e.g., 1.0.123 (Claude Code)"
189
+ validations:
190
+ required: true
191
+
192
+ - type: dropdown
193
+ id: platform
194
+ attributes:
195
+ label: Platform
196
+ description: Which API platform are you using?
197
+ options:
198
+ - Anthropic API
199
+ - AWS Bedrock
200
+ - Google Vertex AI
201
+ - Other
202
+ validations:
203
+ required: true
204
+
205
+ - type: textarea
206
+ id: additional
207
+ attributes:
208
+ label: Additional Context
209
+ description: |
210
+ - Any patterns you've noticed
211
+ - Similar behavior in other sessions
212
+ - Specific file types or project structures that trigger this
213
+ - Screenshots if relevant
214
+ placeholder: |
215
+ This seems to happen more often with:
216
+ - Python projects
217
+ - When there are multiple similar files
218
+ - After long conversations
219
+ validations:
220
+ required: false
.github/workflows/auto-close-duplicates.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Auto-close duplicate issues
2
+ description: Auto-closes issues that are duplicates of existing issues
3
+ on:
4
+ schedule:
5
+ - cron: "0 9 * * *"
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ auto-close-duplicates:
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 10
12
+ permissions:
13
+ contents: read
14
+ issues: write
15
+
16
+ steps:
17
+ - name: Checkout repository
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Setup Bun
21
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned)
22
+ with:
23
+ bun-version: latest
24
+
25
+ - name: Auto-close duplicate issues
26
+ run: bun run scripts/auto-close-duplicates.ts
27
+ env:
28
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29
+ GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
30
+ GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
31
+ STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }}
.github/workflows/backfill-duplicate-comments.yml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Backfill Duplicate Comments
2
+ description: Triggers duplicate detection for old issues that don't have duplicate comments
3
+
4
+ on:
5
+ workflow_dispatch:
6
+ inputs:
7
+ days_back:
8
+ description: 'How many days back to look for old issues'
9
+ required: false
10
+ default: '90'
11
+ type: string
12
+ dry_run:
13
+ description: 'Dry run mode (true to only log what would be done)'
14
+ required: false
15
+ default: 'true'
16
+ type: choice
17
+ options:
18
+ - 'true'
19
+ - 'false'
20
+
21
+ jobs:
22
+ backfill-duplicate-comments:
23
+ runs-on: ubuntu-latest
24
+ timeout-minutes: 30
25
+ permissions:
26
+ contents: read
27
+ issues: read
28
+ actions: write
29
+
30
+ steps:
31
+ - name: Checkout repository
32
+ uses: actions/checkout@v4
33
+
34
+ - name: Setup Bun
35
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned)
36
+ with:
37
+ bun-version: latest
38
+
39
+ - name: Backfill duplicate comments
40
+ run: bun run scripts/backfill-duplicate-comments.ts
41
+ env:
42
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43
+ DAYS_BACK: ${{ inputs.days_back }}
44
+ DRY_RUN: ${{ inputs.dry_run }}
.github/workflows/claude-dedupe-issues.yml ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Claude Issue Dedupe
2
+ description: Automatically dedupe GitHub issues using Claude Code
3
+ on:
4
+ issues:
5
+ types: [opened]
6
+ workflow_dispatch:
7
+ inputs:
8
+ issue_number:
9
+ description: 'Issue number to process for duplicate detection'
10
+ required: true
11
+ type: string
12
+
13
+ jobs:
14
+ claude-dedupe-issues:
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 10
17
+ permissions:
18
+ contents: read
19
+ issues: write
20
+ # Required to mint the OIDC token exchanged for a Claude API access token (Workload Identity Federation)
21
+ id-token: write
22
+
23
+ steps:
24
+ - name: Checkout repository
25
+ uses: actions/checkout@v4
26
+
27
+ - name: Run Claude Code slash command
28
+ uses: anthropics/claude-code-action@v1
29
+ env:
30
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31
+ CLAUDE_CODE_SCRIPT_CAPS: '{"comment-on-duplicates.sh":1}'
32
+ with:
33
+ github_token: ${{ secrets.GITHUB_TOKEN }}
34
+ allowed_non_write_users: "*"
35
+ prompt: "/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}"
36
+ # Authenticate to the Claude API via Workload Identity Federation
37
+ # (the workflow's OIDC token is exchanged for a short-lived access
38
+ # token) instead of a static API key.
39
+ anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
40
+ anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
41
+ anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
42
+ anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
43
+ claude_args: "--model claude-sonnet-4-5-20250929"
44
+
45
+ - name: Log duplicate comment event to Statsig
46
+ if: always()
47
+ env:
48
+ STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }}
49
+ ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
50
+ REPO: ${{ github.repository }}
51
+ TRIGGERED_BY: ${{ github.event_name }}
52
+ WORKFLOW_RUN_ID: ${{ github.run_id }}
53
+ run: |
54
+ if [ -z "$STATSIG_API_KEY" ]; then
55
+ echo "STATSIG_API_KEY not found, skipping Statsig logging"
56
+ exit 0
57
+ fi
58
+
59
+ # Prepare the event payload
60
+ EVENT_PAYLOAD=$(jq -n \
61
+ --arg issue_number "$ISSUE_NUMBER" \
62
+ --arg repo "$REPO" \
63
+ --arg triggered_by "$TRIGGERED_BY" \
64
+ --arg workflow_run_id "$WORKFLOW_RUN_ID" \
65
+ '{
66
+ events: [{
67
+ eventName: "github_duplicate_comment_added",
68
+ value: 1,
69
+ metadata: {
70
+ repository: $repo,
71
+ issue_number: ($issue_number | tonumber),
72
+ triggered_by: $triggered_by,
73
+ workflow_run_id: $workflow_run_id
74
+ },
75
+ time: (now | floor | tostring)
76
+ }]
77
+ }')
78
+
79
+ # Send to Statsig API
80
+ echo "Logging duplicate comment event to Statsig for issue #${ISSUE_NUMBER}"
81
+
82
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST https://events.statsigapi.net/v1/log_event \
83
+ -H "Content-Type: application/json" \
84
+ -H "STATSIG-API-KEY: ${STATSIG_API_KEY}" \
85
+ -d "$EVENT_PAYLOAD")
86
+
87
+ HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
88
+ BODY=$(echo "$RESPONSE" | head -n-1)
89
+
90
+ if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 202 ]; then
91
+ echo "Successfully logged duplicate comment event for issue #${ISSUE_NUMBER}"
92
+ else
93
+ echo "Failed to log duplicate comment event for issue #${ISSUE_NUMBER}. HTTP ${HTTP_CODE}: ${BODY}"
94
+ fi
.github/workflows/claude-issue-triage.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Claude Issue Triage
2
+ on:
3
+ issues:
4
+ types: [opened]
5
+ issue_comment:
6
+ types: [created]
7
+
8
+ jobs:
9
+ triage-issue:
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 10
12
+ if: >-
13
+ github.event_name == 'issues' ||
14
+ (github.event_name == 'issue_comment' && !github.event.issue.pull_request && github.event.comment.user.type != 'Bot')
15
+ concurrency:
16
+ group: issue-triage-${{ github.event.issue.number }}
17
+ cancel-in-progress: true
18
+ permissions:
19
+ contents: read
20
+ issues: write
21
+ # Required to mint the OIDC token exchanged for a Claude API access token (Workload Identity Federation)
22
+ id-token: write
23
+
24
+ steps:
25
+ - name: Checkout repository
26
+ uses: actions/checkout@v4
27
+
28
+ - name: Run Claude Code for Issue Triage
29
+ timeout-minutes: 5
30
+ uses: anthropics/claude-code-action@v1
31
+ env:
32
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33
+ GH_REPO: ${{ github.repository }}
34
+ CLAUDE_CODE_SCRIPT_CAPS: '{"edit-issue-labels.sh":2}'
35
+ with:
36
+ github_token: ${{ secrets.GITHUB_TOKEN }}
37
+ allowed_non_write_users: "*"
38
+ prompt: "/triage-issue REPO: ${{ github.repository }} ISSUE_NUMBER: ${{ github.event.issue.number }} EVENT: ${{ github.event_name }}"
39
+ # Authenticate to the Claude API via Workload Identity Federation
40
+ # (the workflow's OIDC token is exchanged for a short-lived access
41
+ # token) instead of a static API key.
42
+ anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
43
+ anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
44
+ anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
45
+ anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
46
+ claude_args: |
47
+ --model claude-opus-4-6
.github/workflows/claude.yml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Claude Code
2
+
3
+ on:
4
+ issue_comment:
5
+ types: [created]
6
+ pull_request_review_comment:
7
+ types: [created]
8
+ issues:
9
+ types: [opened, assigned]
10
+ pull_request_review:
11
+ types: [submitted]
12
+
13
+ jobs:
14
+ claude:
15
+ if: |
16
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20
+ runs-on: ubuntu-latest
21
+ permissions:
22
+ contents: read
23
+ pull-requests: read
24
+ issues: read
25
+ id-token: write
26
+ steps:
27
+ - name: Checkout repository
28
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
29
+ with:
30
+ fetch-depth: 1
31
+
32
+ - name: Run Claude Code
33
+ id: claude
34
+ uses: anthropics/claude-code-action@v1
35
+ with:
36
+ # Authenticate to the Claude API via Workload Identity Federation
37
+ # (the workflow's OIDC token is exchanged for a short-lived access
38
+ # token) instead of a static API key.
39
+ anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
40
+ anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
41
+ anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
42
+ anthropic_workspace_id: ${{ vars.ANTHROPIC_WORKSPACE_ID }}
43
+ claude_args: "--model claude-sonnet-4-5-20250929"
44
+
.github/workflows/issue-lifecycle-comment.yml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Issue Lifecycle Comment"
2
+
3
+ on:
4
+ issues:
5
+ types: [labeled]
6
+
7
+ permissions:
8
+ issues: write
9
+
10
+ jobs:
11
+ comment:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Setup Bun
18
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned)
19
+ with:
20
+ bun-version: latest
21
+
22
+ - name: Post lifecycle comment
23
+ run: bun run scripts/lifecycle-comment.ts
24
+ env:
25
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26
+ LABEL: ${{ github.event.label.name }}
27
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
.github/workflows/issue-opened-dispatch.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Issue Opened Dispatch
2
+
3
+ on:
4
+ issues:
5
+ types: [opened]
6
+
7
+ permissions:
8
+ issues: read
9
+ actions: write
10
+
11
+ jobs:
12
+ notify:
13
+ runs-on: ubuntu-latest
14
+ timeout-minutes: 1
15
+ steps:
16
+ - name: Process new issue
17
+ env:
18
+ ISSUE_URL: ${{ github.event.issue.html_url }}
19
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
20
+ ISSUE_TITLE: ${{ github.event.issue.title }}
21
+ TARGET_REPO: ${{ secrets.ISSUE_OPENED_DISPATCH_TARGET_REPO }}
22
+ GH_TOKEN: ${{ secrets.ISSUE_OPENED_DISPATCH_TOKEN }}
23
+ run: |
24
+ gh api repos/${TARGET_REPO}/dispatches \
25
+ -f event_type=issue_opened \
26
+ -f client_payload[issue_url]="${ISSUE_URL}" || {
27
+ exit 0
28
+ }
.github/workflows/lock-closed-issues.yml ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Lock Stale Issues"
2
+
3
+ on:
4
+ schedule:
5
+ # 8am Pacific = 1pm UTC (2pm UTC during DST)
6
+ - cron: "0 14 * * *"
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ issues: write
11
+
12
+ concurrency:
13
+ group: lock-threads
14
+
15
+ jobs:
16
+ lock-closed-issues:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Lock closed issues after 7 days of inactivity
20
+ uses: actions/github-script@v7
21
+ with:
22
+ script: |
23
+ const sevenDaysAgo = new Date();
24
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
25
+ const cutoff = sevenDaysAgo.toISOString().split('T')[0];
26
+
27
+ const lockComment = `This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.`;
28
+
29
+ const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:closed is:unlocked updated:<${cutoff}`;
30
+ console.log(`Search query: ${query}`);
31
+
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+ const MAX_PER_RUN = 250;
34
+ const processed = new Set();
35
+ let totalLocked = 0;
36
+
37
+ while (totalLocked < MAX_PER_RUN) {
38
+ const { data } = await github.rest.search.issuesAndPullRequests({
39
+ q: query,
40
+ sort: 'updated',
41
+ order: 'asc',
42
+ per_page: 100,
43
+ });
44
+
45
+ if (totalLocked === 0) {
46
+ console.log(`Total candidates: ${data.total_count}`);
47
+ }
48
+
49
+ const fresh = data.items.filter((i) => !processed.has(i.number));
50
+ if (fresh.length === 0) break;
51
+
52
+ for (const issue of fresh) {
53
+ if (totalLocked >= MAX_PER_RUN) break;
54
+ processed.add(issue.number);
55
+ try {
56
+ await github.rest.issues.createComment({
57
+ owner: context.repo.owner,
58
+ repo: context.repo.repo,
59
+ issue_number: issue.number,
60
+ body: lockComment,
61
+ });
62
+ await github.rest.issues.lock({
63
+ owner: context.repo.owner,
64
+ repo: context.repo.repo,
65
+ issue_number: issue.number,
66
+ lock_reason: 'resolved',
67
+ });
68
+ totalLocked++;
69
+ console.log(`Locked issue #${issue.number}: ${issue.title}`);
70
+ await sleep(1000);
71
+ } catch (error) {
72
+ console.error(`Failed to lock issue #${issue.number}: ${error.message}`);
73
+ }
74
+ }
75
+ }
76
+
77
+ console.log(`Total issues locked: ${totalLocked}`);
.github/workflows/log-issue-events.yml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Log Issue Events to Statsig
2
+
3
+ on:
4
+ issues:
5
+ types: [opened, closed]
6
+
7
+ jobs:
8
+ log-to-statsig:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ issues: read
12
+ steps:
13
+ - name: Log issue creation to Statsig
14
+ env:
15
+ STATSIG_API_KEY: ${{ secrets.STATSIG_API_KEY }}
16
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
17
+ REPO: ${{ github.repository }}
18
+ ISSUE_TITLE: ${{ github.event.issue.title }}
19
+ AUTHOR: ${{ github.event.issue.user.login }}
20
+ CREATED_AT: ${{ github.event.issue.created_at }}
21
+ run: |
22
+ # All values are now safely passed via environment variables
23
+ # No direct templating in the shell script to prevent injection attacks
24
+
25
+ curl -X POST "https://events.statsigapi.net/v1/log_event" \
26
+ -H "Content-Type: application/json" \
27
+ -H "statsig-api-key: $STATSIG_API_KEY" \
28
+ -d '{
29
+ "events": [{
30
+ "eventName": "github_issue_created",
31
+ "metadata": {
32
+ "issue_number": "'"$ISSUE_NUMBER"'",
33
+ "repository": "'"$REPO"'",
34
+ "title": "'"$(echo "$ISSUE_TITLE" | sed "s/\"/\\\\\"/g")"'",
35
+ "author": "'"$AUTHOR"'",
36
+ "created_at": "'"$CREATED_AT"'"
37
+ },
38
+ "time": '"$(date +%s)000"'
39
+ }]
40
+ }'
.github/workflows/mod-tests.yml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Mod tests"
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - "mods/**"
7
+ - ".github/workflows/mod-tests.yml"
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
18
+ with:
19
+ persist-credentials: false
20
+
21
+ - name: Install Claude Code and TypeScript
22
+ run: npm install -g @anthropic-ai/claude-code typescript@5.9.3
23
+
24
+ - name: Typecheck each mod's hooks and tests
25
+ run: tsc -p mods/tsconfig.json
26
+
27
+ - name: Run each mod's tests
28
+ run: |
29
+ if ! claude plugin test --help 2>/dev/null | grep -q '^Usage: claude plugin test'; then
30
+ echo "claude $(claude --version) has no 'plugin test' yet; skipping the test step"
31
+ exit 0
32
+ fi
33
+ status=0
34
+ for mod in mods/*/; do
35
+ [ -d "$mod/tests" ] || continue
36
+ claude plugin test "$mod" || status=1
37
+ done
38
+ exit $status
39
+ env:
40
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "1"
.github/workflows/non-write-users-check.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Non-write Users Check
2
+ on:
3
+ pull_request:
4
+ paths:
5
+ - ".github/**"
6
+
7
+ permissions:
8
+ contents: read
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ allowed-non-write-check:
13
+ runs-on: ubuntu-latest
14
+ env:
15
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16
+ steps:
17
+ - run: |
18
+ DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" || true)
19
+
20
+ if ! echo "$DIFF" | grep -qE '^diff --git a/\.github/.*\.ya?ml'; then
21
+ exit 0
22
+ fi
23
+
24
+ MATCHES=$(echo "$DIFF" | grep "^+.*allowed_non_write_users" || true)
25
+
26
+ if [ -z "$MATCHES" ]; then
27
+ exit 0
28
+ fi
29
+
30
+ EXISTING=$(gh pr view "$PR_NUMBER" -R "$REPO" --json comments --jq '.comments[].body' \
31
+ | grep -c "<!-- non-write-users-check -->" || true)
32
+
33
+ if [ "$EXISTING" -gt 0 ]; then
34
+ exit 0
35
+ fi
36
+
37
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body '<!-- non-write-users-check -->
38
+ **`allowed_non_write_users` detected**
39
+
40
+ This PR adds or modifies `allowed_non_write_users`, which allows users without write access to trigger Claude Code Action workflows. This can introduce security risks.
41
+
42
+ If this is a new flow, please make sure you actually need `allowed_non_write_users`. If you are editing an existing workflow, double check that you are not adding new Claude permissions which might lead to a vulnerability.
43
+
44
+ See existing workflows in this repo for safe usage examples, or contact the AppSec team.'
45
+ env:
46
+ PR_NUMBER: ${{ github.event.pull_request.number }}
47
+ REPO: ${{ github.repository }}
.github/workflows/remove-autoclose-label.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Remove Autoclose Label on Activity"
2
+
3
+ on:
4
+ issue_comment:
5
+ types: [created]
6
+
7
+ permissions:
8
+ issues: write
9
+
10
+ jobs:
11
+ remove-autoclose:
12
+ # Only run if the issue has the autoclose label
13
+ if: |
14
+ github.event.issue.state == 'open' &&
15
+ contains(github.event.issue.labels.*.name, 'autoclose') &&
16
+ github.event.comment.user.login != 'github-actions[bot]'
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Remove autoclose label
20
+ uses: actions/github-script@v7
21
+ with:
22
+ script: |
23
+ console.log(`Removing autoclose label from issue #${context.issue.number} due to new comment from ${context.payload.comment.user.login}`);
24
+
25
+ try {
26
+ // Remove the autoclose label
27
+ await github.rest.issues.removeLabel({
28
+ owner: context.repo.owner,
29
+ repo: context.repo.repo,
30
+ issue_number: context.issue.number,
31
+ name: 'autoclose'
32
+ });
33
+
34
+ console.log(`Successfully removed autoclose label from issue #${context.issue.number}`);
35
+ } catch (error) {
36
+ // If the label was already removed or doesn't exist, that's fine
37
+ if (error.status === 404) {
38
+ console.log(`Autoclose label was already removed from issue #${context.issue.number}`);
39
+ } else {
40
+ throw error;
41
+ }
42
+ }
.github/workflows/sweep.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Issue Sweep"
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 10,22 * * *"
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ issues: write
10
+
11
+ concurrency:
12
+ group: daily-issue-sweep
13
+
14
+ jobs:
15
+ sweep:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Setup Bun
22
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned)
23
+ with:
24
+ bun-version: latest
25
+
26
+ - name: Enforce lifecycle timeouts
27
+ run: bun run scripts/sweep.ts
28
+ env:
29
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30
+ GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
31
+ GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
demo.gif ADDED

Git LFS Details

  • SHA256: 461cf109f52648929acd9959a87e5673541f8249497512b8e4f1d32d10f0fd57
  • Pointer size: 133 Bytes
  • Size of remote file: 11 MB
examples/gateway/aws/.dockerignore ADDED
@@ -0,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
examples/gateway/aws/.gitignore ADDED
@@ -0,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
examples/gateway/aws/Dockerfile ADDED
@@ -0,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"]
examples/gateway/aws/README.md ADDED
@@ -0,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`) |
examples/gateway/aws/gateway.yaml.example ADDED
@@ -0,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 }
examples/gateway/aws/setup.sh ADDED
@@ -0,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
examples/gateway/aws/terraform/.gitignore ADDED
@@ -0,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
examples/gateway/aws/terraform/README.md ADDED
@@ -0,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.
examples/gateway/aws/terraform/main.tf ADDED
@@ -0,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
+ }
examples/gateway/aws/terraform/outputs.tf ADDED
@@ -0,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
+ }
examples/gateway/aws/terraform/terraform.tfvars.example ADDED
@@ -0,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 = "..."
examples/gateway/aws/terraform/variables.tf ADDED
@@ -0,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
+ }
examples/gateway/aws/terraform/versions.tf ADDED
@@ -0,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
+ }
examples/gateway/gcp/.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the build context to just the binary the Dockerfile COPYs. BuildKit (the
2
+ # default, selected via the Dockerfile's syntax directive) only syncs the
3
+ # referenced COPY source anyway, so this is a no-op there — it matters for the
4
+ # classic builder (DOCKER_BUILDKIT=0) and as a conventional signal that the
5
+ # .gitignore'd secrets in this directory aren't part of the image build.
6
+ terraform/
7
+ **/.terraform/
8
+ *.tfstate*
9
+ terraform.tfvars
10
+ gateway.yaml
11
+ secrets/
12
+ *.pem
13
+ client_secret_*.json
examples/gateway/gcp/.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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
6
+ secrets/
7
+ client_secret_*.json
8
+ *.pem
9
+
10
+ # Release binary and pinned version — setup.sh downloads/writes these per release
11
+ claude
12
+ .claude-version
examples/gateway/gcp/Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # Runtime image for `claude gateway`.
4
+ #
5
+ # This image does NOT build the binary. It expects a prebuilt native
6
+ # linux-x64 `claude` executable in the build context — the public Claude Code
7
+ # release binary, which includes the `gateway` subcommand. setup.sh places it
8
+ # at ./claude (downloading it from the public release endpoint and verifying
9
+ # it against the release manifest if missing). Override CLAUDE_BINARY to
10
+ # point at a different path.
11
+ #
12
+ # Build (with the binary at ./claude; otherwise add --build-arg CLAUDE_BINARY=<path>):
13
+ # docker build --platform=linux/amd64 --provenance=false -t claude-gateway .
14
+ #
15
+ # Run:
16
+ # docker run --rm -p 8080:8080 \
17
+ # -v "$PWD/gateway.yaml:/etc/claude/gateway.yaml:ro" \
18
+ # -e OIDC_CLIENT_SECRET -e GATEWAY_JWT_SECRET -e GATEWAY_POSTGRES_URL \
19
+ # claude-gateway
20
+
21
+ ARG CLAUDE_BINARY=./claude
22
+
23
+ # distroless/cc provides glibc + libstdc++ (required by the Bun-compiled
24
+ # native binary). The :nonroot tag runs as uid/gid 65532.
25
+ FROM gcr.io/distroless/cc-debian12:nonroot
26
+
27
+ ARG CLAUDE_BINARY
28
+ COPY --chmod=0755 ${CLAUDE_BINARY} /usr/local/bin/claude
29
+
30
+ ENV CLAUDE_CONFIG_DIR=/tmp/.claude
31
+
32
+ EXPOSE 8080
33
+ USER nonroot
34
+
35
+ ENTRYPOINT ["/usr/local/bin/claude", "gateway", "--config", "/etc/claude/gateway.yaml"]
examples/gateway/gcp/README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Gateway on Google Cloud
2
+
3
+ Reference deployment artifacts for running Claude Gateway on GCP with Agent
4
+ Platform (formerly Vertex AI) as the upstream: Cloud Run or GKE, Cloud SQL for
5
+ PostgreSQL, Secret Manager, and service-account auth to Agent Platform.
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-gcp
11
+
12
+ | File | Purpose |
13
+ |---|---|
14
+ | `setup.sh` | Scripts the walkthrough end to end via `gcloud` |
15
+ | `Dockerfile` | Runtime image for the `claude gateway` binary |
16
+ | `gateway.yaml.example` | Gateway config template, GCP-shaped (Agent Platform upstream, Google Workspace IdP) |
17
+ | `terraform/` | Provisions the full architecture (two-pass apply — see `terraform/README.md`) |
examples/gateway/gcp/gateway.yaml.example ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # gateway.yaml.example — Claude Gateway config template, GCP-shaped (walkthrough §6).
2
+ #
3
+ # Google Workspace IdP + Agent Platform (formerly Vertex AI) upstream, following
4
+ # the walkthrough at https://code.claude.com/docs/en/claude-apps-gateway-on-gcp.
5
+ # The active sections
6
+ # below are a strict subset of the full configuration reference at
7
+ # https://code.claude.com/docs/en/claude-apps-gateway; optional keys are included
8
+ # commented-out.
9
+ #
10
+ # USAGE — this is the shippable TEMPLATE. Copy it to gateway.yaml and fill it in:
11
+ # cp gateway.yaml.example gateway.yaml
12
+ # setup.sh and terraform/ read gateway.yaml (your filled-in copy, which is
13
+ # gitignored). It is published as the Secret Manager secret `gateway-config`
14
+ # (§6) and mounted at /etc/claude/gateway.yaml — the container ENTRYPOINT runs
15
+ # `claude gateway --config /etc/claude/gateway.yaml`.
16
+ #
17
+ # Secret expansion: ${ENV_VAR} reads an env var; ${file:/path} reads a mounted file.
18
+ # On Cloud Run, setup.sh injects the JWT / OIDC / Postgres secrets as ENV VARS
19
+ # (Cloud Run can't mount multiple secrets into a single directory), and mounts
20
+ # only gateway.yaml itself as a file at /etc/claude. On GKE you may use file mounts.
21
+ #
22
+ # BEFORE DEPLOY — replace every REPLACE_ME placeholder below (setup.sh refuses to
23
+ # publish the config secret while any remain), and create the referenced secrets:
24
+ # gateway-jwt-secret (setup.sh generates this)
25
+ # gateway-oidc-client-secret (from the Google Cloud Console OAuth client)
26
+ # gateway-postgres-url (setup.sh generates this)
27
+
28
+ # ── Listener ─────────────────────────────────────────────────────────────────
29
+ listen:
30
+ host: 0.0.0.0
31
+ port: 8080 # Cloud Run sets PORT=8080; leave as-is
32
+ # Required. Fixes the IdP redirect_uri, the OIDC discovery doc, and the
33
+ # gateway-token issuer so none are derived from the client-controlled Host
34
+ # header (X-Forwarded-Host/-Proto are likewise never trusted). On Cloud Run
35
+ # the run.app URL is only assigned on the first deploy, so this starts as a
36
+ # placeholder for the provisioning-only first pass (login does NOT work until
37
+ # the real URL is set). After the first deploy, setup.sh prints the run.app
38
+ # URL: set it here (or your LB hostname) and re-run; setup.sh republishes the
39
+ # config and redeploys. Register the same host's /oauth/callback on the
40
+ # Google OAuth client.
41
+ public_url: https://set-after-first-deploy.invalid
42
+ # Register this exact redirect URI on the Google OAuth client:
43
+ # https://<public_url host>/oauth/callback
44
+ #
45
+ # On Cloud Run (or behind any L7 LB) every request arrives via Google's front
46
+ # end, so the gateway sees one peer IP for all developers — set trusted_proxies
47
+ # so X-Forwarded-For from those proxies is trusted and per-IP rate limiting /
48
+ # audit IPs record the real client. 169.254.0.0/16 is Cloud Run's fixed
49
+ # link-local serving range; the proxy-only subnet is the one your internal ALB
50
+ # uses in this VPC.
51
+ # trusted_proxies:
52
+ # - 169.254.0.0/16 # Cloud Run serving proxy (link-local peer)
53
+ # - <proxy-only-subnet-cidr> # add if fronted by your internal ALB (its proxy-only subnet)
54
+ #
55
+ # Alternative — terminate TLS in the gateway itself instead of at a proxy:
56
+ # tls:
57
+ # cert: /certs/gateway.crt
58
+ # key: /certs/gateway.key
59
+
60
+ # ── Identity provider — Google Workspace ─────────────────────────────────────
61
+ oidc:
62
+ issuer: https://accounts.google.com
63
+ client_id: REPLACE_ME # Google OAuth client ID (not secret; from Cloud Console)
64
+ client_secret: ${OIDC_CLIENT_SECRET}
65
+ allowed_email_domains: [REPLACE_ME] # e.g. [example.com] — reject id_tokens outside your org
66
+ # Google ignores the default offline_access scope; these two are what actually
67
+ # yield refresh tokens (silent renewal + the deprovision leash) from Google.
68
+ scopes: [openid, profile, email]
69
+ extra_auth_params: { access_type: offline, prompt: consent }
70
+ # NOTE: Google id_tokens carry NO groups claim. For group-based RBAC with
71
+ # Google as IdP, set `google_groups` (below) and the gateway fetches each
72
+ # user's Workspace groups at login via the Admin SDK Directory API.
73
+ # Otherwise, use email_domain matching (see managed.policies below).
74
+ # google_groups:
75
+ # service_account_json_path: /secrets/google-sa.json # SA with domain-wide delegation on admin.directory.group.readonly
76
+ # admin_email: admin@example.com # a Workspace admin the SA impersonates
77
+ # groups_claim: groups # Okta=groups, Entra app roles=roles — NOT Google
78
+ # ca_cert_pem: ${file:/secrets/idp-ca.pem} # only for an IdP behind a private CA
79
+
80
+ # ── Sessions ─────────────────────────────────────────────────────────────────
81
+ session:
82
+ jwt_secret: ${GATEWAY_JWT_SECRET} # >= 32 bytes; openssl rand -base64 32
83
+ # Google issues refresh tokens (above), so sessions renew silently and this
84
+ # mainly bounds deprovision latency. 8 is a sane default; lower toward 1 for
85
+ # tighter revocation. Array form rotates keys: [new, old] (index 0 signs, all verify).
86
+ ttl_hours: 8
87
+
88
+ # ── Store (REQUIRED — the gateway refuses to boot without it) ─────────────────
89
+ store:
90
+ postgres_url: ${GATEWAY_POSTGRES_URL} # private-IP Cloud SQL; built with ?sslmode=require by setup.sh
91
+
92
+ # ── Upstreams — Agent Platform ───────────────────────────────────────────────
93
+ upstreams:
94
+ - provider: vertex
95
+ region: us-east5 # a region where the Claude models you need are published in Model Garden
96
+ project_id: REPLACE_ME # your GCP project ID for Agent Platform access
97
+ auth: {} # ADC via Cloud Run SA / GKE Workload Identity (preferred — no static keys)
98
+ # base_url: https://us-east5-aiplatform.p.googleapis.com # Private Service Connect endpoint
99
+ # Add more upstreams for failover (tried top→bottom on 5xx/timeout/501): a
100
+ # second region, or an anthropic/bedrock fallback. See
101
+ # https://code.claude.com/docs/en/claude-apps-gateway.
102
+
103
+ # ── Telemetry fan-out (OPTIONAL) ─────────────────────────────────────────────
104
+ # The CLI sends OTLP/HTTP to the gateway; the gateway fans out, stamping
105
+ # user.id/user.email/user.groups server-side. On GCP, point at an OpenTelemetry
106
+ # Collector with the googlecloud exporter (-> Cloud Trace / Managed Prometheus).
107
+ # Takes effect after the second pass (once public_url is the real URL, not the
108
+ # placeholder): when forward_to and public_url are both configured the gateway pushes
109
+ # CLAUDE_CODE_ENABLE_TELEMETRY and the OTEL exporter selectors to every client
110
+ # automatically — no per-developer config needed.
111
+ # telemetry:
112
+ # forward_to:
113
+ # - url: https://otel-collector.internal.example.com:4318
114
+ # headers:
115
+ # Authorization: ${file:/secrets/otlp-token}
116
+ # metrics: true # safe aggregate counters (default)
117
+ # logs: false # carries bash commands / tool inputs — opt in deliberately
118
+ # traces: false
119
+
120
+ # ── RBAC + managed settings (OPTIONAL; first-match-wins, top -> bottom) ───────
121
+ # With Google as IdP, match on email_domain, or on group email addresses
122
+ # (e.g. eng@example.com) once oidc.google_groups is configured above.
123
+ # managed:
124
+ # policies:
125
+ # - match: { email_domain: example.com }
126
+ # cli:
127
+ # availableModels: [claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5]
128
+ # permissions: { deny: ["Read(./.env)", "Read(./secrets/**)"] }
129
+ # - match: {} # catch-all floor — keep LAST
130
+ # cli:
131
+ # availableModels: [claude-sonnet-4-6, claude-haiku-4-5]
132
+
133
+ # ── Admin API (OPTIONAL — enables db-mode runtime config + spend caps) ───────
134
+ # admin_groups needs a groups claim — with Google as IdP, set
135
+ # oidc.google_groups (above) so Workspace group email addresses populate the
136
+ # claim, or use the bootstrap keys below instead. Named keys for
137
+ # attribution in the audit log; 32-char minimum on key values. On Cloud Run add
138
+ # these as env vars to --set-secrets (or terraform env value_source blocks),
139
+ # same as the JWT/OIDC/Postgres secrets above; on GKE you may use ${file:...}.
140
+ # admin:
141
+ # write_keys:
142
+ # - id: terraform
143
+ # key: ${GATEWAY_ADMIN_WRITE_KEY}
144
+ # read_keys:
145
+ # - id: reporting
146
+ # key: ${GATEWAY_ADMIN_READ_KEY}
147
+ # # admin_groups: [platform-finops@example.com] # group emails via oidc.google_groups, or any groups-capable IdP
148
+
149
+ # ── Model catalog (OPTIONAL) ─────────────────────────────────────────────────
150
+ # Default true: every built-in Claude model is exposed and auto-translated per
151
+ # upstream. Set false + a models: list to pin IDs (e.g. provisioned throughput).
152
+ # auto_include_builtin_models: true
153
+ # models:
154
+ # - id: claude-opus-4-8
155
+ # label: Claude Opus 4.8
156
+ # upstream_model: { vertex: claude-opus-4-8 }
examples/gateway/gcp/setup.sh ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # setup.sh — GCP setup for Claude Gateway (walkthrough §1–7b).
4
+ #
5
+ # Provisions, in doc order: APIs (§1), service account + IAM (§2), the gateway
6
+ # container image in Artifact Registry (§3), a Cloud SQL (PostgreSQL) backend
7
+ # with PRIVATE IP only (§4), the JWT + postgres-url secrets (§5), the
8
+ # gateway.yaml config secret (§6), and a Cloud Run deploy with Direct VPC
9
+ # egress (§7b).
10
+ #
11
+ # Private IP is required because public IP is disallowed by the org-policy constraint
12
+ # `constraints/sql.restrictPublicIp`. A Cloud SQL private IP is an address inside a VPC,
13
+ # so §4 here also provisions the prerequisite VPC + Private Services Access — the
14
+ # one-time, irreducible networking required for private IP.
15
+ #
16
+ # Section markers (§N) below map to the walkthrough:
17
+ # https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
18
+ #
19
+ # Covers here: APIs (§1) -> service account + IAM (§2) -> build & push image (§3)
20
+ # -> VPC + Private Services Access -> Cloud SQL (private IP only) -> database
21
+ # + user (§4) -> jwt + postgres-url secrets (§5) -> gateway-config
22
+ # secret from gateway.yaml (§6) -> Cloud Run deploy (§7b).
23
+ # Not covered: GKE track (§7a) — Cloud Run is the lower-friction path here.
24
+ #
25
+ # Idempotent: existing resources are detected and skipped, so it is safe to re-run.
26
+ # Override any default below via environment variable, e.g. `REGION=us-east5 ./setup.sh`.
27
+
28
+ set -euo pipefail
29
+
30
+ # ---- configuration (env-overridable) ----------------------------------------
31
+ PROJECT_ID="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}"
32
+ REGION="${REGION:-${CLOUDSDK_COMPUTE_REGION:-us-east5}}" # guide §1 uses us-east5 (Agent Platform model region)
33
+
34
+ SA_NAME="${SA_NAME:-claude-gateway}" # §2 service account
35
+ SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
36
+
37
+ # §3 image
38
+ AR_REPO="${AR_REPO:-claude-gateway}" # Artifact Registry repository
39
+ IMAGE_NAME="${IMAGE_NAME:-gateway}"
40
+ RELEASES_URL="${RELEASES_URL:-https://downloads.claude.ai/claude-code-releases}" # public Claude Code release endpoint
41
+ VERSION="${VERSION:-}" # Claude Code release to deploy; empty = latest release (resolved below)
42
+ VERSION_FILE="${VERSION_FILE:-./.claude-version}" # pins the resolved release across re-runs; delete it (or set VERSION) to upgrade
43
+ DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
44
+ CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # linux-x64 Claude Code binary; downloaded from RELEASES_URL if missing
45
+ CLAUDE_SHA256="${CLAUDE_SHA256:-}" # optional: out-of-band sha256 pin for the downloaded binary, checked in addition to the release manifest
46
+
47
+ VPC_NETWORK="${VPC_NETWORK:-cc-gateway-vpc}"
48
+ SUBNET="${SUBNET:-cc-gateway-subnet}"
49
+ SUBNET_RANGE="${SUBNET_RANGE:-10.0.0.0/24}"
50
+
51
+ PSA_RANGE_NAME="${PSA_RANGE_NAME:-google-managed-services-${VPC_NETWORK}}"
52
+ PSA_PREFIX_LENGTH="${PSA_PREFIX_LENGTH:-16}" # /16 is GCP's recommendation; reserved, not consumed
53
+
54
+ DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
55
+ DB_VERSION="${DB_VERSION:-POSTGRES_16}" # PG14+ supported; 16 is the recommended default (§4)
56
+ DB_TIER="${DB_TIER:-db-g1-small}"
57
+ DB_NAME="${DB_NAME:-claude_gateway}"
58
+ DB_USER="${DB_USER:-gateway}"
59
+
60
+ SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
61
+ JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
62
+
63
+ GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §6 config file
64
+ CONFIG_SECRET="${CONFIG_SECRET:-gateway-config}" # §6 mounted at /etc/claude/gateway.yaml
65
+
66
+ # §7 Cloud Run deploy
67
+ SERVICE_NAME="${SERVICE_NAME:-claude-gateway}"
68
+ OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Google OAuth client)
69
+ DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no Cloud Run deploy
70
+ INGRESS="${INGRESS:-internal}" # internal (default; no public URL) | internal-and-cloud-load-balancing (only if you front it with your own internal ALB)
71
+ MAX_INSTANCES="${MAX_INSTANCES:-8}" # keep MAX_INSTANCES × store.max_connections (default 5) below the DB tier's max_connections (~50 on db-g1-small); raise the tier before raising this
72
+
73
+ # ---- helpers ----------------------------------------------------------------
74
+ log() { printf '\n==> %s\n' "$*"; }
75
+ skip() { printf ' (exists) %s\n' "$*"; }
76
+ curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
77
+ sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
78
+
79
+ if [[ -z "${PROJECT_ID}" ]]; then
80
+ echo "ERROR: PROJECT_ID is not set and no gcloud default project is configured." >&2
81
+ echo " Set it with: export PROJECT_ID=<your-project> (or 'gcloud config set project ...')" >&2
82
+ exit 1
83
+ fi
84
+ # VERSION tags the image and selects the public Claude Code release to download.
85
+ # The first resolved value is pinned to ${VERSION_FILE} so the documented
86
+ # re-runs (fill gateway.yaml -> re-run; set public_url -> re-run) don't silently
87
+ # build and deploy a newer release mid-bootstrap.
88
+ if [[ -z "${VERSION}" && -f "${VERSION_FILE}" ]]; then
89
+ VERSION="$(< "${VERSION_FILE}")"
90
+ log "Using release pinned in ${VERSION_FILE}: ${VERSION} (delete the file or set VERSION to change it)"
91
+ elif [[ -z "${VERSION}" ]]; then
92
+ # /latest is the channel the official installer (claude.ai/install.sh) uses.
93
+ VERSION="$(curl_https -fsSL "${RELEASES_URL}/latest" | tr -d '[:space:]' || true)"
94
+ if [[ -z "${VERSION}" ]]; then
95
+ echo "ERROR: could not resolve the latest release from ${RELEASES_URL}/latest." >&2
96
+ echo " Set VERSION to a Claude Code release version, e.g. export VERSION=2.1.195" >&2
97
+ exit 1
98
+ fi
99
+ log "VERSION not set — using latest Claude Code release: ${VERSION}"
100
+ fi
101
+ # Reject non-version content (e.g. an HTML error page served with HTTP 200)
102
+ # before it reaches the image tag and download URLs.
103
+ if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
104
+ echo "ERROR: '${VERSION}' is not a release version (from VERSION, ${VERSION_FILE}, or ${RELEASES_URL}/latest)." >&2
105
+ exit 1
106
+ fi
107
+ printf '%s' "${VERSION}" > "${VERSION_FILE}"
108
+ IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/${IMAGE_NAME}:${VERSION}"
109
+ # Claude Code only connects to a gateway whose hostname resolves to private
110
+ # addresses (a client-side /login check), so public ingress can never serve
111
+ # clients — mirror the terraform module's validation and refuse it up front.
112
+ if [[ "${INGRESS}" != "internal" && "${INGRESS}" != "internal-and-cloud-load-balancing" ]]; then
113
+ echo "ERROR: INGRESS must be 'internal' or 'internal-and-cloud-load-balancing' — Claude Code's" >&2
114
+ echo " /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients." >&2
115
+ exit 1
116
+ fi
117
+
118
+ log "Project: ${PROJECT_ID} Region: ${REGION} VPC: ${VPC_NETWORK}"
119
+
120
+ # ---- 1 Project & API setup ------------------------------------------------
121
+ # walkthrough §1 list (aiplatform, artifactregistry, sqladmin, secretmanager, iamcredentials)
122
+ # plus iam/compute/servicenetworking required for the SA + private-IP networking below.
123
+ # container.googleapis.com is for the GKE track (§7a) — harmless if you stay on Cloud Run.
124
+ # We pass --project on every call rather than mutating your gcloud config.
125
+ log "Enabling required APIs (§1)"
126
+ gcloud services enable \
127
+ aiplatform.googleapis.com \
128
+ artifactregistry.googleapis.com \
129
+ sqladmin.googleapis.com \
130
+ secretmanager.googleapis.com \
131
+ iamcredentials.googleapis.com \
132
+ iam.googleapis.com \
133
+ compute.googleapis.com \
134
+ container.googleapis.com \
135
+ servicenetworking.googleapis.com \
136
+ run.googleapis.com \
137
+ --project="${PROJECT_ID}"
138
+
139
+ # ---- 2 Service account & IAM ----------------------------------------------
140
+ log "Creating service account ${SA_EMAIL} and granting project roles (§2)"
141
+ if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
142
+ skip "service account ${SA_EMAIL}"
143
+ else
144
+ gcloud iam service-accounts create "${SA_NAME}" \
145
+ --display-name="Claude Gateway" --project="${PROJECT_ID}"
146
+ fi
147
+
148
+ # add-iam-policy-binding is idempotent (re-adding an existing binding is a no-op).
149
+ # --condition=None avoids the interactive condition prompt in non-interactive runs.
150
+ #
151
+ # Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at
152
+ # its PRIVATE IP with a password user (§4/§7b — direct TCP, not the Cloud SQL
153
+ # Auth Proxy / connector), so it never calls cloudsql.instances.connect and no
154
+ # roles/cloudsql.client grant is needed. Direct private-IP is used because the
155
+ # gateway's store is a plain postgres_url — no proxy sidecar/socket plumbing,
156
+ # one less moving part, and the connection string is portable across Cloud Run
157
+ # and GKE.
158
+ gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
159
+ --member="serviceAccount:${SA_EMAIL}" \
160
+ --role="roles/aiplatform.user" --condition=None >/dev/null # Agent Platform inference (§2)
161
+
162
+ # ---- 3 Build & push image to Artifact Registry ----------------------------
163
+ log "Ensuring Artifact Registry repo and image (§3)"
164
+ if gcloud artifacts repositories describe "${AR_REPO}" \
165
+ --location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
166
+ skip "Artifact Registry repo ${AR_REPO}"
167
+ else
168
+ gcloud artifacts repositories create "${AR_REPO}" \
169
+ --repository-format=docker --location="${REGION}" --project="${PROJECT_ID}"
170
+ fi
171
+
172
+ # Image is the expensive, already-done step: skip the build+push entirely if the
173
+ # tag already exists in the registry.
174
+ if gcloud artifacts docker images describe "${IMAGE}" >/dev/null 2>&1; then
175
+ skip "image ${IMAGE}"
176
+ else
177
+ # The public Claude Code release includes the gateway subcommand, so the
178
+ # binary comes straight from the release endpoint, verified against the
179
+ # release manifest's sha256. A pre-existing ${CLAUDE_BINARY} (stale version,
180
+ # interrupted download, hand-placed file) is verified the same way and
181
+ # re-downloaded on mismatch, so an unverified binary can never reach the image.
182
+ manifest="$(curl_https -fsSL "${RELEASES_URL}/${VERSION}/manifest.json" | tr -d '[:space:]' || true)"
183
+ sha_re='"linux-x64"[^}]*"checksum":"([a-f0-9]{64})"' # structure-based: survives pretty-printed, minified, and one-line-per-platform manifests
184
+ if [[ ! "${manifest}" =~ ${sha_re} ]]; then
185
+ echo "ERROR: could not read the linux-x64 sha256 from ${RELEASES_URL}/${VERSION}/manifest.json — refusing to build." >&2
186
+ exit 1
187
+ fi
188
+ expected_sha="${BASH_REMATCH[1]}"
189
+ if [[ -f "${CLAUDE_BINARY}" && "$(sha_of "${CLAUDE_BINARY}")" == "${expected_sha}" ]]; then
190
+ skip "binary ${CLAUDE_BINARY} (sha256 matches release ${VERSION})"
191
+ else
192
+ if [[ -f "${CLAUDE_BINARY}" ]]; then
193
+ log "Existing ${CLAUDE_BINARY} does not match release ${VERSION} — re-downloading"
194
+ else
195
+ log "Downloading Claude Code ${VERSION} (linux-x64) from ${RELEASES_URL}"
196
+ fi
197
+ # Until verification passes, ANY exit (curl failure, set -e, signal, the
198
+ # error exit below) removes the file, so a partial download can't be
199
+ # silently picked up by a later run.
200
+ trap 'rm -f "${CLAUDE_BINARY}"' EXIT INT TERM
201
+ curl_https -fL -o "${CLAUDE_BINARY}" "${RELEASES_URL}/${VERSION}/linux-x64/claude"
202
+ actual_sha="$(sha_of "${CLAUDE_BINARY}")"
203
+ if [[ "${actual_sha}" != "${expected_sha}" ]]; then
204
+ echo "ERROR: sha256 of ${CLAUDE_BINARY} is ${actual_sha} but the release manifest says ${expected_sha} — refusing to build." >&2
205
+ exit 1
206
+ fi
207
+ trap - EXIT INT TERM
208
+ log "Verified binary sha256 ${actual_sha}"
209
+ fi
210
+ # Optional out-of-band pin, checked even for a pre-existing binary: the
211
+ # manifest shares an origin with the binary, so it can't defend against a
212
+ # compromised endpoint — CLAUDE_SHA256 can.
213
+ if [[ -n "${CLAUDE_SHA256}" && "$(sha_of "${CLAUDE_BINARY}")" != "${CLAUDE_SHA256}" ]]; then
214
+ echo "ERROR: sha256 of ${CLAUDE_BINARY} does not match CLAUDE_SHA256 (${CLAUDE_SHA256}) — refusing to build." >&2
215
+ exit 1
216
+ fi
217
+ chmod +x "${CLAUDE_BINARY}"
218
+ log "Building and pushing ${IMAGE}"
219
+ gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
220
+ # Cloud Run requires linux/amd64. --platform forces it (e.g. when building on an
221
+ # Apple Silicon Mac), and --provenance=false keeps buildx from wrapping the result
222
+ # in an OCI image index that Cloud Run rejects ("manifest ... must support amd64/linux").
223
+ docker build --platform=linux/amd64 --provenance=false \
224
+ -f "${DOCKERFILE}" --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" -t "${IMAGE}" .
225
+ docker push "${IMAGE}"
226
+ fi
227
+
228
+ # ---- 4 VPC + Private Services Access (private-IP prerequisite) -------------
229
+ log "Creating VPC network and subnet"
230
+ if gcloud compute networks describe "${VPC_NETWORK}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
231
+ skip "network ${VPC_NETWORK}"
232
+ else
233
+ gcloud compute networks create "${VPC_NETWORK}" \
234
+ --subnet-mode=custom --project="${PROJECT_ID}"
235
+ fi
236
+
237
+ if gcloud compute networks subnets describe "${SUBNET}" \
238
+ --region="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
239
+ skip "subnet ${SUBNET}"
240
+ else
241
+ gcloud compute networks subnets create "${SUBNET}" \
242
+ --network="${VPC_NETWORK}" --region="${REGION}" \
243
+ --range="${SUBNET_RANGE}" --project="${PROJECT_ID}"
244
+ fi
245
+
246
+ log "Configuring Private Services Access (allocated range + VPC peering)"
247
+ if gcloud compute addresses describe "${PSA_RANGE_NAME}" \
248
+ --global --project="${PROJECT_ID}" >/dev/null 2>&1; then
249
+ skip "allocated range ${PSA_RANGE_NAME}"
250
+ else
251
+ gcloud compute addresses create "${PSA_RANGE_NAME}" \
252
+ --global --purpose=VPC_PEERING --prefix-length="${PSA_PREFIX_LENGTH}" \
253
+ --network="${VPC_NETWORK}" --project="${PROJECT_ID}"
254
+ fi
255
+
256
+ if gcloud services vpc-peerings list --network="${VPC_NETWORK}" --project="${PROJECT_ID}" \
257
+ --format='value(peering)' 2>/dev/null | grep -q servicenetworking; then
258
+ skip "servicenetworking VPC peering"
259
+ else
260
+ gcloud services vpc-peerings connect \
261
+ --service=servicenetworking.googleapis.com \
262
+ --ranges="${PSA_RANGE_NAME}" \
263
+ --network="${VPC_NETWORK}" --project="${PROJECT_ID}"
264
+ fi
265
+
266
+ # ---- 4 Cloud SQL instance (private IP only) -------------------------------
267
+ log "Creating Cloud SQL instance ${DB_INSTANCE} (private IP only)"
268
+ if gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
269
+ skip "instance ${DB_INSTANCE}"
270
+ else
271
+ gcloud sql instances create "${DB_INSTANCE}" \
272
+ --database-version="${DB_VERSION}" \
273
+ --tier="${DB_TIER}" \
274
+ --region="${REGION}" \
275
+ --network="projects/${PROJECT_ID}/global/networks/${VPC_NETWORK}" \
276
+ --no-assign-ip \
277
+ --project="${PROJECT_ID}"
278
+ fi
279
+
280
+ log "Creating database ${DB_NAME}"
281
+ if gcloud sql databases describe "${DB_NAME}" \
282
+ --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
283
+ skip "database ${DB_NAME}"
284
+ else
285
+ gcloud sql databases create "${DB_NAME}" \
286
+ --instance="${DB_INSTANCE}" --project="${PROJECT_ID}"
287
+ fi
288
+
289
+ # hex (not base64) keeps the password URL-safe for the connection string below.
290
+ log "Creating database user ${DB_USER}"
291
+ DB_PASSWORD=""
292
+ if gcloud sql users list --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" \
293
+ --format='value(name)' 2>/dev/null | grep -qx "${DB_USER}"; then
294
+ if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
295
+ skip "user ${DB_USER} (password unchanged; secret not rewritten)"
296
+ else
297
+ # Self-heal: a previous run died after creating the user but before writing
298
+ # the connection-string secret, losing the only copy of the password. The
299
+ # secret is the password's only consumer, so resetting it is safe and keeps
300
+ # re-runs able to recover from any partial state.
301
+ log "User ${DB_USER} exists but secret ${SECRET_NAME} is missing — resetting password"
302
+ DB_PASSWORD="$(openssl rand -hex 24)"
303
+ gcloud sql users set-password "${DB_USER}" \
304
+ --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
305
+ --project="${PROJECT_ID}"
306
+ fi
307
+ else
308
+ DB_PASSWORD="$(openssl rand -hex 24)"
309
+ gcloud sql users create "${DB_USER}" \
310
+ --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
311
+ --project="${PROJECT_ID}"
312
+ fi
313
+
314
+ # ---- 5 Connection string -> Secret Manager + secretAccessor ---------------
315
+ PRIVATE_IP="$(gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" \
316
+ --format='value(ipAddresses[0].ipAddress)')"
317
+
318
+ if [[ -n "${DB_PASSWORD}" ]]; then
319
+ # direct private-IP form, ?sslmode=require (guide §4)
320
+ CONN="postgres://${DB_USER}:${DB_PASSWORD}@${PRIVATE_IP}:5432/${DB_NAME}?sslmode=require"
321
+ log "Storing connection string in Secret Manager secret ${SECRET_NAME}"
322
+ if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
323
+ printf '%s' "${CONN}" | gcloud secrets versions add "${SECRET_NAME}" \
324
+ --data-file=- --project="${PROJECT_ID}"
325
+ else
326
+ printf '%s' "${CONN}" | gcloud secrets create "${SECRET_NAME}" \
327
+ --replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
328
+ fi
329
+ else
330
+ log "Skipping secret write (user already existed, password not available this run)"
331
+ fi
332
+
333
+ if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
334
+ log "Granting ${SA_EMAIL} secretAccessor on ${SECRET_NAME}"
335
+ gcloud secrets add-iam-policy-binding "${SECRET_NAME}" \
336
+ --member="serviceAccount:${SA_EMAIL}" \
337
+ --role="roles/secretmanager.secretAccessor" \
338
+ --condition=None --project="${PROJECT_ID}" >/dev/null
339
+ fi
340
+
341
+ # JWT signing secret — generated once (re-runs do NOT rotate it).
342
+ log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
343
+ if gcloud secrets describe "${JWT_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
344
+ skip "secret ${JWT_SECRET_NAME}"
345
+ else
346
+ openssl rand -base64 32 | tr -d '\n' | gcloud secrets create "${JWT_SECRET_NAME}" \
347
+ --replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
348
+ fi
349
+ gcloud secrets add-iam-policy-binding "${JWT_SECRET_NAME}" \
350
+ --member="serviceAccount:${SA_EMAIL}" \
351
+ --role="roles/secretmanager.secretAccessor" \
352
+ --condition=None --project="${PROJECT_ID}" >/dev/null
353
+
354
+ # OIDC client secret — operator-created (the script can't generate it). Grant
355
+ # accessor here once it exists so the deploy step doesn't fail on permission.
356
+ if gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
357
+ log "Granting ${SA_EMAIL} secretAccessor on ${OIDC_SECRET_NAME}"
358
+ gcloud secrets add-iam-policy-binding "${OIDC_SECRET_NAME}" \
359
+ --member="serviceAccount:${SA_EMAIL}" \
360
+ --role="roles/secretmanager.secretAccessor" \
361
+ --condition=None --project="${PROJECT_ID}" >/dev/null
362
+ fi
363
+
364
+ # ---- 6 gateway.yaml -> Secret Manager (gateway-config) --------------------
365
+ # Published only when fully filled in: refuse to push a config that still has
366
+ # REPLACE_ME placeholders (checked on non-comment lines so commented examples
367
+ # and this file's header don't trip the guard).
368
+ log "Publishing ${GATEWAY_YAML} as Secret Manager secret ${CONFIG_SECRET} (§6)"
369
+ if [[ ! -f "${GATEWAY_YAML}" ]]; then
370
+ echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§6)."
371
+ elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
372
+ echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
373
+ grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
374
+ echo " Fill them in, then re-run to publish ${CONFIG_SECRET}."
375
+ else
376
+ if gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
377
+ gcloud secrets versions add "${CONFIG_SECRET}" \
378
+ --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
379
+ else
380
+ gcloud secrets create "${CONFIG_SECRET}" --replication-policy=automatic \
381
+ --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
382
+ fi
383
+ gcloud secrets add-iam-policy-binding "${CONFIG_SECRET}" \
384
+ --member="serviceAccount:${SA_EMAIL}" \
385
+ --role="roles/secretmanager.secretAccessor" \
386
+ --condition=None --project="${PROJECT_ID}" >/dev/null
387
+ fi
388
+
389
+ # ---- 7 Cloud Run deploy (Direct VPC egress) -------------------------------
390
+ # Direct VPC egress (--network/--subnet/--vpc-egress) puts the service on the
391
+ # VPC so it reaches the Cloud SQL PRIVATE IP directly — matching the private-IP
392
+ # connection string in the postgres-url secret. private-ranges-only keeps public
393
+ # egress (Agent Platform, accounts.google.com) off the VPC, so no Cloud NAT is needed.
394
+ # We deliberately do NOT use --add-cloudsql-instances (that's the Auth Proxy /
395
+ # socket path, which would need a different connection string).
396
+ #
397
+ # Secrets: gateway.yaml is mounted as a FILE at /etc/claude (alone in its dir).
398
+ # The JWT / OIDC / Postgres secrets are injected as ENV VARS — Cloud Run cannot
399
+ # mount multiple secrets into one directory, and gateway.yaml references them via
400
+ # ${ENV_VAR}. (See the env-var names in gateway.yaml: GATEWAY_JWT_SECRET etc.)
401
+ #
402
+ # Self-gating: deploy only once its inputs exist (config secret published + the
403
+ # operator-provided OIDC client secret). On a first run these are missing and it
404
+ # cleanly skips.
405
+ RUN_URL=""
406
+ missing=""
407
+ gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${CONFIG_SECRET}"
408
+ gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${OIDC_SECRET_NAME}"
409
+ # Also gate on the postgres-url secret (referenced by --set-secrets below): if it
410
+ # is somehow absent, skip with a clear message rather than failing the deploy with
411
+ # a raw Cloud Run missing-secret error.
412
+ gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${SECRET_NAME}"
413
+
414
+ if [[ "${DEPLOY}" != "1" ]]; then
415
+ log "Skipping Cloud Run deploy (DEPLOY=${DEPLOY}) (§7)"
416
+ elif [[ -n "${missing// }" ]]; then
417
+ log "Skipping Cloud Run deploy — missing secret(s):${missing} (§7)"
418
+ echo " Fill ${GATEWAY_YAML} and re-run to publish ${CONFIG_SECRET}; create ${OIDC_SECRET_NAME}"
419
+ echo " from the Google OAuth client. Then re-run to deploy."
420
+ else
421
+ SECRET_MOUNTS="/etc/claude/gateway.yaml=${CONFIG_SECRET}:latest" # file mount (alone in /etc/claude)
422
+ SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_JWT_SECRET=${JWT_SECRET_NAME}:latest" # env var
423
+ SECRET_MOUNTS="${SECRET_MOUNTS},OIDC_CLIENT_SECRET=${OIDC_SECRET_NAME}:latest" # env var
424
+ SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_POSTGRES_URL=${SECRET_NAME}:latest" # env var
425
+
426
+ log "Deploying Cloud Run service ${SERVICE_NAME} (§7b, Direct VPC egress)"
427
+ # Deploy private (--no-allow-unauthenticated avoids the interactive prompt and
428
+ # keeps allUsers OUT of the deploy, so a Domain-Restricted-Sharing org doesn't
429
+ # fail the deploy on the IAM step). Public access is attempted separately below.
430
+ #
431
+ # --ingress is passed EXPLICITLY because it is sticky across redeploys (omitting
432
+ # it keeps the previous value). The default, internal, keeps the *.run.app URL
433
+ # off the public internet — reachable only from this VPC, or from corp networks
434
+ # with the PSC endpoint + private run.app DNS plumbing (see terraform/README.md
435
+ # "Private access"). Public ingress cannot serve clients (see the INGRESS
436
+ # guard at the top of this script), so the two-pass OAuth bootstrap has to be
437
+ # completed from inside the VPC (or a PSC-connected corp network). Use
438
+ # internal-and-cloud-load-balancing instead if you front the service with
439
+ # your own internal ALB.
440
+ #
441
+ # --timeout=3600 raises Cloud Run's default 300s request timeout, which would
442
+ # otherwise cut off long streaming /v1/messages responses mid-stream.
443
+ #
444
+ # --max-instances bounds the Postgres connection footprint: each instance
445
+ # opens a pool of up to 5 connections (store.max_connections default) and
446
+ # db-g1-small caps at ~50 max_connections, so the default ceiling of 100
447
+ # instances would crash-loop new instances under load. Keep
448
+ # max-instances × 5 below the DB tier's max_connections; raise the DB tier
449
+ # (or set store.max_connections lower) before raising this.
450
+ gcloud run deploy "${SERVICE_NAME}" \
451
+ --image="${IMAGE}" \
452
+ --region="${REGION}" \
453
+ --service-account="${SA_EMAIL}" \
454
+ --min-instances=1 \
455
+ --max-instances="${MAX_INSTANCES}" \
456
+ --port=8080 \
457
+ --timeout=3600 \
458
+ --ingress="${INGRESS}" \
459
+ --network="${VPC_NETWORK}" \
460
+ --subnet="${SUBNET}" \
461
+ --vpc-egress=private-ranges-only \
462
+ --set-secrets="${SECRET_MOUNTS}" \
463
+ --no-allow-unauthenticated \
464
+ --project="${PROJECT_ID}"
465
+
466
+ # The gateway runs its OWN OIDC, so the Cloud Run IAM layer must allow
467
+ # unauthenticated. Attempt it separately and tolerate failure: Domain Restricted
468
+ # Sharing (iam.allowedPolicyMemberDomains) blocks allUsers in hardened orgs.
469
+ log "Granting public invoker (allUsers) — required for the gateway's OIDC login"
470
+ if gcloud run services add-iam-policy-binding "${SERVICE_NAME}" \
471
+ --region="${REGION}" --member=allUsers --role=roles/run.invoker \
472
+ --project="${PROJECT_ID}" >/dev/null 2>&1; then
473
+ echo " public invoker granted."
474
+ else
475
+ echo " WARN: allUsers rejected (likely Domain Restricted Sharing). The service is"
476
+ echo " deployed but the invoker IAM check is still enabled, so requests 403"
477
+ echo " before reaching the container. Preferred fix (where available):"
478
+ echo " gcloud run services update ${SERVICE_NAME} --no-invoker-iam-check \\"
479
+ echo " --region=${REGION} --project=${PROJECT_ID}"
480
+ echo " Alternatively: request a DRS exception for ${SERVICE_NAME}, or use the GKE"
481
+ echo " track, which exposes the gateway at the network layer with no allUsers"
482
+ echo " binding. An LB is NOT a fix — it does not bypass the invoker IAM check."
483
+ fi
484
+
485
+ RUN_URL="$(gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" \
486
+ --project="${PROJECT_ID}" --format='value(status.url)')"
487
+ log "Cloud Run URL: ${RUN_URL}"
488
+
489
+ # public_url is now required (config validation refuses a non-loopback bind
490
+ # without it), so the template ships a placeholder for the first pass. Once we
491
+ # know the real URL, warn on any mismatch so the operator doesn't leave the
492
+ # placeholder — or a stale hostname — in place. Normalize quotes / inline
493
+ # comments / a trailing slash so schema-equivalent spellings compare equal.
494
+ # Only checked with internal ingress, where public_url should be the run.app
495
+ # URL; behind an internal ALB it is the ALB hostname, which this script
496
+ # cannot know.
497
+ CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
498
+ | head -1 \
499
+ | sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
500
+ || true)"
501
+ CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
502
+ CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
503
+ if [[ "${INGRESS}" == "internal" && -n "${RUN_URL}" && "${CFG_PUBLIC_URL}" != "${RUN_URL%/}" ]]; then
504
+ echo " NOTE — ${GATEWAY_YAML} has public_url: ${CFG_PUBLIC_URL:-<unset>}"
505
+ echo " but this service's URL is ${RUN_URL}."
506
+ echo " Set listen.public_url to ${RUN_URL} (or your LB hostname) and re-run."
507
+ fi
508
+
509
+ if [[ -n "${RUN_URL}" ]]; then
510
+ # gcloud run deploy already fails the script if the revision can't boot (it
511
+ # waits for the Ready condition), so what's left to verify is that the
512
+ # gateway is serving. The OAuth discovery document below returns 200 only
513
+ # after config load, OIDC discovery, upstream construction, and Postgres
514
+ # migration all succeed, so it doubles as an end-to-end boot check (the
515
+ # readiness probe proper is GET /readyz). With internal ingress the URL is
516
+ # reachable only from inside the VPC (or a PSC-connected corp network), so
517
+ # verification is left to the operator rather than attempted from here.
518
+ log "Verify the gateway is serving (from inside the VPC, or a PSC-connected corp network):"
519
+ echo " curl -s ${RUN_URL}/.well-known/oauth-authorization-server"
520
+ echo " If it isn't responding yet, check logs:"
521
+ echo " gcloud run services logs read ${SERVICE_NAME} --region=${REGION} --project=${PROJECT_ID}"
522
+
523
+ log "Finish the OAuth bootstrap:"
524
+ echo " 1. Register this redirect URI on the Google OAuth client: ${RUN_URL}/oauth/callback"
525
+ echo " 2. Set listen.public_url in ${GATEWAY_YAML} to ${RUN_URL}, then re-run: INGRESS=${INGRESS} ./setup.sh"
526
+ echo " (republishes ${CONFIG_SECRET} and redeploys so the IdP redirect_uri matches)."
527
+ echo " With INGRESS=internal-and-cloud-load-balancing, use your internal ALB hostname"
528
+ echo " instead of the run.app URL in both steps."
529
+ fi
530
+ fi
531
+
532
+ # ---- summary ----------------------------------------------------------------
533
+ cat <<EOF
534
+
535
+ ==> Done.
536
+
537
+ Service account ${SA_EMAIL}
538
+ roles: aiplatform.user, secretmanager.secretAccessor
539
+ Image ${IMAGE}
540
+ Instance ${DB_INSTANCE}
541
+ Connection name ${PROJECT_ID}:${REGION}:${DB_INSTANCE}
542
+ Private IP ${PRIVATE_IP}
543
+ Database / user ${DB_NAME} / ${DB_USER}
544
+ Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${CONFIG_SECRET}
545
+ Cloud Run service ${SERVICE_NAME} -> ${RUN_URL:-(not deployed yet)} (ingress: ${INGRESS})
546
+
547
+ Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-gcp):
548
+ - Create the one operator-provided secret (from the Google Cloud Console OAuth client):
549
+ printf '%s' "<client-secret>" | gcloud secrets create ${OIDC_SECRET_NAME} \\
550
+ --data-file=- --project="${PROJECT_ID}"
551
+ setup.sh grants ${SA_EMAIL} secretAccessor on it on the next re-run.
552
+ - Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh publishes
553
+ ${CONFIG_SECRET} and deploys ${SERVICE_NAME} once both secrets exist.
554
+ - After the first deploy: set listen.public_url to the Cloud Run URL above (or your
555
+ internal ALB hostname) and register <url>/oauth/callback on the Google OAuth client,
556
+ then re-run to redeploy.
557
+ - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
558
+ EOF
examples/gateway/gcp/terraform/.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
13
+ .terraform.lock.hcl
examples/gateway/gcp/terraform/README.md ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Gateway — Terraform (Cloud Run)
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-gcp) §1–7): APIs →
5
+ service account + IAM → Artifact Registry repo → VPC + Private Services Access →
6
+ private-IP Cloud SQL (PG16) → secrets → Cloud Run with Direct VPC egress.
7
+
8
+ ## Files
9
+
10
+ | File | Purpose |
11
+ |------|---------|
12
+ | `versions.tf` | Provider pins (google, random) |
13
+ | `variables.tf` | All inputs (defaults match `setup.sh`'s) |
14
+ | `main.tf` | Resources |
15
+ | `outputs.tf` | Service URL, OAuth redirect URI, SA, DB info |
16
+ | `terraform.tfvars.example` | Copy to `terraform.tfvars` and edit |
17
+
18
+ ## Prerequisites
19
+
20
+ 1. **`../gateway.yaml` created and filled in** — copy the template first:
21
+ `cp ../gateway.yaml.example ../gateway.yaml`, then replace every `REPLACE_ME`
22
+ (Terraform reads this file and enforces no `REPLACE_ME` via a precondition).
23
+ Leave `public_url` at its placeholder for the first apply; set it to the
24
+ `run.app` URL (the `service_url` output) or your LB hostname and re-apply.
25
+ `gateway.yaml` is gitignored; the committed template is `gateway.yaml.example`.
26
+ 2. A **remote backend** for shared use (see below). State holds secrets — never commit it.
27
+
28
+ ## Deploy
29
+
30
+ Terraform creates the Artifact Registry repo but does **not** build/push the
31
+ image, so the apply is two passes: a targeted apply to create the repo, then
32
+ build/push, then the full apply.
33
+
34
+ ```bash
35
+ cp terraform.tfvars.example terraform.tfvars # edit it
36
+ terraform init
37
+
38
+ # 1. Create just the Artifact Registry repo (the -target warning is expected):
39
+ terraform apply -target=google_artifact_registry_repository.repo
40
+
41
+ # 2. Download the public Claude Code linux-x64 release binary (it includes the
42
+ # `gateway` subcommand; the Dockerfile picks it up at gcp/claude), verify its
43
+ # sha256 against the release manifest, then build and push the image:
44
+ BASE="https://downloads.claude.ai/claude-code-releases"
45
+ VERSION="$(curl -fsSL --proto '=https' "${BASE}/latest")"
46
+ curl -fL --proto '=https' --proto-redir '=https' -o ../claude \
47
+ "${BASE}/${VERSION}/linux-x64/claude"
48
+ WANT="$(curl -fsSL --proto '=https' "${BASE}/${VERSION}/manifest.json" \
49
+ | tr -d '[:space:]' | grep -oE '"linux-x64"[^}]*' | grep -oE '[a-f0-9]{64}' | head -1)"
50
+ [ "$(openssl dgst -sha256 ../claude | awk '{print $NF}')" = "${WANT}" ] \
51
+ && echo "sha256 OK" || { echo "checksum mismatch" >&2; rm -f ../claude; }
52
+ gcloud auth configure-docker us-east5-docker.pkg.dev --quiet
53
+ docker build --platform=linux/amd64 --provenance=false \
54
+ -f ../Dockerfile -t "us-east5-docker.pkg.dev/<project>/claude-gateway/gateway:${VERSION}" ..
55
+ docker push "us-east5-docker.pkg.dev/<project>/claude-gateway/gateway:${VERSION}"
56
+
57
+ # 3. Full apply:
58
+ terraform apply
59
+ ```
60
+
61
+ (`../setup.sh` §3 automates the same download-and-verify.)
62
+
63
+ Set in `terraform.tfvars`:
64
+
65
+ - `project_id`, `region`
66
+ - `image_tag` (after building/pushing — step 2 above)
67
+ - **`oidc_client_secret`** — required (the Cloud Run service mounts `latest` of
68
+ this secret; with no version the deploy fails). Terraform creates the
69
+ secret + version from it.
70
+ - `invoker_iam_disabled` / `allow_unauthenticated` — the gateway runs its own
71
+ OIDC, so the Cloud Run invoker IAM check must be opened or disabled.
72
+ **Preferred:** `invoker_iam_disabled = true` (no `allUsers` binding; works
73
+ under Domain Restricted Sharing). **Fallback:** `allow_unauthenticated = true`
74
+ grants `allUsers` `run.invoker` — fine on a normal org, but DRS orgs reject
75
+ `allUsers` (set it `false` there, since an LB does **not** bypass the IAM
76
+ check). If both paths are blocked by org policy, use the GKE track.
77
+ - `ingress` — defaults to **internal-only** (no public URL). Claude Code's `/login`
78
+ only accepts gateway hosts on private addresses, so public ingress cannot serve
79
+ clients; the two-pass OAuth bootstrap must be completed from inside the VPC (or a
80
+ PSC-connected corp network). See "Private access" below.
81
+
82
+ Tear down a trial with `terraform destroy`: set `deletion_protection = false`,
83
+ run `terraform apply` to record that in state (the provider checks the value in
84
+ **state**, not config, so destroy would still refuse otherwise), then `terraform
85
+ destroy`. The destroy will stop at the VPC network
86
+ because the Private Services Access peering is intentionally left in place
87
+ (`deletion_policy = ABANDON` — see Guard rails below); finish by deleting the
88
+ peering manually once the Cloud SQL instance is gone, then re-run destroy:
89
+
90
+ ```bash
91
+ gcloud services vpc-peerings delete --service=servicenetworking.googleapis.com \
92
+ --network=cc-gateway-vpc --project=<project>
93
+ terraform destroy
94
+ ```
95
+
96
+ ## Guard rails
97
+
98
+ Tuned so accidental deletion is hard but greenfield teardown stays easy:
99
+
100
+ - `deletion_protection = true` (variable, default true) on Cloud SQL and Cloud Run —
101
+ blocks accidental deletion; set `false` when you intend to `terraform destroy`.
102
+ - `disable_on_destroy = false` on APIs — tearing down config never disables APIs.
103
+ - `deletion_policy = ABANDON` on the PSA peering — never tears down the
104
+ service-networking peering automatically (it's shared by every private-IP
105
+ service on the VPC). On the dedicated VPC this module creates, that means
106
+ `terraform destroy` stops at the network step; delete the peering manually
107
+ per the teardown note above.
108
+ - IAM uses non-authoritative `_member` resources, so other project/secret bindings
109
+ are never clobbered.
110
+
111
+ ## Private access (internal ingress) — the default
112
+
113
+ By default the service has **no public URL** (`ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"`),
114
+ and there is no public-ingress option: Claude Code's `/login` rejects gateway hosts that
115
+ resolve to public addresses, so public exposure cannot serve clients. Reach the service
116
+ from inside the VPC, or via the private-access plumbing below.
117
+
118
+ With internal-only ingress, `public_url` stays the `run.app` URL (Google-managed cert) —
119
+ **no load balancer or your own certificate required**. But internal ingress alone does
120
+ **not** let corporate on-prem clients reach `run.app`; that needs **operator /
121
+ network-team-owned** plumbing that Cloud Run does **not** create for you (validate it's in
122
+ place before relying on internal ingress):
123
+
124
+ 1. A **Private Service Connect endpoint** for Google APIs (an internal VIP in the VPC).
125
+ 2. A **Cloud DNS private zone for `run.app`** resolving `*.run.app` to that endpoint IP.
126
+ 3. **On-prem routing** to the endpoint over Cloud VPN / Interconnect.
127
+
128
+ This is normally managed centrally in the network/hub project, so the module does not
129
+ provision it. See [Private networking and Cloud Run](https://cloud.google.com/run/docs/securing/private-networking).
130
+ For a greenfield trial without this plumbing, complete the OAuth bootstrap from inside
131
+ the VPC — e.g. a browser proxied through an in-VPC VM (SSH SOCKS tunnel over IAP).
132
+
133
+ For a **custom internal hostname or your own TLS cert**, use
134
+ `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER` and front the service with your own internal
135
+ Application Load Balancer (also not provisioned by this module).
136
+
137
+ ## Remote state (recommended for teams)
138
+
139
+ Add a backend so state is shared and locked (and out of git):
140
+
141
+ ```hcl
142
+ # backend.tf
143
+ terraform {
144
+ backend "gcs" {
145
+ bucket = "<your-tf-state-bucket>"
146
+ prefix = "claude-gateway/cloudrun"
147
+ }
148
+ }
149
+ ```
150
+
151
+ ## After deploy
152
+
153
+ - `terraform output service_url` / `oauth_redirect_uri`.
154
+ - Register the redirect URI on the Google OAuth client and make sure
155
+ `../gateway.yaml` `public_url` matches the host.
156
+ - Notes: Terraform does not build the image. To ship a new gateway version,
157
+ rerun the docker build/push under a new tag and bump `image_tag` — a bare
158
+ re-apply under an unchanged tag does **not** roll a new revision (Cloud Run
159
+ resolves the tag to a digest only at revision creation, and an unchanged
160
+ `image` attribute means no new revision).
examples/gateway/gcp/terraform/main.tf ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Gateway on Cloud Run — 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-gcp
4
+
5
+ locals {
6
+ config_path = var.gateway_config_path != "" ? var.gateway_config_path : "${path.module}/../gateway.yaml"
7
+ gateway_config = file(local.config_path)
8
+ image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.ar_repo}/${var.image_name}:${var.image_tag}"
9
+
10
+ apis = [
11
+ "aiplatform.googleapis.com",
12
+ "artifactregistry.googleapis.com",
13
+ "cloudresourcemanager.googleapis.com",
14
+ "sqladmin.googleapis.com",
15
+ "secretmanager.googleapis.com",
16
+ "iamcredentials.googleapis.com",
17
+ "iam.googleapis.com",
18
+ "compute.googleapis.com",
19
+ "servicenetworking.googleapis.com",
20
+ "run.googleapis.com",
21
+ ]
22
+ }
23
+
24
+ # ── 1 Project & API setup ───────────────────────────────────────────────────
25
+ resource "google_project_service" "apis" {
26
+ for_each = toset(local.apis)
27
+ project = var.project_id
28
+ service = each.value
29
+ # Don't disable APIs (or delete anything) when this config is torn down.
30
+ disable_on_destroy = false
31
+ disable_dependent_services = false
32
+ }
33
+
34
+ # ── 2 Service account & IAM (least-privilege) ───────────────────────────────
35
+ resource "google_service_account" "gateway" {
36
+ project = var.project_id
37
+ account_id = var.sa_name
38
+ display_name = "Claude Gateway"
39
+ depends_on = [google_project_service.apis]
40
+ }
41
+
42
+ # Non-authoritative (_member) so we never clobber other project bindings.
43
+ #
44
+ # Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at
45
+ # its private IP with a password user (direct TCP via Direct VPC egress — see §7
46
+ # below), not via the Cloud SQL Auth Proxy / connector, so it never calls
47
+ # cloudsql.instances.connect and no roles/cloudsql.client grant is needed.
48
+ # Direct private-IP keeps the gateway's store a plain postgres_url with no proxy
49
+ # sidecar/socket plumbing, and the connection string is portable across Cloud
50
+ # Run and GKE.
51
+ resource "google_project_iam_member" "vertex" {
52
+ project = var.project_id
53
+ role = "roles/aiplatform.user" # Agent Platform inference
54
+ member = "serviceAccount:${google_service_account.gateway.email}"
55
+ }
56
+
57
+ # ── 3 Artifact Registry repo ────────────────────────────────────────────────
58
+ # NOTE: image build/push is a separate step (see README) — Terraform only makes the repo.
59
+ resource "google_artifact_registry_repository" "repo" {
60
+ project = var.project_id
61
+ location = var.region
62
+ repository_id = var.ar_repo
63
+ format = "DOCKER"
64
+ description = "Claude Gateway container images"
65
+ depends_on = [google_project_service.apis]
66
+ }
67
+
68
+ # ── 4 VPC + Private Services Access ──────────────────────────────────────────
69
+ resource "google_compute_network" "vpc" {
70
+ project = var.project_id
71
+ name = var.vpc_network
72
+ auto_create_subnetworks = false
73
+ depends_on = [google_project_service.apis]
74
+ }
75
+
76
+ resource "google_compute_subnetwork" "subnet" {
77
+ project = var.project_id
78
+ name = var.subnet
79
+ region = var.region
80
+ network = google_compute_network.vpc.id
81
+ ip_cidr_range = var.subnet_range
82
+ }
83
+
84
+ resource "google_compute_global_address" "psa_range" {
85
+ project = var.project_id
86
+ name = "google-managed-services-${var.vpc_network}"
87
+ purpose = "VPC_PEERING"
88
+ address_type = "INTERNAL"
89
+ prefix_length = var.psa_prefix_length
90
+ network = google_compute_network.vpc.id
91
+ }
92
+
93
+ resource "google_service_networking_connection" "psa" {
94
+ network = google_compute_network.vpc.id
95
+ service = "servicenetworking.googleapis.com"
96
+ reserved_peering_ranges = [google_compute_global_address.psa_range.name]
97
+ # ABANDON: on destroy, leave the producer peering in place (deleting it can hang
98
+ # and would affect any other private-IP service on this VPC).
99
+ deletion_policy = "ABANDON"
100
+ # If the peering already exists (e.g. a previous apply failed partway), patch it
101
+ # instead of failing the create.
102
+ update_on_creation_fail = true
103
+ depends_on = [google_project_service.apis]
104
+ }
105
+
106
+ # ── 4 Cloud SQL (private IP only) ───────────────────────────────────────────
107
+ resource "google_sql_database_instance" "db" {
108
+ project = var.project_id
109
+ name = var.db_instance
110
+ region = var.region
111
+ database_version = var.db_version
112
+ deletion_protection = var.deletion_protection
113
+ depends_on = [google_service_networking_connection.psa]
114
+
115
+ settings {
116
+ tier = var.db_tier
117
+ ip_configuration {
118
+ ipv4_enabled = false # private IP only (org policy: sql.restrictPublicIp)
119
+ private_network = google_compute_network.vpc.id
120
+ ssl_mode = "ENCRYPTED_ONLY"
121
+ }
122
+ }
123
+ }
124
+
125
+ resource "google_sql_database" "db" {
126
+ project = var.project_id
127
+ name = var.db_name
128
+ instance = google_sql_database_instance.db.name
129
+ }
130
+
131
+ # URL-safe (alphanumeric) so it drops cleanly into the connection string.
132
+ # nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
133
+ resource "random_password" "db" {
134
+ length = 32
135
+ special = false
136
+ }
137
+
138
+ # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
139
+ resource "google_sql_user" "gateway" {
140
+ project = var.project_id
141
+ name = var.db_user
142
+ instance = google_sql_database_instance.db.name
143
+ password = random_password.db.result
144
+ # On destroy the role owns the tables it migrated at boot, so DROP ROLE can
145
+ # fail (and races google_sql_database.db). ABANDON is harmless on the
146
+ # greenfield teardown — the whole instance is deleted anyway.
147
+ deletion_policy = "ABANDON"
148
+ }
149
+
150
+ # ── 5/6 Secrets + secretAccessor ────────────────────────────────────────────
151
+ # postgres-url: connection string built from the instance's private IP.
152
+ resource "google_secret_manager_secret" "postgres_url" {
153
+ project = var.project_id
154
+ secret_id = var.secret_name
155
+ replication {
156
+ auto {}
157
+ }
158
+ depends_on = [google_project_service.apis]
159
+ }
160
+
161
+ # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
162
+ resource "google_secret_manager_secret_version" "postgres_url" {
163
+ secret = google_secret_manager_secret.postgres_url.id
164
+ secret_data = "postgres://${var.db_user}:${random_password.db.result}@${google_sql_database_instance.db.private_ip_address}:5432/${var.db_name}?sslmode=require"
165
+ }
166
+
167
+ # jwt: session signing key.
168
+ # nosemgrep: terraform-generic-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
169
+ resource "random_password" "jwt" {
170
+ length = 48
171
+ special = false
172
+ }
173
+
174
+ resource "google_secret_manager_secret" "jwt" {
175
+ project = var.project_id
176
+ secret_id = var.jwt_secret_name
177
+ replication {
178
+ auto {}
179
+ }
180
+ depends_on = [google_project_service.apis]
181
+ }
182
+
183
+ # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
184
+ resource "google_secret_manager_secret_version" "jwt" {
185
+ secret = google_secret_manager_secret.jwt.id
186
+ secret_data = random_password.jwt.result
187
+ }
188
+
189
+ # oidc client secret: operator-provided (from the Google OAuth client).
190
+ resource "google_secret_manager_secret" "oidc" {
191
+ project = var.project_id
192
+ secret_id = var.oidc_secret_name
193
+ replication {
194
+ auto {}
195
+ }
196
+ depends_on = [google_project_service.apis]
197
+ }
198
+
199
+ # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
200
+ resource "google_secret_manager_secret_version" "oidc" {
201
+ count = var.oidc_client_secret != "" ? 1 : 0
202
+ secret = google_secret_manager_secret.oidc.id
203
+ secret_data = var.oidc_client_secret
204
+ }
205
+
206
+ # Warn (not block) at plan time when the OIDC secret value isn't set: the Cloud
207
+ # Run service mounts gateway-oidc-client-secret:latest unconditionally, so an
208
+ # empty value with no out-of-band version means the apply fails late at
209
+ # revision creation. A warning (not a precondition) keeps the documented
210
+ # out-of-band-version mode usable.
211
+ check "oidc_client_secret_set" {
212
+ assert {
213
+ condition = var.oidc_client_secret != ""
214
+ 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 Cloud Run revision mounts it at :latest and will fail without one)."
215
+ }
216
+ }
217
+
218
+ # config: gateway.yaml. Guard mirrors the bash REPLACE_ME check (non-comment lines).
219
+ resource "google_secret_manager_secret" "config" {
220
+ project = var.project_id
221
+ secret_id = var.config_secret_name
222
+ replication {
223
+ auto {}
224
+ }
225
+ depends_on = [google_project_service.apis]
226
+ }
227
+
228
+ # nosemgrep: terraform-gcp-secrets-in-state -- secrets in tfstate are inherent to TF; mitigated by the documented remote GCS backend (see README "Remote state")
229
+ resource "google_secret_manager_secret_version" "config" {
230
+ secret = google_secret_manager_secret.config.id
231
+ secret_data = local.gateway_config
232
+
233
+ lifecycle {
234
+ precondition {
235
+ condition = length([
236
+ for line in split("\n", local.gateway_config) :
237
+ line
238
+ if !startswith(trimspace(line), "#") && strcontains(line, "REPLACE_ME")
239
+ ]) == 0
240
+ error_message = "gateway.yaml still has REPLACE_ME on a non-comment line — fill it in before applying."
241
+ }
242
+ }
243
+ }
244
+
245
+ resource "google_secret_manager_secret_iam_member" "postgres_url" {
246
+ project = var.project_id
247
+ secret_id = google_secret_manager_secret.postgres_url.secret_id
248
+ role = "roles/secretmanager.secretAccessor"
249
+ member = "serviceAccount:${google_service_account.gateway.email}"
250
+ }
251
+
252
+ resource "google_secret_manager_secret_iam_member" "jwt" {
253
+ project = var.project_id
254
+ secret_id = google_secret_manager_secret.jwt.secret_id
255
+ role = "roles/secretmanager.secretAccessor"
256
+ member = "serviceAccount:${google_service_account.gateway.email}"
257
+ }
258
+
259
+ resource "google_secret_manager_secret_iam_member" "oidc" {
260
+ project = var.project_id
261
+ secret_id = google_secret_manager_secret.oidc.secret_id
262
+ role = "roles/secretmanager.secretAccessor"
263
+ member = "serviceAccount:${google_service_account.gateway.email}"
264
+ }
265
+
266
+ resource "google_secret_manager_secret_iam_member" "config" {
267
+ project = var.project_id
268
+ secret_id = google_secret_manager_secret.config.secret_id
269
+ role = "roles/secretmanager.secretAccessor"
270
+ member = "serviceAccount:${google_service_account.gateway.email}"
271
+ }
272
+
273
+ # ── 7 Cloud Run (Direct VPC egress) ─────────────────────────────────────────
274
+ resource "google_cloud_run_v2_service" "gateway" {
275
+ project = var.project_id
276
+ name = var.service_name
277
+ location = var.region
278
+ ingress = var.ingress
279
+ invoker_iam_disabled = var.invoker_iam_disabled
280
+ deletion_protection = var.deletion_protection
281
+
282
+ template {
283
+ service_account = google_service_account.gateway.email
284
+ scaling {
285
+ min_instance_count = var.min_instances
286
+ max_instance_count = var.max_instances
287
+ }
288
+ # Secrets are mounted at version=latest, so a config edit or secret
289
+ # rotation alone wouldn't diff this resource and the warm min_instances=1
290
+ # revision would keep the old values. Stamping a hash of the rendered
291
+ # config + every managed secret value forces a new revision whenever any
292
+ # of them change — without this, tainting random_password.db ALTERs the
293
+ # SQL role to the new password while the running revision keeps the old
294
+ # connection string and breaks on its next reconnect, and rotating the
295
+ # OIDC client secret leaves login failing invalid_client.
296
+ labels = {
297
+ config-sha = substr(sha256(join("", [
298
+ local.gateway_config,
299
+ random_password.db.result,
300
+ random_password.jwt.result,
301
+ var.oidc_client_secret,
302
+ ])), 0, 63)
303
+ }
304
+ # Cloud Run's default 300s request timeout would cut off long streaming
305
+ # /v1/messages responses mid-stream.
306
+ timeout = "3600s"
307
+
308
+ vpc_access {
309
+ network_interfaces {
310
+ network = google_compute_network.vpc.id
311
+ subnetwork = google_compute_subnetwork.subnet.id
312
+ }
313
+ egress = "PRIVATE_RANGES_ONLY" # public egress (Agent Platform, accounts.google.com) bypasses the VPC -> no Cloud NAT needed
314
+ }
315
+
316
+ containers {
317
+ image = local.image
318
+ ports { container_port = 8080 }
319
+
320
+ # gateway.yaml mounted as a file at /etc/claude/gateway.yaml (alone in its dir).
321
+ volume_mounts {
322
+ name = "config"
323
+ mount_path = "/etc/claude"
324
+ }
325
+
326
+ # Cloud Run can't mount multiple secrets in one dir, so the rest are env vars
327
+ # (gateway.yaml references them via ${ENV_VAR}).
328
+ env {
329
+ name = "GATEWAY_JWT_SECRET"
330
+ value_source {
331
+ secret_key_ref {
332
+ secret = google_secret_manager_secret.jwt.secret_id
333
+ version = "latest"
334
+ }
335
+ }
336
+ }
337
+ env {
338
+ name = "OIDC_CLIENT_SECRET"
339
+ value_source {
340
+ secret_key_ref {
341
+ secret = google_secret_manager_secret.oidc.secret_id
342
+ version = "latest"
343
+ }
344
+ }
345
+ }
346
+ env {
347
+ name = "GATEWAY_POSTGRES_URL"
348
+ value_source {
349
+ secret_key_ref {
350
+ secret = google_secret_manager_secret.postgres_url.secret_id
351
+ version = "latest"
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ volumes {
358
+ name = "config"
359
+ secret {
360
+ secret = google_secret_manager_secret.config.secret_id
361
+ items {
362
+ path = "gateway.yaml"
363
+ version = "latest"
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ depends_on = [
370
+ google_secret_manager_secret_iam_member.config,
371
+ google_secret_manager_secret_iam_member.jwt,
372
+ google_secret_manager_secret_iam_member.oidc,
373
+ google_secret_manager_secret_iam_member.postgres_url,
374
+ google_secret_manager_secret_version.config,
375
+ google_secret_manager_secret_version.postgres_url,
376
+ google_secret_manager_secret_version.jwt,
377
+ google_secret_manager_secret_version.oidc,
378
+ google_sql_database.db,
379
+ google_sql_user.gateway,
380
+ google_project_service.apis,
381
+ ]
382
+ }
383
+
384
+ # Public access at the Cloud Run IAM layer — the gateway runs its own OIDC, so the
385
+ # invoker check must be opened or disabled (real auth stays the gateway's SSO):
386
+ # Preferred — disable it: invoker_iam_disabled=true on the service above. No allUsers
387
+ # binding at all, and it works under Domain Restricted Sharing.
388
+ # Fallback — open it: this allUsers run.invoker grant. Domain Restricted Sharing orgs
389
+ # reject allUsers, and an LB does NOT bypass that (ingress is network-layer; the IAM
390
+ # check still runs) — use invoker_iam_disabled, a DRS exception, or GKE.
391
+ # Skipped when invoker_iam_disabled=true (the grant would be redundant, and DRS rejects it).
392
+ resource "google_cloud_run_v2_service_iam_member" "public" {
393
+ count = var.allow_unauthenticated && !var.invoker_iam_disabled ? 1 : 0
394
+ project = var.project_id
395
+ location = var.region
396
+ name = google_cloud_run_v2_service.gateway.name
397
+ role = "roles/run.invoker"
398
+ member = "allUsers"
399
+ }
examples/gateway/gcp/terraform/outputs.tf ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ output "service_url" {
2
+ description = "Cloud Run service URL."
3
+ value = google_cloud_run_v2_service.gateway.uri
4
+ }
5
+
6
+ output "oauth_redirect_uri" {
7
+ description = "Register this exact URI on the Google OAuth client, and ensure gateway.yaml public_url matches the host."
8
+ value = "${google_cloud_run_v2_service.gateway.uri}/oauth/callback"
9
+ }
10
+
11
+ output "service_account_email" {
12
+ description = "Gateway runtime service account."
13
+ value = google_service_account.gateway.email
14
+ }
15
+
16
+ output "image" {
17
+ description = "Image the service runs (build/push this separately — see README)."
18
+ value = local.image
19
+ }
20
+
21
+ output "db_connection_name" {
22
+ description = "Cloud SQL instance connection name (project:region:instance)."
23
+ value = google_sql_database_instance.db.connection_name
24
+ }
25
+
26
+ output "db_private_ip" {
27
+ description = "Cloud SQL private IP."
28
+ value = google_sql_database_instance.db.private_ip_address
29
+ }
30
+
31
+ output "public_invoker_granted" {
32
+ description = "Whether the allUsers run.invoker binding was applied (false when invoker_iam_disabled handles public access instead, or on Domain-Restricted-Sharing orgs)."
33
+ value = length(google_cloud_run_v2_service_iam_member.public) > 0
34
+ }
examples/gateway/gcp/terraform/terraform.tfvars.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to terraform.tfvars and edit. terraform.tfvars is gitignored (see .gitignore).
2
+
3
+ project_id = "your-gcp-project-id"
4
+ region = "us-east5"
5
+
6
+ image_tag = "<version>" # REQUIRED — the Claude Code release version you build and push as linux/amd64 (see README Deploy)
7
+
8
+ # Public access at the Cloud Run IAM layer (the gateway runs its own OIDC):
9
+ # Preferred — disable the invoker check: no allUsers binding, works under Domain
10
+ # Restricted Sharing. Needs google provider >= 6.8 and the feature enabled for your org:
11
+ # invoker_iam_disabled = true
12
+ # Fallback — grant allUsers (fine on a normal org; Domain Restricted Sharing rejects it,
13
+ # so there prefer invoker_iam_disabled, or use a DRS exception / GKE):
14
+ allow_unauthenticated = true
15
+
16
+ # Network reachability — a separate axis from the IAM choice above. Default is internal-only:
17
+ # no public URL (Claude Code's /login only accepts gateway hosts on private addresses, so
18
+ # public ingress cannot serve clients); corp on-prem reaches run.app via a PSC endpoint +
19
+ # private run.app DNS — see README "Private access" for the prerequisites. The only
20
+ # alternative to the internal-only default:
21
+ # ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" # only if you front it with your OWN internal ALB (custom hostname/cert; not provisioned here)
22
+
23
+ # Google OAuth client secret: REQUIRED — uncomment and set it (Terraform creates the
24
+ # secret version; the Cloud Run service mounts `latest`, so without a version the
25
+ # deploy fails). Leave empty only if you add the secret version out-of-band.
26
+ # oidc_client_secret = "GOCSPX-..."
examples/gateway/gcp/terraform/variables.tf ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Inputs — mirror the env-overridable knobs in setup.sh (same defaults).
2
+
3
+ variable "project_id" {
4
+ description = "GCP project ID."
5
+ type = string
6
+ }
7
+
8
+ variable "region" {
9
+ description = "Infra region for Artifact Registry, Cloud SQL, subnet, and Cloud Run. (Agent Platform region is set separately inside gateway.yaml.)"
10
+ type = string
11
+ default = "us-east5"
12
+ }
13
+
14
+ # ── Service account (§2) ────────────────────────────────────────────────────
15
+ variable "sa_name" {
16
+ description = "Service account account_id (the part before @)."
17
+ type = string
18
+ default = "claude-gateway"
19
+ }
20
+
21
+ # ── Image (§3) ──────────────────────────────────────────────────────────────
22
+ # Terraform creates the Artifact Registry repo but does NOT build/push the image
23
+ # (that's a docker build step — see README). It references the image by tag.
24
+ variable "ar_repo" {
25
+ description = "Artifact Registry Docker repository ID."
26
+ type = string
27
+ default = "claude-gateway"
28
+ }
29
+
30
+ variable "image_name" {
31
+ description = "Image name within the repo."
32
+ type = string
33
+ default = "gateway"
34
+ }
35
+
36
+ variable "image_tag" {
37
+ description = "Image tag — the Claude Code release version you build and push (must already be pushed as linux/amd64). See the README Deploy section for the build command."
38
+ type = string
39
+ validation {
40
+ condition = can(regex("^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", var.image_tag))
41
+ error_message = "image_tag must be a valid OCI tag — set it to the Claude Code release version you pushed (the '<version>' in terraform.tfvars.example is a placeholder)."
42
+ }
43
+ }
44
+
45
+ # ── Networking (§4) ─────────────────────────────────────────────────────────
46
+ variable "vpc_network" {
47
+ description = "Custom VPC network name."
48
+ type = string
49
+ default = "cc-gateway-vpc"
50
+ }
51
+
52
+ variable "subnet" {
53
+ description = "Subnet name (Cloud Run Direct VPC egress attaches here)."
54
+ type = string
55
+ default = "cc-gateway-subnet"
56
+ }
57
+
58
+ variable "subnet_range" {
59
+ description = "Subnet primary CIDR."
60
+ type = string
61
+ default = "10.0.0.0/24"
62
+ }
63
+
64
+ variable "psa_prefix_length" {
65
+ description = "Prefix length for the Private Services Access allocated range (/16 is GCP's recommendation)."
66
+ type = number
67
+ default = 16
68
+ }
69
+
70
+ # ── Cloud SQL (§4) ──────────────────────────────────────────────────────────
71
+ variable "db_instance" {
72
+ description = "Cloud SQL instance name."
73
+ type = string
74
+ default = "claude-gateway-db"
75
+ }
76
+
77
+ variable "db_version" {
78
+ description = "Postgres major version. The gateway supports PostgreSQL 14 or newer; 16 is the recommended default."
79
+ type = string
80
+ default = "POSTGRES_16"
81
+ }
82
+
83
+ variable "db_tier" {
84
+ description = "Cloud SQL machine tier."
85
+ type = string
86
+ default = "db-g1-small"
87
+ }
88
+
89
+ variable "db_name" {
90
+ description = "Database name."
91
+ type = string
92
+ default = "claude_gateway"
93
+ }
94
+
95
+ variable "db_user" {
96
+ description = "Database user (the gateway connects as this role)."
97
+ type = string
98
+ default = "gateway"
99
+ }
100
+
101
+ # ── Secrets (§5 / §6) ─────────────────────────────────────────────────────
102
+ variable "secret_name" {
103
+ description = "Secret Manager secret holding the Postgres connection string."
104
+ type = string
105
+ default = "gateway-postgres-url"
106
+ }
107
+
108
+ variable "jwt_secret_name" {
109
+ description = "Secret Manager secret holding the session JWT signing key."
110
+ type = string
111
+ default = "gateway-jwt-secret"
112
+ }
113
+
114
+ variable "oidc_secret_name" {
115
+ description = "Secret Manager secret holding the Google OAuth client secret."
116
+ type = string
117
+ default = "gateway-oidc-client-secret"
118
+ }
119
+
120
+ variable "config_secret_name" {
121
+ description = "Secret Manager secret holding gateway.yaml (mounted at /etc/claude/gateway.yaml)."
122
+ type = string
123
+ default = "gateway-config"
124
+ }
125
+
126
+ variable "oidc_client_secret" {
127
+ description = "Google OAuth 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 deploy fails)."
128
+ type = string
129
+ default = ""
130
+ sensitive = true
131
+ }
132
+
133
+ variable "gateway_config_path" {
134
+ description = "Path to gateway.yaml. Empty = ../gateway.yaml relative to this module."
135
+ type = string
136
+ default = ""
137
+ }
138
+
139
+ # ── Cloud Run (§7) ─────────────��────────────────────────────────────────────
140
+ variable "service_name" {
141
+ description = "Cloud Run service name."
142
+ type = string
143
+ default = "claude-gateway"
144
+ }
145
+
146
+ variable "min_instances" {
147
+ description = "Minimum Cloud Run instances (1 avoids cold OIDC discovery)."
148
+ type = number
149
+ default = 1
150
+ }
151
+
152
+ variable "max_instances" {
153
+ description = "Maximum Cloud Run instances. Each instance opens a Postgres pool of up to 5 connections (the gateway's store.max_connections default) and db-g1-small caps at ~50 max_connections — keep max_instances × 5 below the DB tier's limit, or raise the tier before raising this."
154
+ type = number
155
+ default = 8
156
+ }
157
+
158
+ variable "ingress" {
159
+ description = "Cloud Run ingress — Claude Code's /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients: INGRESS_TRAFFIC_INTERNAL_ONLY (default; no public URL — VPC-only; reaches corp on-prem only with the private-access prerequisites in the README; public_url stays the run.app URL, so no LB or custom cert needed) or INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER (front with your own internal ALB for a custom hostname/cert)."
160
+ type = string
161
+ default = "INGRESS_TRAFFIC_INTERNAL_ONLY"
162
+ validation {
163
+ condition = contains(["INGRESS_TRAFFIC_INTERNAL_ONLY", "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"], var.ingress)
164
+ error_message = "ingress must be INGRESS_TRAFFIC_INTERNAL_ONLY or INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER — Claude Code only connects to gateways on private addresses."
165
+ }
166
+ }
167
+
168
+ variable "invoker_iam_disabled" {
169
+ description = "PREFERRED public-access path: disable the Cloud Run invoker IAM check so requests reach the container with no allUsers binding (works under Domain Restricted Sharing). Real auth stays the gateway's own OIDC. When true, the allUsers grant below is skipped. May be blocked by org policy constraints/run.managed.requireInvokerIam, or unavailable for the org (\"invoker_iam_disabled is not currently available for your organization\") — then fall back to allow_unauthenticated. Requires google provider >= 6.8."
170
+ type = bool
171
+ default = false
172
+ }
173
+
174
+ variable "allow_unauthenticated" {
175
+ description = "Fallback public-access path: grant allUsers run.invoker (the gateway needs the IAM layer open for its own OIDC). Prefer invoker_iam_disabled. Domain Restricted Sharing orgs reject allUsers — set false there and use invoker_iam_disabled, a DRS exception, or GKE."
176
+ type = bool
177
+ default = true
178
+ }
179
+
180
+ variable "deletion_protection" {
181
+ description = "Provider-level deletion protection on Cloud SQL and Cloud Run. Keep true to avoid accidental deletion of the running deployment."
182
+ type = bool
183
+ default = true
184
+ }
examples/gateway/gcp/terraform/versions.tf ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Provider + version pins for the Claude Gateway Cloud Run deployment.
2
+ terraform {
3
+ required_version = ">= 1.5"
4
+ required_providers {
5
+ google = {
6
+ source = "hashicorp/google"
7
+ version = ">= 6.8, < 7.0" # 6.8 adds invoker_iam_disabled on google_cloud_run_v2_service
8
+ }
9
+ random = {
10
+ source = "hashicorp/random"
11
+ version = ">= 3.5"
12
+ }
13
+ }
14
+ }
15
+
16
+ provider "google" {
17
+ project = var.project_id
18
+ region = var.region
19
+ }
examples/hooks/bash_command_validator_example.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claude Code Hook: Bash Command Validator
4
+ =========================================
5
+ This hook runs as a PreToolUse hook for the Bash tool.
6
+ It validates bash commands against a set of rules before execution.
7
+ In this case it changes grep calls to using rg.
8
+
9
+ Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
10
+
11
+ Make sure to change your path to your actual script.
12
+
13
+ {
14
+ "hooks": {
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Bash",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "python3 /path/to/claude-code/examples/hooks/bash_command_validator_example.py"
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
27
+ }
28
+
29
+ """
30
+
31
+ import json
32
+ import re
33
+ import sys
34
+
35
+ # Define validation rules as a list of (regex pattern, message) tuples
36
+ _VALIDATION_RULES = [
37
+ (
38
+ r"^grep\b(?!.*\|)",
39
+ "Use 'rg' (ripgrep) instead of 'grep' for better performance and features",
40
+ ),
41
+ (
42
+ r"^find\s+\S+\s+-name\b",
43
+ "Use 'rg --files | rg pattern' or 'rg --files -g pattern' instead of 'find -name' for better performance",
44
+ ),
45
+ ]
46
+
47
+
48
+ def _validate_command(command: str) -> list[str]:
49
+ issues = []
50
+ for pattern, message in _VALIDATION_RULES:
51
+ if re.search(pattern, command):
52
+ issues.append(message)
53
+ return issues
54
+
55
+
56
+ def main():
57
+ try:
58
+ input_data = json.load(sys.stdin)
59
+ except json.JSONDecodeError as e:
60
+ print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
61
+ # Exit code 1 shows stderr to the user but not to Claude
62
+ sys.exit(1)
63
+
64
+ tool_name = input_data.get("tool_name", "")
65
+ if tool_name != "Bash":
66
+ sys.exit(0)
67
+
68
+ tool_input = input_data.get("tool_input", {})
69
+ command = tool_input.get("command", "")
70
+
71
+ if not command:
72
+ sys.exit(0)
73
+
74
+ issues = _validate_command(command)
75
+ if issues:
76
+ for message in issues:
77
+ print(f"• {message}", file=sys.stderr)
78
+ # Exit code 2 blocks tool call and shows stderr to Claude
79
+ sys.exit(2)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()