<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hugolesta</title>
    <description>The latest articles on DEV Community by hugolesta (@hugolesta).</description>
    <link>https://dev.to/hugolesta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F199721%2Fb7aa0261-c82c-4a48-9701-2c03e14d3102.jpeg</url>
      <title>DEV Community: hugolesta</title>
      <link>https://dev.to/hugolesta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hugolesta"/>
    <language>en</language>
    <item>
      <title>KMCP Turns MCP Servers Into Boring Infrastructure</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Fri, 31 Jul 2026 08:55:26 +0000</pubDate>
      <link>https://dev.to/hugolesta/kmcp-turns-mcp-servers-into-boring-infrastructure-1of</link>
      <guid>https://dev.to/hugolesta/kmcp-turns-mcp-servers-into-boring-infrastructure-1of</guid>
      <description>&lt;p&gt;Everyone building with LLMs eventually writes an MCP server. It works great on a laptop: &lt;code&gt;npx some-mcp-server&lt;/code&gt;, point your client at stdio, done. Then someone asks the obvious question — "can the rest of the team use it?" — and you discover you've just signed up to write a Dockerfile, a Deployment, a Service, an Ingress, a secret wiring story, and a health check, for a process whose entire job is to expose four tools.&lt;/p&gt;

&lt;p&gt;Multiply that by every tool your organization wants an agent to reach — ArgoCD, Jira, the incident tracker, the cost API — and you have a small fleet of bespoke microservices, each maintained by whoever wrote it first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kagent.dev/docs/kmcp" rel="noopener noreferrer"&gt;KMCP&lt;/a&gt; is the fix. It's a Kubernetes controller plus a CRD that turns "run this MCP server" into about 20 lines of YAML. This is what deploying it into a management &lt;a href="https://aws.amazon.com/eks/" rel="noopener noreferrer"&gt;EKS&lt;/a&gt; cluster actually looked like, and — more usefully — what changed once the platform's tools were on the other side of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["Helmfile — kmcp-crds + kmcp charts"] --&amp;gt;|"OCI chart from ghcr.io"| B["KMCP controller — kmcp-system namespace"]
    C["Git repo — mcpserver.yaml"] --&amp;gt;|"ArgoCD + cdk8s plugin"| D["MCPServer CR — team namespace"]
    B --&amp;gt;|"watches and reconciles"| D
    D --&amp;gt;|"controller creates"| E["Deployment + Service"]
    E --&amp;gt;|"Traefik IngressRoute — internal only"| F["mcp.internal.example.com"]
    F --&amp;gt;|"streamable HTTP — /mcp"| G["Agents, IDE clients, chat assistants"]
    E --&amp;gt;|"scoped API token"| H[("Platform tool — ArgoCD API")]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two moving parts, deliberately separated. The &lt;strong&gt;controller&lt;/strong&gt; is platform infrastructure — installed once, per EKS cluster, by the platform team's Helmfile. The &lt;strong&gt;MCPServer resources&lt;/strong&gt; are application config — owned by whoever owns the tool being exposed, shipped through the normal GitOps path. A team adding an MCP server never touches the platform repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installing the controller
&lt;/h2&gt;

&lt;p&gt;KMCP ships as OCI Helm charts, which means the repository entry needs &lt;code&gt;oci: true&lt;/code&gt; — easy to miss, and the failure mode is a confusing "not a valid chart repository" error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repositories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/kagent-dev/kmcp/helm&lt;/span&gt;
    &lt;span class="na"&gt;oci&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then two releases, CRDs first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;releases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp-crds&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp-system&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp/kmcp-crds&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~&amp;gt;0.1&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp-system&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kmcp/kmcp&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~&amp;gt;0.1&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kmcp-crds&lt;/span&gt;
    &lt;span class="na"&gt;installed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Environment.Values.kmcp.installed&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Splitting CRDs into their own release is not ceremony. Helm's CRD handling is famously half-hearted — it installs &lt;code&gt;crds/&lt;/code&gt; directory contents on first install and then never upgrades them. A separate chart with an explicit &lt;code&gt;needs&lt;/code&gt; dependency means CRD upgrades are a normal Helm operation, and the controller never starts before the types it watches exist.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;installed:&lt;/code&gt; flag is wired per environment. In this rollout, &lt;code&gt;kmcp.installed&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; only in the management EKS cluster and explicitly &lt;code&gt;false&lt;/code&gt; in dev, acc, and prod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;mng&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kmcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;installed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;prod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kmcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;installed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;installed: false&lt;/code&gt; explicitly in every other environment rather than relying on a &lt;code&gt;| default false&lt;/code&gt; is worth the extra lines. It makes the rollout state legible in one file — you can answer "where is KMCP running?" by reading, not by reasoning about template defaults.&lt;/p&gt;

&lt;p&gt;Why the management cluster and not the workload EKS clusters? Because the tools worth exposing over MCP — ArgoCD, the CI control plane, the cost reporting API — already live there. Putting the MCP layer next to its backends avoids cross-cluster networking for what is otherwise a trivial HTTP hop.&lt;/p&gt;




&lt;h2&gt;
  
  
  An MCP server as 20 lines of YAML
&lt;/h2&gt;

&lt;p&gt;Here's a real one — the ArgoCD MCP server, which gives an agent read access to application state, sync status, and logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kagent.dev/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MCPServer&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-mcp&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-tools&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kagent.dev/discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disabled"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;transportType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
  &lt;span class="na"&gt;httpTransport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8770&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/mcp&lt;/span&gt;
  &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:24-bookworm&lt;/span&gt;
    &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;argocd-mcp@latest&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--port&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8770"&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8770&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ARGOCD_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://argocd.internal.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. No Dockerfile, no Deployment, no Service, no readiness probe. The controller reconciles this into a Deployment and a Service, wires the port mapping, and manages the lifecycle from there.&lt;/p&gt;

&lt;p&gt;A few details that matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;transportType: http&lt;/code&gt;.&lt;/strong&gt; MCP's original transport is stdio — the client spawns the server as a subprocess and talks over pipes. That model does not survive contact with Kubernetes: there's nothing to load balance, nothing to health check, and one client per process. Streamable HTTP is what makes an MCP server a &lt;em&gt;service&lt;/em&gt; — many clients, one deployment, standard networking. If you're deploying MCP servers to a cluster and reaching for stdio, stop and switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;image: node:24-bookworm&lt;/code&gt; with &lt;code&gt;cmd: npx&lt;/code&gt;.&lt;/strong&gt; This is a deliberate shortcut, not sloppiness. Most published MCP servers are npm packages with no official container image. Rather than maintaining a build pipeline for a wrapper image around a package you don't own, you run the stock Node image and let &lt;code&gt;npx&lt;/code&gt; fetch it. The tradeoff is real and you should name it out loud: &lt;code&gt;argocd-mcp@latest&lt;/code&gt; resolves at pod start, so a pod restart six weeks from now can silently pick up a different version. Fine for a management cluster running internal tooling; not fine for anything on the critical path. Pin the version (&lt;code&gt;argocd-mcp@1.4.2&lt;/code&gt;) the moment this stops being an experiment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kagent.dev/discovery: "disabled"&lt;/code&gt;.&lt;/strong&gt; This opts the server out of automatic tool discovery by kagent agents in the cluster. Default-on discovery is a lovely demo and a bad default in a shared environment — it means every agent gets every tool the moment someone deploys a new MCPServer. Explicitly disabling it and granting access deliberately is the version you want when more than one team shares a cluster.&lt;/p&gt;




&lt;h2&gt;
  
  
  Routing: internal, and deliberately so
&lt;/h2&gt;

&lt;p&gt;The service gets exposed through the existing ingress stack rather than anything KMCP-specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ingressRoute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;enableDefaultRoute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;publicAccess&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;internetFacing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;routesList&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-mcp&lt;/span&gt;
      &lt;span class="na"&gt;portName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-mcp.internal.example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;publicAccess: false&lt;/code&gt; is doing the heavy lifting. An MCP server is, by construction, a remote code execution surface with a friendly schema on top — it exists to let a language model call functions against your infrastructure. It belongs on the internal load balancer, reachable over the corporate network, and nowhere else.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;portName: http&lt;/code&gt; took three attempts to get right in practice. The ingress controller matches the service port &lt;em&gt;by name&lt;/em&gt;, and a name mismatch produces a route that resolves, returns a 502, and gives you no useful signal about why. If your MCP route 502s, check the port name before you check anything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Shipping it through GitOps, not a Makefile
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;kmcp&lt;/code&gt; CLI has a &lt;code&gt;kmcp deploy&lt;/code&gt; command, and it's genuinely nice for iterating locally. It has no place in your deployment path. In this setup the MCPServer manifests live in the owning team's repo and get synced by ArgoCD, with cdk8s doing the templating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cdk8s&lt;/span&gt;

&lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cdk8s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chart&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cdk8s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcpserver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/mcpserver.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cdk8s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yaml_output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cdk8s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;YamlOutputType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FILE_PER_APP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcpserver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cdk8s.Include&lt;/code&gt; on a plain YAML file looks like a no-op wrapper, and mostly it is. The point is that the MCPServer sits inside the same synth as everything else the team deploys — ingress routes, config, the app itself — so one ArgoCD Application covers the whole unit. The MCP server is not a special deployment path. It's just another manifest in the tree.&lt;/p&gt;

&lt;p&gt;That's the real design decision here: &lt;strong&gt;an MCP server should be indistinguishable from any other workload you run.&lt;/strong&gt; Same repo layout, same GitOps sync, same review process, same rollback. The moment it needs its own deployment tooling, it becomes something only one person knows how to operate.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that actually changes delivery time
&lt;/h2&gt;

&lt;p&gt;Standing up a controller is a day of work. The interesting question is what an organization gets once the tools are behind MCP, and the honest answer is that most of the value comes from something unglamorous: &lt;strong&gt;collapsing the distance between a question and its answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Think about what "check the deploy status" costs today. An engineer opens the ArgoCD UI, finds the right project among forty, filters applications, reads sync status, opens the diff, maybe pulls logs. Two minutes if they know exactly where to look, fifteen if they don't, and a Slack message to the platform team if they don't have access. Now consider a developer who has never used ArgoCD asking their assistant "is the checkout service synced in acc, and if not, what changed?" and getting an answer grounded in real cluster state.&lt;/p&gt;

&lt;p&gt;That is not a chatbot novelty. It's the removal of a lookup tax that every engineer in the organization pays several times a day.&lt;/p&gt;

&lt;p&gt;Three concrete shifts worth planning for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tribal knowledge becomes queryable.&lt;/strong&gt; The platform team's real product is not the cluster; it's knowing which of forty ArgoCD projects owns a given service, and that a &lt;code&gt;Progressing&lt;/code&gt; status stuck for ten minutes usually means a failed readiness probe. Exposed as tools, that knowledge stops being a queue in front of three people. The measurable win is fewer interrupts, and interrupts are the thing that actually destroys a platform team's throughput.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read access spreads without the safety problem.&lt;/strong&gt; Historically, giving a developer visibility into production meant giving them a console login and hoping. Handing them a read-scoped API token they present through an MCP server gives them &lt;em&gt;answers&lt;/em&gt; without giving them &lt;em&gt;the console&lt;/em&gt;. Different blast radius, entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding compresses.&lt;/strong&gt; A new engineer's first week is mostly discovering which internal tool answers which question, and where it lives. With those tools behind MCP, the discovery step goes away — they ask in the language they already have, and the tooling routes it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The credential belongs to the caller, not the server
&lt;/h2&gt;

&lt;p&gt;This is the part teams skip, and it's the part that determines whether the rollout survives its first security review.&lt;/p&gt;

&lt;p&gt;The instinct when deploying a tool server is to give it a service account and a token — mount an ArgoCD credential into the Deployment and let it act on everyone's behalf. Don't. That builds a &lt;em&gt;confused deputy&lt;/em&gt;: a single identity, holding the union of everything anyone might need, sitting behind an endpoint whose whole purpose is executing instructions from a language model.&lt;/p&gt;

&lt;p&gt;Look closely at the MCPServer spec earlier in this post. The only thing in &lt;code&gt;env&lt;/code&gt; is &lt;code&gt;ARGOCD_BASE_URL&lt;/code&gt;. There is no token, no &lt;code&gt;envFrom&lt;/code&gt;, no mounted secret — the server knows &lt;em&gt;where&lt;/em&gt; ArgoCD is and nothing about who may talk to it. Send it an unauthenticated request and it says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"initialize"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x-argocd-api-token must be provided in the request header
(or the ARGOCD_API_TOKEN env var), or a token registry must be
configured via ARGOCD_TOKEN_REGISTRY_PATH.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error is the design, not a misconfiguration. &lt;strong&gt;The caller presents its own token on every request&lt;/strong&gt;, in the &lt;code&gt;x-argocd-api-token&lt;/code&gt; header. The MCP server is a stateless protocol translator: it turns MCP tool calls into ArgoCD API calls and forwards the caller's credential. It holds no standing authority of its own.&lt;/p&gt;

&lt;p&gt;The consequences are worth being explicit about, because they invert the usual threat model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The server is not a prize.&lt;/strong&gt; Compromising the pod yields a URL. There's no token at rest to exfiltrate, no secret in the namespace, no IRSA role on the ServiceAccount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization is ArgoCD's job, where it belongs.&lt;/strong&gt; Every call is evaluated against the caller's own RBAC by the system that owns the data. The MCP layer cannot widen anyone's access, because it has none to lend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audit trail stays truthful.&lt;/strong&gt; ArgoCD logs the acting account, not a shared &lt;code&gt;mcp-server&lt;/code&gt; identity. "Who asked?" remains answerable — which it would not be if one token fronted every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation is per-team and instant.&lt;/strong&gt; Killing one team's token stops that team, with no redeploy and no effect on anyone else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note the env var mentioned in that error message. &lt;code&gt;ARGOCD_API_TOKEN&lt;/code&gt; on the Deployment &lt;em&gt;would&lt;/em&gt; work, and it is exactly the shortcut to refuse — it collapses every caller into one identity and undoes everything above. The token registry is the middle path if per-request headers are impractical for some client, but per-request is the model to start from.&lt;/p&gt;

&lt;p&gt;This is what makes the per-team ArgoCD accounts load-bearing rather than bureaucratic. Each consuming team gets its own account, scoped to exactly what it owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;accounts.cloudplatform.enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;accounts.cloudplatform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiKey&lt;/span&gt;
  &lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;policy.csv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, projects, *, k8s-tools-project, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, projects, *, kafka*, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, projects, *, vault-project, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, applications, *, k8s-tools-*/*, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, applications, *, kafka*/*, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, applications, *, vault-*/*, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, logs, get, k8s-tools-*/*, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, logs, get, kafka*/*, allow&lt;/span&gt;
      &lt;span class="s"&gt;p, role:cloudplatform-role, logs, get, vault-*/*, allow&lt;/span&gt;
      &lt;span class="s"&gt;g, cloudplatform, role:cloudplatform-role&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;apiKey&lt;/code&gt; and &lt;em&gt;not&lt;/em&gt; &lt;code&gt;login&lt;/code&gt;. The account can mint a token for programmatic use and cannot be used to log into the UI as a human. If the token leaks, the blast radius is "read some applications in three projects" instead of "an interactive session".&lt;/p&gt;

&lt;p&gt;Note also that &lt;code&gt;logs, get&lt;/code&gt; is granted separately from application access. Logs frequently contain more than the deploy metadata does — connection strings in stack traces, customer identifiers in error messages. Deciding about log access explicitly, per team, is worth the extra three lines.&lt;/p&gt;

&lt;p&gt;Rolling this out one team at a time — a separate account and policy block per consuming team — is slower than a single shared account and it is the right call. It means an over-permissioned policy affects one team, and revoking access for one team doesn't take down everyone else's assistant.&lt;/p&gt;

&lt;p&gt;The pattern generalizes past ArgoCD. Any tool you put behind MCP should answer the same question: &lt;em&gt;whose&lt;/em&gt; authority does a call carry? If the answer is "the server's", you've centralized risk and blinded your audit log. If it's "the caller's", the MCP layer stays what it should be — plumbing that translates protocols and carries someone else's identity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Field notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;oci: true&lt;/code&gt; is not optional and the error doesn't tell you.&lt;/strong&gt; Helmfile treats an OCI registry URL as a classic chart repo without it and fails looking for an &lt;code&gt;index.yaml&lt;/code&gt; that will never exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the CRD chart from the controller chart, always.&lt;/strong&gt; Helm will install CRDs from a chart's &lt;code&gt;crds/&lt;/code&gt; directory on first install and then never upgrade them. When KMCP's &lt;code&gt;MCPServer&lt;/code&gt; spec gains a field in &lt;code&gt;0.2&lt;/code&gt;, you want a chart upgrade, not a manual &lt;code&gt;kubectl apply&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@latest&lt;/code&gt; in an MCPServer's &lt;code&gt;args&lt;/code&gt; is a time bomb with a slow fuse.&lt;/strong&gt; The pod that has been running for six weeks and the pod that just restarted are running different code. It won't hurt you until it does, and when it does the symptom will be "it worked yesterday". Pin it before it matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service port names, not numbers, are what ingress matches on.&lt;/strong&gt; A mismatch gives you a 502 with no useful log line on either side. Check this first when a new MCP route doesn't answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off automatic tool discovery in shared clusters.&lt;/strong&gt; &lt;code&gt;kagent.dev/discovery: "disabled"&lt;/code&gt; plus deliberate grants beats every-agent-gets-every-tool. The default is optimized for a demo, not for a cluster with four teams in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform-managed IAM roles for the MCP workload were removed, not fixed.&lt;/strong&gt; The first pass provisioned an IRSA role for the MCP server before anyone had established that it needed AWS access. It didn't — it forwards the caller's ArgoCD token and talks to nothing else. Deleting infrastructure that exists "just in case" is a real win; every unused role is a permission someone will eventually attach a policy to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what your MCP server holds at rest before you ship it.&lt;/strong&gt; &lt;code&gt;kubectl get deploy &amp;lt;name&amp;gt; -o jsonpath='{.spec.template.spec.containers[0].env}'&lt;/code&gt; and a look for mounted secrets takes ten seconds. An empty result is the good outcome — it means a compromised pod yields nothing worth stealing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only first, and mean it.&lt;/strong&gt; There will be pressure to add write tools — "just let it trigger a sync". Resist until the read path has been in production long enough that you trust the identity boundaries. A hallucinated &lt;code&gt;get&lt;/code&gt; is a wrong answer; a hallucinated &lt;code&gt;sync&lt;/code&gt; is an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The controller is not where your time goes.&lt;/strong&gt; Installing KMCP took an afternoon. Getting ArgoCD accounts, RBAC scoping, and the per-team policy format right took a week and several follow-up PRs. Budget accordingly — the platform piece is easy, the identity piece is the project.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;KMCP's contribution is narrow and worth having: it makes an MCP server a first-class Kubernetes object, so exposing a tool costs a YAML file instead of a service. That reduction is what lets you say yes to the fifth MCP server without inheriting a fleet of snowflakes.&lt;/p&gt;

&lt;p&gt;The leverage, though, is in what you connect and whose identity the calls carry. Every tool behind MCP is one fewer thing an engineer has to know where to find, and one fewer interrupt in the platform team's day. Keep the credential with the caller and that scales safely: the MCP layer never accumulates authority, and nobody can ask it for more than they were already allowed to have.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>mcp</category>
      <category>kagent</category>
      <category>argocd</category>
    </item>
    <item>
      <title>Terraform Enterprise on EKS: The Parts Nobody Documents</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Fri, 17 Jul 2026 17:00:23 +0000</pubDate>
      <link>https://dev.to/hugolesta/terraform-enterprise-on-eks-the-parts-nobody-documents-2khj</link>
      <guid>https://dev.to/hugolesta/terraform-enterprise-on-eks-the-parts-nobody-documents-2khj</guid>
      <description>&lt;p&gt;Terraform Enterprise's own install docs assume a plain Docker host or a generic Kubernetes cluster with an ingress controller that speaks HTTPS by default. Run it on EKS behind ArgoCD, cdk8s, ExternalSecrets, and Traefik, and you hit at least four failure modes that produce no useful error message — just a 404, a silently-ignored &lt;code&gt;UPDATE&lt;/code&gt;, or an OAuth token that pretends a repository doesn't exist.&lt;/p&gt;

&lt;p&gt;None of these are TFE bugs. They're the gap between "works on a demo cluster" and "works inside a real platform stack."&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["Terraform — RDS, S3, IAM, Secrets Manager"] --&amp;gt; B["cdk8s — K8s manifests"]
    B --&amp;gt; C["ArgoCD — sync"]
    C --&amp;gt; D["Terraform Enterprise pod — EKS"]
    D --&amp;gt; E[("Aurora PostgreSQL 16")]
    D --&amp;gt; F[("ElastiCache Redis")]
    D --&amp;gt; G[("S3 — state storage, IRSA")]
    D --&amp;gt; H["Traefik IngressRoute — HTTPS backend"]
    H --&amp;gt; I["ALB / Global Accelerator — external TLS"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Infra (RDS, ElastiCache, S3, IAM) comes from Terraform. Kubernetes manifests are generated by cdk8s. ArgoCD syncs them. Secrets live in AWS Secrets Manager and land in the pod via ExternalSecrets. Standard platform-engineering plumbing — the failure modes below all live in the seams between these pieces, not inside any one of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Image: AMD64 only
&lt;/h2&gt;

&lt;p&gt;TFE publishes no ARM64 image. If your GitHub Actions runners default to &lt;code&gt;linux/arm64&lt;/code&gt;, or you've standardized on Graviton for cost, the build fails outright unless you explicitly override:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; images.releases.hashicorp.com/hashicorp/terraform-enterprise:v202507-1&lt;/span&gt;
&lt;span class="k"&gt;LABEL&lt;/span&gt;&lt;span class="s"&gt; maintainer="platform-team@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;linux/amd64"&lt;/span&gt;   &lt;span class="c1"&gt;# required in both build and deploy workflows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cheap to fix once you know it's the reason — expensive to diagnose if you don't, because the build error just looks like a generic manifest mismatch.&lt;/p&gt;




&lt;h2&gt;
  
  
  The database needs the admin user, briefly
&lt;/h2&gt;

&lt;p&gt;Aurora Postgres 16 is the ceiling — TFE does not support 17, and Aurora can't be downgraded in place, only destroyed and recreated. Budget for that constraint before you provision, not after.&lt;/p&gt;

&lt;p&gt;The subtler trap: on first boot, TFE needs to create schemas. If &lt;code&gt;TFE_DATABASE_USER&lt;/code&gt; points at a least-privilege application user (which is the instinct, and the right end state), schema creation fails with a permissions error that doesn't obviously say "wrong user." Use the admin/root credential for the first boot, let migrations run, then rotate to the scoped app user afterward.&lt;/p&gt;




&lt;h2&gt;
  
  
  TLS: the certificate header that isn't &lt;code&gt;CERTIFICATE&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;TFE's internal nginx uses &lt;code&gt;ssl_trusted_certificate&lt;/code&gt;, which requires the PEM header &lt;code&gt;TRUSTED CERTIFICATE&lt;/code&gt; — not the standard &lt;code&gt;CERTIFICATE&lt;/code&gt; header every other tool expects. A normal self-signed cert fails at container startup with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nginx: [emerg] cannot load certificate: PEM_read_bio_X509_AUX() failed (Expecting: TRUSTED CERTIFICATE)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate it in the right format from the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl req &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-newkey&lt;/span&gt; rsa:2048 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-keyout&lt;/span&gt; /tmp/tfe-key.pem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-out&lt;/span&gt; /tmp/tfe-cert.pem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-days&lt;/span&gt; 3650 &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=tfe.example.internal"&lt;/span&gt;

openssl x509 &lt;span class="nt"&gt;-in&lt;/span&gt; /tmp/tfe-cert.pem &lt;span class="nt"&gt;-trustout&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt; /tmp/tfe-cert-trusted.pem

&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; /tmp/tfe-cert-trusted.pem
&lt;span class="c"&gt;# Expected: -----BEGIN TRUSTED CERTIFICATE-----&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more ways this quietly breaks even after you get the header right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line wrapping matters.&lt;/strong&gt; &lt;code&gt;openssl x509 -trustout&lt;/code&gt; produces correctly wrapped 64-character lines. If anything downstream collapses the file onto a single line — &lt;code&gt;cat&lt;/code&gt; in a shell substitution is the usual culprit — nginx fails to parse it even though the header is technically correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret key names must match the Helm chart's &lt;code&gt;subPath&lt;/code&gt; exactly.&lt;/strong&gt; The chart mounts &lt;code&gt;subPath: tls.crt&lt;/code&gt; and &lt;code&gt;subPath: tls.key&lt;/code&gt;. Name your &lt;code&gt;ExternalSecret&lt;/code&gt; keys anything else (&lt;code&gt;cert.pem&lt;/code&gt;, &lt;code&gt;key.pem&lt;/code&gt; — the "obvious" names) and you get a silent empty mount: the pod starts, nginx just gets a zero-byte file, and the resulting error looks identical to a missing cert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're piping any multi-line secret (cert, key, license) through an encryption or secrets API as part of provisioning, &lt;code&gt;json.dumps()&lt;/code&gt; the file content in Python before sending it — never &lt;code&gt;cat&lt;/code&gt; or &lt;code&gt;$(cat file)&lt;/code&gt;, which collapses newlines and silently produces the same "technically present, functionally broken" secret.&lt;/p&gt;




&lt;h2&gt;
  
  
  Traefik: HTTPS-only backend, no plain HTTP path
&lt;/h2&gt;

&lt;p&gt;TFE's Helm chart only exposes an HTTPS backend — there's no plain-HTTP listener at all. That's unusual enough among services behind a typical ingress controller that it's easy to configure exactly the same way as everything else and get nothing but a 404.&lt;/p&gt;

&lt;p&gt;Two things are non-negotiable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;ServersTransport&lt;/code&gt; with &lt;code&gt;insecureSkipVerify: true&lt;/code&gt;.&lt;/strong&gt; Traefik verifies backend TLS by default; TFE's backend cert is self-signed. Without this resource, Traefik doesn't error — it silently drops the route and returns 404, which looks identical to a misconfigured &lt;code&gt;IngressRoute&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServersTransport&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-transport&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;insecureSkipVerify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The &lt;code&gt;IngressRoute&lt;/code&gt; must reconnect over HTTPS&lt;/strong&gt;, referencing that transport, even if your Traefik setup terminates external TLS upstream (ALB, Global Accelerator) and only forwards plain HTTP internally to everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`tfe.example.internal`)&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-enterprise&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
          &lt;span class="na"&gt;serversTransport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-transport&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the service name: the chart hardcodes it to &lt;code&gt;terraform-enterprise&lt;/code&gt; regardless of your Helm release name. Reference the hardcoded name, not whatever you called the release.&lt;/p&gt;

&lt;p&gt;If you're generating manifests with &lt;code&gt;cdk8s.Include&lt;/code&gt;, put &lt;code&gt;ServersTransport&lt;/code&gt; and &lt;code&gt;IngressRoute&lt;/code&gt; in &lt;strong&gt;separate files&lt;/strong&gt;. &lt;code&gt;cdk8s.Include&lt;/code&gt; only processes the first document in a YAML file — a multi-document file separated by &lt;code&gt;---&lt;/code&gt; silently drops everything after the first &lt;code&gt;---&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A working route returns a 301 to &lt;code&gt;/session&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; https://tfe.example.internal/ &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{redirect_url}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# Expected: 301 https://tfe.example.internal/session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The SSO trap that skips your first site admin
&lt;/h2&gt;

&lt;p&gt;This is the one that actually costs people time.&lt;/p&gt;

&lt;p&gt;TFE bootstraps its first site admin through a one-time signup endpoint, &lt;code&gt;/admin/account/new&lt;/code&gt;. It only renders the signup form while &lt;strong&gt;no user exists yet&lt;/strong&gt;. The moment any user record exists, the route redirects to &lt;code&gt;/app&lt;/code&gt; and is permanently closed — there's no supported way to reopen it.&lt;/p&gt;

&lt;p&gt;Here's the trap: if the &lt;em&gt;first&lt;/em&gt; login to a fresh TFE instance happens via SSO/SAML instead of that local signup form, TFE still considers its job done — a user now exists — but that user is created with &lt;code&gt;is_admin = false&lt;/code&gt;. Nobody has site-admin rights. The &lt;strong&gt;Site Admin&lt;/strong&gt; menu and &lt;strong&gt;New Organization&lt;/strong&gt; button don't appear anywhere, for anyone, and there's no UI or API path to fix it.&lt;/p&gt;

&lt;p&gt;The correct sequence, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the pod is healthy and the IngressRoute works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before anyone signs in via SSO for the first time&lt;/strong&gt;, visit &lt;code&gt;/admin/account/new&lt;/code&gt; directly and create a local admin account. This is the only flow that auto-sets &lt;code&gt;is_admin = true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If SSO has already beaten you to it — this happens more often than the docs imply, usually because someone tests the login flow before thinking about admin bootstrapping — promote the existing user directly in the database.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- fetch admin creds from your secrets manager first&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rails&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;is_admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_admin&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;SET search_path&lt;/code&gt; line isn't optional. Without it, the &lt;code&gt;UPDATE&lt;/code&gt; fails inside an internal trigger (&lt;code&gt;user_hcp_user_email_reservation_check&lt;/code&gt;) with &lt;code&gt;relation "hcp_user_email_reservations" does not exist&lt;/code&gt; — and depending on your client, that failure can look like it silently did nothing rather than raising a clear error. Always verify with a follow-up &lt;code&gt;SELECT&lt;/code&gt; that &lt;code&gt;is_admin&lt;/code&gt; actually flipped.&lt;/p&gt;




&lt;h2&gt;
  
  
  SAML team sync doesn't create the team for you
&lt;/h2&gt;

&lt;p&gt;Once SSO works and an admin exists, the next surprise is team provisioning. TFE grants organization membership through team membership — not directly. Your IdP can send a perfectly correct &lt;code&gt;MemberOf&lt;/code&gt; attribute on every login, but if no team with that exact name exists yet in the TFE organization, the user authenticates successfully, gets an account, and is simply &lt;strong&gt;not part of the organization&lt;/strong&gt;. No error. Just an orphaned, authenticated user.&lt;/p&gt;

&lt;p&gt;Fix: create the team first, with a name that matches the IdP attribute value exactly, then have the user log out and back in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$USER_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/vnd.api+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "data": {
      "type": "teams",
      "attributes": {
        "name": "developers",
        "organization-access": { "manage-workspaces": true }
      }
    }
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://tfe.example.internal/api/v2/organizations/&amp;lt;org&amp;gt;/teams"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separately, TFE's SAML config also requires a &lt;strong&gt;Single Log-Out URL&lt;/strong&gt;, even though SLO itself is optional in most SAML deployments and your IdP's setup page may not surface a dedicated logout URL. Leaving it blank fails validation outright. Your IdP's own sign-out endpoint is a legitimate value here, not a placeholder.&lt;/p&gt;

&lt;p&gt;And when writing the IdP-side attribute expression for site-admin auto-provisioning: make sure it emits a &lt;strong&gt;string&lt;/strong&gt;, not a boolean. &lt;code&gt;isMemberOfGroupName("admins")&lt;/code&gt; alone evaluates to &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;, and TFE's Site Admin Role field matches against a specific string (e.g. &lt;code&gt;site-admins&lt;/code&gt;) — a boolean silently never matches, so nobody gets auto-promoted and the failure is invisible unless you go looking for it. A ternary that emits the actual string fixes it.&lt;/p&gt;




&lt;h2&gt;
  
  
  No inbound webhooks, no VCS auto-apply
&lt;/h2&gt;

&lt;p&gt;If TFE sits behind a private, VPN-only ingress — a reasonable default for an internal platform tool — connecting a VCS provider works fine for the OAuth handshake and API calls (TFE calling out to GitHub), but GitHub's webhook delivery (GitHub calling &lt;em&gt;in&lt;/em&gt; to TFE on push) simply cannot reach a private endpoint. There's no stable, publishable GitHub IP range you can safely allow-list without reopening broad public ingress.&lt;/p&gt;

&lt;p&gt;Practically: connecting a workspace to a repo in the UI will never auto-trigger a plan on push. The fix is to invert the flow — instead of waiting for GitHub to call TFE, have a CI job that already runs inside your private network call TFE's API directly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Developer pushes to the repo.&lt;/li&gt;
&lt;li&gt;A CI workflow runs on a self-hosted runner inside the network.&lt;/li&gt;
&lt;li&gt;The runner calls TFE's API (&lt;code&gt;POST /api/v2/runs&lt;/code&gt;, or the CLI-driven workflow) to create a run — outbound from inside the network, so no inbound exposure needed.&lt;/li&gt;
&lt;li&gt;TFE executes the plan/apply and posts the commit status back via its own outbound call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Still attach VCS metadata to the workspace for source display and commit-status reporting — just don't depend on the webhook trigger.&lt;/p&gt;




&lt;h2&gt;
  
  
  Module registry publish needs its own VCS scope — and a second, invisible SSO check
&lt;/h2&gt;

&lt;p&gt;Two separate gotchas stack on top of each other here, and they produce the identical error message, which makes debugging genuinely confusing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First:&lt;/strong&gt; the VCS Provider's scope has three independent toggles — Workspaces, Policy Sets, Modules. Enabling the connection for workspaces does not enable it for module publishing. Miss this and publishing fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error publishing module — VCS Connection Validation failed: The specified repository "&amp;lt;repo&amp;gt;" doesn't exist or isn't accessible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— even though the same repo works fine for VCS-linked workspaces. Fix: toggle Modules on in the VCS Provider settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, and much sneakier:&lt;/strong&gt; if your GitHub organization enforces SAML SSO, and the VCS Provider is a plain OAuth App (not an installed GitHub App), the OAuth token must be &lt;strong&gt;separately authorized for SSO per organization&lt;/strong&gt; — even for a service account that's already a full org member with read access to the repo. Until that authorization is granted, GitHub silently filters that org's repositories out of every API response made with the token. No error. The repo picker just shows "0 repositories," and direct publish fails with the exact same "doesn't exist or isn't accessible" message as the Modules-toggle issue above.&lt;/p&gt;

&lt;p&gt;The tell that distinguishes the two: TFE's manual "can't see your repository? enter its ID" field accepts any string and advances you to a confirmation step regardless of the SSO block, because it doesn't call GitHub's API at that point — the real validation call only happens at final publish. Reaching that confirmation screen proves nothing.&lt;/p&gt;

&lt;p&gt;Fix: as the service account, go to &lt;code&gt;github.com/settings/connections/applications&lt;/code&gt;, find the OAuth App matching the Client ID shown on the VCS Provider's edit page, and grant/authorize it for the organization. One-click, one-time, per org. No token regeneration or TFE-side change needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Field notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a service account for the VCS OAuth connection, not a personal one.&lt;/strong&gt; It survives offboarding and doesn't break when someone's personal token expires or access is revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aurora Postgres 16 is a hard ceiling, not a soft recommendation&lt;/strong&gt; — plan the destroy/recreate cost into your migration timeline if you're already running 17 elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test SSO from a session with a way back in.&lt;/strong&gt; Confirm a working local admin login exists before testing SAML changes — don't lock yourself out mid-configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The org-level "run/apply everywhere" permission has no narrower sibling.&lt;/strong&gt; &lt;code&gt;manage-workspaces: true&lt;/code&gt; at the org level grants admin on every workspace in the org — there's no org-level "plan/apply but not admin." Scoping down to specific workspaces or a lesser permission means configuring per-workspace Team Access individually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every one of these failures is silent by design&lt;/strong&gt;, not by bug: a dropped route, a filtered repo list, a trigger that swallows an error, a boolean that never matches a string. None of them page you. All of them look like something else until you know the specific shape of this one.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Most of the actual engineering effort in a TFE install isn't the Terraform or the Helm values — it's building the mental map of which of these four subsystems (TLS, ingress, SAML, VCS OAuth) is failing silently, since none of them will tell you directly. Once you know the map, every one of these takes minutes to fix instead of hours to find.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>eks</category>
      <category>kubernetes</category>
      <category>saml</category>
    </item>
    <item>
      <title>Your AI Platform Needs a Gateway, Not a Credit Card</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Thu, 09 Jul 2026 12:28:23 +0000</pubDate>
      <link>https://dev.to/hugolesta/your-ai-platform-needs-a-gateway-not-a-credit-card-3e5a</link>
      <guid>https://dev.to/hugolesta/your-ai-platform-needs-a-gateway-not-a-credit-card-3e5a</guid>
      <description>&lt;p&gt;A team in your org hits Anthropic during a production incident. Anthropic is down. The team is blocked for 20 minutes while someone scrambles to find an alternative API key, change a config, and redeploy. There's no fallback. There was never a plan for this.&lt;/p&gt;

&lt;p&gt;Somewhere else, another team has quietly started billing their LLM usage to a personal credit card because procurement takes six weeks. A third team has hardcoded &lt;code&gt;gpt-4o&lt;/code&gt; everywhere and doesn't know there's a cheaper model that handles their workload fine.&lt;/p&gt;

&lt;p&gt;This isn't a story about AI risk. It's a story about missing infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern you've seen before
&lt;/h2&gt;

&lt;p&gt;When teams first started running services in the cloud, the ones who moved fast didn't start with a load balancer and a CDN. They spun up an EC2 instance, pointed DNS at it, and shipped. That worked — until it didn't. Until traffic spiked, until the instance went down, until three teams had three different DNS setups and nobody knew which was canonical.&lt;/p&gt;

&lt;p&gt;AI adoption is following the same curve, faster. Every team that can call an API is calling one. The platforms don't have a control plane yet. The control plane is somebody's laptop with a hardcoded key.&lt;/p&gt;

&lt;p&gt;An AI gateway is what closes that gap. It's not a product pitch — it's the same thing you built for HTTP traffic: a single ingress point with routing, fallback, observability, and policy enforcement. The LLM is the backend. The gateway is the infrastructure around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gateway actually does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["Developer — MCP / SDK / REST client"] --&amp;gt; G["AI Gateway — single stable endpoint"]
    B["Business user — internal tooling"] --&amp;gt; G
    G --&amp;gt;|"primary"| P1["Anthropic Claude"]
    G --&amp;gt;|"fallback on 5xx / timeout"| P2["AWS Bedrock — Claude / Titan"]
    G --&amp;gt;|"cost-optimised route"| P3["OpenAI GPT-4o mini"]
    G --&amp;gt; OBS["Observability — spend per team — request logs — latency"]
    G --&amp;gt; POL["Policy — rate limits — budget caps — PII filter"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Failover.&lt;/strong&gt; When a provider goes down, the gateway retries against the next configured backend — same request, different model, no client code change. This is the feature that looks optional until someone spends 20 minutes blocked mid-incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost routing.&lt;/strong&gt; Not every call needs a frontier model. Summarisation, classification, short completions — route those to something cheaper. The gateway makes this a config change, not a refactor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spend visibility.&lt;/strong&gt; Every request is tagged by team, application, and model. You see where the money goes before finance calls you about it. You can set hard budget caps per team so nobody accidentally runs a batch job against &lt;code&gt;claude-opus&lt;/code&gt; all night.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider abstraction.&lt;/strong&gt; When a new model ships — or when your organisation wants to experiment with providers you don't use today — teams swap the gateway config, not their application code. The SDK they're using doesn't change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit logging.&lt;/strong&gt; In any regulated environment, knowing who sent what prompt to which model isn't a nice-to-have. It's table stakes for governance. The gateway is the right place to capture that, not each individual application.&lt;/p&gt;

&lt;h2&gt;
  
  
  What self-hosting looks like in practice
&lt;/h2&gt;

&lt;p&gt;The two tools worth knowing are &lt;a href="https://litellm.ai" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt; and &lt;a href="https://bifrost.ai" rel="noopener noreferrer"&gt;Bifrost&lt;/a&gt;. Both expose an OpenAI-compatible API, which means existing applications need zero changes to the SDK call — only the base URL and API key change.&lt;/p&gt;

&lt;p&gt;LiteLLM is the more battle-tested option. It runs as a Docker container, connects to a Postgres database for request logs and virtual key management, and supports every major provider out of the box. A minimal deployment on Kubernetes looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/berriai/litellm:main-latest&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--config"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/litellm/config.yaml"&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-secrets&lt;/span&gt;
                  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database-url&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/litellm&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config itself is where the routing logic lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-sonnet&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic/claude-sonnet-4-5&lt;/span&gt;
      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/ANTHROPIC_API_KEY&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-sonnet&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bedrock/us.anthropic.claude-sonnet-4-5-20251001-v2:0&lt;/span&gt;
      &lt;span class="na"&gt;aws_region_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;

&lt;span class="na"&gt;router_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;routing_strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;least-busy&lt;/span&gt;
  &lt;span class="na"&gt;num_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;fallbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;claude-sonnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;claude-sonnet&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;success_callback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langfuse"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;failure_callback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langfuse"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two entries for the same &lt;code&gt;model_name&lt;/code&gt; is the whole failover mechanism. LiteLLM tries the first, fails, retries against the second. From the client's perspective, the call either succeeds or returns an error after both backends fail — never a 20-minute block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency argument doesn't hold here
&lt;/h2&gt;

&lt;p&gt;The pushback you'll hear is latency. "We can't add a proxy hop to every LLM call."&lt;/p&gt;

&lt;p&gt;That argument makes sense for traditional APIs where you're measuring response times in milliseconds. It doesn't apply to LLMs. Time-to-first-token on a typical Claude call is 300–800ms. A gateway adds 5–15ms. That's noise, not overhead. The conversation about "proxy latency" is a conversation that predates LLMs and doesn't transfer.&lt;/p&gt;

&lt;p&gt;The actual latency concern worth having is gateway availability — if the gateway goes down, everything goes down. The answer is the same as any other critical path: multiple replicas, a readiness probe, a PodDisruptionBudget, and a runbook. Nothing exotic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The enforcement problem
&lt;/h2&gt;

&lt;p&gt;One thing the gateway doesn't solve on its own: casual users who bypass it entirely. If teams can buy a Claude.ai subscription for $20/month and skip the gateway, many will — especially if the gateway requires a non-trivial onboarding step.&lt;/p&gt;

&lt;p&gt;This is a governance problem, not a technical one. The gateway has to be the path of least resistance, not just the official path. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API keys that work without friction (virtual keys in LiteLLM's admin UI take 30 seconds to issue)&lt;/li&gt;
&lt;li&gt;A clear internal page that says "here is your endpoint and here is your key"&lt;/li&gt;
&lt;li&gt;A procurement policy that routes all model spend through the central key, not individual accounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The technical infrastructure is the easier part. Getting adoption is the harder part, and that's a platform team problem from day one, not something you solve after the gateway is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Field notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run the gateway in at least two replicas from day one.&lt;/strong&gt; A single-replica proxy is a single point of failure you introduced yourself. Two replicas with a PDB that disallows simultaneous evictions cost almost nothing and remove the worst failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiteLLM's virtual key system is the right unit of cost tracking.&lt;/strong&gt; One virtual key per team, budget cap attached, spend dashboard out of the box. Don't try to build this yourself — it's already there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover works at the gateway level, not the application level.&lt;/strong&gt; Stop trying to handle retries in application code when you have a gateway. Put the retry and fallback logic in one place and let every team benefit from it automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag every request with &lt;code&gt;metadata&lt;/code&gt;.&lt;/strong&gt; LiteLLM forwards custom metadata to your observability backend. Use it to tag team, environment, and use case from the first day — you'll want that granularity when someone asks why the bill doubled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK compatibility means the migration is low-friction.&lt;/strong&gt; If you start with LiteLLM and later want to evaluate Bifrost or something else, the migration is a base URL change for application teams. That's the right amount of coupling.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The teams that will struggle with AI costs and reliability in 12 months are the ones treating model access like a SaaS subscription — something each team manages independently. The teams that won't are the ones who put a control plane in front of it now, before the sprawl hardens into something unauditable.&lt;/p&gt;

&lt;p&gt;The gateway isn't the exciting part of the AI platform. It's the infrastructure that makes the exciting parts sustainable.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>aws</category>
      <category>litellm</category>
    </item>
    <item>
      <title>Give Your LLM Hands: Bedrock Agents, Lambda Tools, and the MCP Pattern in Terraform</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Fri, 03 Jul 2026 09:14:49 +0000</pubDate>
      <link>https://dev.to/hugolesta/give-your-llm-hands-bedrock-agents-lambda-tools-and-the-mcp-pattern-in-terraform-2fdd</link>
      <guid>https://dev.to/hugolesta/give-your-llm-hands-bedrock-agents-lambda-tools-and-the-mcp-pattern-in-terraform-2fdd</guid>
      <description>&lt;p&gt;The Knowledge Base from the &lt;a href="https://www.hugolesta.nl/blog/dont-let-your-llm-wing-it-bedrock-knowledge-base" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; gives the LLM memory. But memory is passive — the model can tell you what the runbook says, but it can't go and create the Confluence page, look up the Jira ticket, or ping Slack. For that you need an agent: a model that can reason over a goal, decide which tool to call, call it, inspect the result, and loop until the task is done.&lt;/p&gt;

&lt;p&gt;This is what Bedrock Agents does. The model driving it is Anthropic Claude; the "tools" it invokes are Lambda functions you own; the schema that tells the model what each function accepts and returns lives in Terraform as a &lt;code&gt;function_schema&lt;/code&gt; block. It's the Model Context Protocol pattern, just deployed as AWS-managed infrastructure instead of a local sidecar.&lt;/p&gt;

&lt;p&gt;The agent covered here — an incident reporter — takes a PagerDuty incident ID, fetches details, searches Jira for related tickets, writes a Confluence incident page, and posts a Slack notification. Four action groups, four Lambdas, one Knowledge Base, one Terraform file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["Caller — incident ID"] --&amp;gt;|"InvokeAgent"| B["Bedrock Agent — Claude Sonnet — eu cross-region"]
    B --&amp;gt;|"Retrieve context"| C[("Knowledge Base — Aurora pgvector")]
    B --&amp;gt;|"action group"| D["Lambda — pagerduty-tools — get_incident — list_incidents — get_incident_alerts"]
    B --&amp;gt;|"action group"| E["Lambda — jira-tools — search_issues — get_issue"]
    B --&amp;gt;|"action group"| F["Lambda — confluence-tools — create_page — get_page — update_page"]
    B --&amp;gt;|"action group"| G["Lambda — slack-tools — send_message — find_channel"]
    D --&amp;gt;|"PagerDuty API"| H["PagerDuty"]
    E --&amp;gt;|"Jira API"| I["Jira"]
    F --&amp;gt;|"Confluence API"| J["Confluence"]
    G --&amp;gt;|"Slack API"| K["Slack"]
    D &amp;amp; E &amp;amp; F &amp;amp; G --&amp;gt;|"tool results"| B
    B --&amp;gt;|"final response"| A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Lambda reads its API credentials from Secrets Manager at runtime — no credentials in environment variables, no secrets in Terraform state beyond the ARN.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Lambda tools pattern
&lt;/h2&gt;

&lt;p&gt;Bedrock calls this concept "action groups". I find it cleaner to think of it as MCP: the agent has a set of named functions with typed parameters, it decides which ones to call based on the task, calls them, and incorporates the responses. The Lambda is the implementation; the Terraform &lt;code&gt;function_schema&lt;/code&gt; block is the schema the model reads.&lt;/p&gt;

&lt;p&gt;Every tool Lambda in this setup follows the same structure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A module call that provisions the Lambda (runtime, memory, timeout, S3 source, env vars)&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;aws_lambda_permission&lt;/code&gt; that allows &lt;code&gt;bedrock.amazonaws.com&lt;/code&gt; to invoke it&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;aws_bedrockagent_agent_action_group&lt;/code&gt; that declares the function schema&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The permission and the action group are separate resources — the permission is at the Lambda level, the action group is at the agent level. Both are required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets handling
&lt;/h3&gt;

&lt;p&gt;Each Lambda that calls an external API gets the secret name as an environment variable, not the secret value. The Lambda's IAM role has &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; on that specific secret ARN. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rotating a token is a Secrets Manager operation, not a Terraform apply&lt;/li&gt;
&lt;li&gt;The secret value never appears in a plan output or state file&lt;/li&gt;
&lt;li&gt;CloudWatch logs show &lt;code&gt;PAGERDUTY_SECRET_NAME=pagerduty-api-token&lt;/code&gt;, not a token
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;lambdas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pagerduty&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock-agent-pagerduty-tools"&lt;/span&gt;
    &lt;span class="nx"&gt;jira&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock-agent-jira-tools"&lt;/span&gt;
    &lt;span class="nx"&gt;confluence&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock-agent-confluence-tools"&lt;/span&gt;
    &lt;span class="nx"&gt;slack&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock-agent-slack-tools"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lambda module + permission (PagerDuty as example)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"lambda_pagerduty_tools"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-org/lambda/aws"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 2.15.0"&lt;/span&gt;

  &lt;span class="nx"&gt;env&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
  &lt;span class="nx"&gt;vcs&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vcs&lt;/span&gt;
  &lt;span class="nx"&gt;owner&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;

  &lt;span class="nx"&gt;function_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambdas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagerduty&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lambda_function.lambda_handler"&lt;/span&gt;
  &lt;span class="nx"&gt;runtime&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"python3.13"&lt;/span&gt;
  &lt;span class="nx"&gt;networking_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;cloudwatch_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;memory_size&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;
  &lt;span class="nx"&gt;timeout&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;

  &lt;span class="nx"&gt;s3_bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-artifacts-bucket"&lt;/span&gt;
  &lt;span class="nx"&gt;s3_key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"agents/${local.lambdas.pagerduty}/${local.lambdas.pagerduty}.zip"&lt;/span&gt;

  &lt;span class="nx"&gt;env_vars&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;PAGERDUTY_SECRET_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagerduty_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret_name&lt;/span&gt;
    &lt;span class="nx"&gt;LOG_LEVEL&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"INFO"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagerduty_secret&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_permission"&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_pagerduty_tools"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AllowBedrockInvoke"&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lambda:InvokeFunction"&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.project}-${local.lambdas.pagerduty}-${local.env}"&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock.amazonaws.com"&lt;/span&gt;
  &lt;span class="nx"&gt;source_arn&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:bedrock:${local.aws_region}:${local.aws_account_id}:agent/*"&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda_pagerduty_tools&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;source_arn&lt;/code&gt; uses &lt;code&gt;agent/*&lt;/code&gt; — wildcard over agents in this account and region. If you want tighter scoping, replace the wildcard with the specific agent ARN after it's created, but that creates a circular dependency you'll have to break with a two-phase apply.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Agent resource
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_agent"&lt;/span&gt; &lt;span class="s2"&gt;"incident_reporter"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.project}-incident-reporter-agent-${local.env}"&lt;/span&gt;
  &lt;span class="nx"&gt;agent_resource_role_arn&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;foundation_model&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu.anthropic.claude-sonnet-4-6"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Orchestrates PagerDuty, Jira, Confluence, and Slack for automated incident documentation"&lt;/span&gt;
  &lt;span class="nx"&gt;idle_session_ttl_in_seconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;

  &lt;span class="nx"&gt;instruction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="no"&gt;EOT&lt;/span&gt;&lt;span class="sh"&gt;
    You are an AI Incident Reporter for the Cloud Platform team.

    When given a PagerDuty incident, complete ALL of the following steps in order:

    1. FETCH INCIDENT DETAILS
       - Use the pagerduty-tools action group to get full incident details and alerts
       - Extract: title, description, severity, affected service, timeline, assignees

    2. FIND RELATED JIRA TICKETS
       - Use the jira-tools action group to search for related tickets
       - DO NOT create new Jira tickets — only retrieve existing ones

    3. CREATE CONFLUENCE INCIDENT PAGE
       - Use the confluence-tools action group to create a new incident page
       - Include: title, severity, timeline, PagerDuty link, related Jira tickets

    4. NOTIFY SLACK
       - Use the slack-tools action group to send a message to the incident channel
       - Include: summary, severity, Confluence URL, Jira links

    5. RETURN SUMMARY
       - Provide a final summary with all links created

    Guidelines:
    - Always complete all steps — do not skip any
    - If a step fails, report the error and continue with remaining steps
    - Use the knowledge base to understand infrastructure context when relevant
&lt;/span&gt;&lt;span class="no"&gt;  EOT

&lt;/span&gt;  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default_tags&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_role_policy_attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter_agent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting about this resource:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;foundation_model&lt;/code&gt;&lt;/strong&gt;: the &lt;code&gt;eu.&lt;/code&gt; prefix routes through AWS's cross-region inference, which lets the agent use model capacity across EU regions (eu-west-1, eu-central-1, eu-west-3) automatically. Without this prefix you're locked to a single region's available capacity. Use it if you're in a EU region.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;instruction&lt;/code&gt;&lt;/strong&gt;: this is the system prompt. It's what tells the model the task structure, step ordering, and guard rails. Keep it procedural and explicit — the model will follow numbered steps reliably. Vague instructions produce vague agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;idle_session_ttl_in_seconds&lt;/code&gt;&lt;/strong&gt;: how long a conversation session stays alive between calls. 600 seconds (10 minutes) is reasonable for a human-in-the-loop flow; drop it lower if you're doing fully automated invocations where sessions shouldn't accumulate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Agent IAM
&lt;/h2&gt;

&lt;p&gt;The agent's execution role needs three permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"incident_reporter_agent"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"InvokeModel"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"bedrock:InvokeModel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"bedrock:InvokeModelWithResponseStream"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KBRetrieve"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bedrock:Retrieve"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"arn:aws:bedrock:${local.aws_region}:${local.aws_account_id}:knowledge-base/${var.knowledge_base_id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"InvokeLambdas"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lambda:InvokeFunction"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"arn:aws:lambda:${local.aws_region}:${local.aws_account_id}:function:${local.project}-${local.lambdas.pagerduty}-${local.env}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"arn:aws:lambda:${local.aws_region}:${local.aws_account_id}:function:${local.project}-${local.lambdas.jira}-${local.env}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"arn:aws:lambda:${local.aws_region}:${local.aws_account_id}:function:${local.project}-${local.lambdas.confluence}-${local.env}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"arn:aws:lambda:${local.aws_region}:${local.aws_account_id}:function:${local.project}-${local.lambdas.slack}-${local.env}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;InvokeModel&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt; is required — Bedrock Agents uses this to invoke the foundation model internally, and the resource ARN for FM invocation isn't predictable at the time the role is written if you're using cross-region inference prefixes. You can scope it to &lt;code&gt;arn:aws:bedrock:*::foundation-model/*&lt;/code&gt; if you want some constraint without breaking cross-region.&lt;/p&gt;




&lt;h2&gt;
  
  
  Action groups with function schemas
&lt;/h2&gt;

&lt;p&gt;This is where the MCP analogy is most visible. The &lt;code&gt;function_schema&lt;/code&gt; block is effectively a tool manifest: the model reads it at runtime to understand what each function is named, what parameters it expects, and what they mean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_agent_action_group"&lt;/span&gt; &lt;span class="s2"&gt;"pagerduty_tools"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;
  &lt;span class="nx"&gt;agent_version&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DRAFT"&lt;/span&gt;
  &lt;span class="nx"&gt;action_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pagerduty-tools"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PagerDuty tools: get_incident, list_incidents, get_incident_alerts"&lt;/span&gt;

  &lt;span class="nx"&gt;action_group_executor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lambda&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:lambda:${local.aws_region}:${local.aws_account_id}:function:${local.project}-${local.lambdas.pagerduty}-${local.env}"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;function_schema&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;member_functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"get_incident"&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Retrieve full details of a single PagerDuty incident by its ID"&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"incident_id"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The PagerDuty incident ID (e.g. P1234AB)"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"list_incidents"&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"List active PagerDuty incidents filtered by status and/or service"&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"statuses"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Comma-separated statuses to filter by: triggered, acknowledged, resolved"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"limit"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"integer"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Maximum number of incidents to return (default: 20)"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"get_incident_alerts"&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Get all alerts associated with a PagerDuty incident"&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"incident_id"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The PagerDuty incident ID"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda_pagerduty_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;aws_lambda_permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrock_pagerduty_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Jira action group is read-only by design — &lt;code&gt;search_issues&lt;/code&gt; and &lt;code&gt;get_issue&lt;/code&gt; only. This isn't enforced at the Lambda level (you could build a write-capable Lambda and only expose read functions here), but the intent is captured in both the &lt;code&gt;description&lt;/code&gt; field and the agent's &lt;code&gt;instruction&lt;/code&gt;. Two layers of "don't create tickets" beats one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_agent_action_group"&lt;/span&gt; &lt;span class="s2"&gt;"jira_tools"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;
  &lt;span class="nx"&gt;agent_version&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DRAFT"&lt;/span&gt;
  &lt;span class="nx"&gt;action_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"jira-tools"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Jira tools (read-only): search_issues, get_issue — no ticket creation"&lt;/span&gt;

  &lt;span class="nx"&gt;action_group_executor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lambda&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:lambda:${local.aws_region}:${local.aws_account_id}:function:${local.project}-${local.lambdas.jira}-${local.env}"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;function_schema&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;member_functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"search_issues"&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Search Jira issues using JQL"&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"jql"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"JQL query string"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"max_results"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"integer"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Maximum number of results to return (default: 10)"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fields"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Comma-separated list of fields to include"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;functions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"get_issue"&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Retrieve full details of a single Jira issue by its key"&lt;/span&gt;
        &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;map_block_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"issue_key"&lt;/span&gt;
          &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The Jira issue key (e.g. OPS-1234)"&lt;/span&gt;
          &lt;span class="nx"&gt;required&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda_jira_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;aws_lambda_permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrock_jira_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Attaching the Knowledge Base
&lt;/h2&gt;

&lt;p&gt;One resource to connect the Knowledge Base built in the previous post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_agent_knowledge_base_association"&lt;/span&gt; &lt;span class="s2"&gt;"incident_reporter_kb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;
  &lt;span class="nx"&gt;knowledge_base_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;knowledge_base_id&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Internal infrastructure and platform documentation"&lt;/span&gt;
  &lt;span class="nx"&gt;knowledge_base_state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ENABLED"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this association in place, the agent automatically retrieves relevant KB chunks before deciding which tools to call. The instruction's "use the knowledge base to understand infrastructure context when relevant" is the trigger — the model decides when to retrieve, not you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Agent alias and SSM parameters
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_agent_alias"&lt;/span&gt; &lt;span class="s2"&gt;"incident_reporter"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;
  &lt;span class="nx"&gt;agent_alias_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"live"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Live alias for the Incident Reporter agent"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default_tags&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;aws_bedrockagent_agent_action_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagerduty_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;aws_bedrockagent_agent_action_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jira_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;aws_bedrockagent_agent_action_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confluence_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;aws_bedrockagent_agent_action_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slack_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;aws_bedrockagent_agent_knowledge_base_association&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter_kb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"incident_reporter_agent_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/${local.env}/incident-reporter/agent_id"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"String"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"incident_reporter_agent_alias_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/${local.env}/incident-reporter/agent_alias_id"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"String"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_agent_alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incident_reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_alias_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alias is required to invoke the agent from application code — you always invoke via &lt;code&gt;agentId&lt;/code&gt; + &lt;code&gt;agentAliasId&lt;/code&gt;. Publishing a named alias decouples callers from the internal version counter: when you update action groups and Bedrock creates a new draft version, you prepare a new alias version and flip it without changing any caller configuration.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;depends_on&lt;/code&gt; on the alias resource is load-bearing. Without it, Terraform might try to create the alias before all action groups exist, which causes Bedrock to create the alias pointing at an agent that's still missing tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  Field notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;agent_version = "DRAFT"&lt;/code&gt; on all action groups.&lt;/strong&gt; Action groups always attach to &lt;code&gt;DRAFT&lt;/code&gt;. When you publish a new agent version (via alias routing), Bedrock snapshots the DRAFT config. If you try to attach action groups to a numbered version, the API rejects it. This trips up people who try to version-lock action groups for safety.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The function description matters more than you'd think.&lt;/strong&gt; The model reads &lt;code&gt;description&lt;/code&gt; fields to decide which tool to call and when. Vague descriptions ("does stuff with PagerDuty") produce agents that hallucinate which tool to invoke. Write descriptions as if you're writing API docs for a junior engineer — precise verbs, clear scope.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Parameter types are limited: &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;array&lt;/code&gt;.&lt;/strong&gt; No nested objects. If your Lambda needs structured input (a list of filter criteria, a nested config), serialize it as JSON in a &lt;code&gt;string&lt;/code&gt; parameter and document the shape in the description. Ugly but it works.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lambda cold starts extend agent response time noticeably.&lt;/strong&gt; Each tool call is a synchronous Lambda invocation. Four cold starts in a chain = 4× the latency penalty. Provision concurrency on the most-called tools if response time matters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The &lt;code&gt;depends_on&lt;/code&gt; on the alias is not optional.&lt;/strong&gt; Terraform's parallelism will race the alias creation against action group creation without it. The apply succeeds but the agent alias captures an incomplete version with missing tools. You won't notice until runtime.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-region inference (&lt;code&gt;eu.&lt;/code&gt; prefix) changes the IAM picture.&lt;/strong&gt; With the cross-region prefix, the model ARN the agent resolves to at runtime isn't in your account's region — it's wherever AWS routes capacity. &lt;code&gt;InvokeModel&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt; is practically required here, or you scope to &lt;code&gt;arn:aws:bedrock:*::foundation-model/*&lt;/code&gt; to at least limit to foundation models.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Secrets Manager reads happen at every Lambda invocation.&lt;/strong&gt; For high-throughput agents this adds latency and cost. Cache the secret in the Lambda's global scope with a TTL if invocations are frequent — just don't cache indefinitely or token rotation won't take effect.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The Knowledge Base gave the LLM a read-only window into your documentation. The agent and its Lambda tools give it hands: it can fetch, create, and notify across your tooling stack in a single invocation. The infrastructure for it is one Terraform file, four Lambda deployments, and an IAM role with three statements.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>bedrock</category>
      <category>agents</category>
    </item>
    <item>
      <title>Don't Let Your LLM Wing It: Building a Knowledge Base That Actually Knows Things</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Tue, 30 Jun 2026 21:08:46 +0000</pubDate>
      <link>https://dev.to/hugolesta/dont-let-your-llm-wing-it-building-a-knowledge-base-that-actually-knows-things-k2m</link>
      <guid>https://dev.to/hugolesta/dont-let-your-llm-wing-it-building-a-knowledge-base-that-actually-knows-things-k2m</guid>
      <description>&lt;p&gt;Every team eventually asks the same question: "can we make the LLM answer questions about &lt;em&gt;our&lt;/em&gt; docs instead of hallucinating something plausible?" The answer is retrieval-augmented generation (RAG), and the unglamorous truth is that RAG is 10% prompt engineering and 90% plumbing — object storage, a vector index, an embedding model, and a pipeline that keeps all three in sync whenever someone edits a markdown file.&lt;/p&gt;

&lt;p&gt;This is the plumbing. A Bedrock Knowledge Base backed by Aurora PostgreSQL with &lt;code&gt;pgvector&lt;/code&gt;, provisioned entirely in Terraform, synced automatically from a Git repo via GitHub Actions. No notebooks, no manual "let me re-upload the docs" Tuesdays.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["Git repository — knowledge-base/*.md"] --&amp;gt;|"push to main or workflow_dispatch"| B["GitHub Actions — sync-knowledge-base.yml"]
    B --&amp;gt;|uploads docs| C[("S3 bucket — KMS encrypted")]
    C --&amp;gt;|start ingestion| D{"Bedrock ingestion job — hierarchical chunking"}
    D --&amp;gt;|"Titan Embed Text v2"| E[("Aurora PostgreSQL — pgvector — HNSW + GIN indexes")]
    E --&amp;gt;|"Retrieve / RetrieveAndGenerate"| F["App / LLM proxy — scoped IAM role"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two environments — &lt;code&gt;acc&lt;/code&gt; and &lt;code&gt;prod&lt;/code&gt; — run this same pipeline side by side, each with its own bucket, KB, and database, gated by branch and dispatch logic in the workflow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Provisioning the vector store
&lt;/h2&gt;

&lt;h3&gt;
  
  
  S3 bucket for ingestion
&lt;/h3&gt;

&lt;p&gt;Bedrock pulls documents from S3, not the other way around, so the bucket is just a private, versioned, encrypted drop zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"kb_docs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.cluster_name}-knowledge-base"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_versioning"&lt;/span&gt; &lt;span class="s2"&gt;"kb_docs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;versioning_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Enabled"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_server_side_encryption_configuration"&lt;/span&gt; &lt;span class="s2"&gt;"kb_docs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;apply_server_side_encryption_by_default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;sse_algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws:kms"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_public_access_block"&lt;/span&gt; &lt;span class="s2"&gt;"kb_docs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;block_public_acls&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;block_public_policy&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_public_acls&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;restrict_public_buckets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Aurora pgvector cluster
&lt;/h3&gt;

&lt;p&gt;This is a separate Aurora cluster from any application database — different lifecycle, different access pattern, and you don't want a runaway ingestion job competing for connections with your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"kb_db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-aws-modules/rds-aurora/aws"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 9.0"&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock-kb"&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aurora-postgresql"&lt;/span&gt;
  &lt;span class="nx"&gt;engine_version&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"16.6"&lt;/span&gt;
  &lt;span class="nx"&gt;instances&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;serverlessv2_scaling_configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
    &lt;span class="nx"&gt;max_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;database_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_kb"&lt;/span&gt;
  &lt;span class="nx"&gt;master_username&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"root"&lt;/span&gt;
  &lt;span class="nx"&gt;manage_master_user_password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;enable_http_endpoint&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bedrock's Knowledge Base service expects credentials in &lt;code&gt;{"username": ..., "password": ...}&lt;/code&gt; JSON shape in Secrets Manager. If your Terraform module stores a bare password string (most do), mirror it into a second secret in the right shape rather than fighting the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret"&lt;/span&gt; &lt;span class="s2"&gt;"kb_db_bedrock"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock-kb-db-credentials"&lt;/span&gt;
  &lt;span class="nx"&gt;recovery_window_in_days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret_version"&lt;/span&gt; &lt;span class="s2"&gt;"kb_db_bedrock"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db_bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;secret_string&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"root"&lt;/span&gt;
    &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_master_password&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  IAM: the Bedrock service role
&lt;/h3&gt;

&lt;p&gt;Bedrock's Knowledge Base needs to assume a role that can read from S3, describe and execute statements against Aurora via the Data API, read the credentials secret, and invoke the embedding model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_kb_assume_role"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;principals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Service"&lt;/span&gt;
      &lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bedrock.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;test&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StringEquals"&lt;/span&gt;
      &lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws:SourceAccount"&lt;/span&gt;
      &lt;span class="nx"&gt;values&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_account_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_kb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S3Read"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"${aws_s3_bucket.kb_docs.arn}/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RDSDataApi"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rds-data:BatchExecuteStatement"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"rds-data:ExecuteStatement"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SecretsManagerRead"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:GetSecretValue"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db_bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"BedrockEmbeddings"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bedrock:InvokeModel"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock:eu-west-1::foundation-model/amazon.titan-embed-text-v2:0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_kb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.cluster_name}-bedrock-kb"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrock_kb_assume_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_kb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrock_kb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrock_kb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Knowledge Base resource itself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_knowledge_base"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.cluster_name}-knowledge-base"&lt;/span&gt;
  &lt;span class="nx"&gt;role_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bedrock_kb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;

  &lt;span class="nx"&gt;knowledge_base_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VECTOR"&lt;/span&gt;
    &lt;span class="nx"&gt;vector_knowledge_base_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;embedding_model_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:bedrock:eu-west-1::foundation-model/amazon.titan-embed-text-v2:0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;storage_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RDS"&lt;/span&gt;
    &lt;span class="nx"&gt;rds_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;resource_arn&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_arn&lt;/span&gt;
      &lt;span class="nx"&gt;credentials_secret_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db_bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="nx"&gt;database_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_kb"&lt;/span&gt;
      &lt;span class="nx"&gt;table_name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bedrock_integration.bedrock_kb"&lt;/span&gt;
      &lt;span class="nx"&gt;field_mapping&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;primary_key_field&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt;
        &lt;span class="nx"&gt;vector_field&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"embedding"&lt;/span&gt;
        &lt;span class="nx"&gt;text_field&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"chunks"&lt;/span&gt;
        &lt;span class="nx"&gt;metadata_field&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"metadata"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_db&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_bedrockagent_data_source"&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;knowledge_base_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_knowledge_base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.cluster_name}-knowledge-base-s3"&lt;/span&gt;

  &lt;span class="nx"&gt;data_source_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S3"&lt;/span&gt;
    &lt;span class="nx"&gt;s3_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;bucket_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;vector_ingestion_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chunking_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;chunking_strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HIERARCHICAL"&lt;/span&gt;
      &lt;span class="nx"&gt;hierarchical_chunking_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;level_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;max_tokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;level_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;max_tokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;overlap_tokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hierarchical chunking is worth calling out: it splits documents into large 1500-token "parent" chunks and smaller 300-token "child" chunks with a 60-token overlap. Retrieval matches on the precise child chunk but can return the broader parent context — better recall on long documents than flat fixed-size chunking, at the cost of slightly more complex ingestion.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-time schema bootstrap
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;aws_bedrockagent_knowledge_base&lt;/code&gt; expects the target table to already exist with the right columns and indexes — Terraform won't create the &lt;code&gt;pgvector&lt;/code&gt; extension or table for you. This is a one-time &lt;code&gt;psql&lt;/code&gt; job against the Aurora endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bedrock_integration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bedrock_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bedrock_kb&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;        &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;chunks&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;  &lt;span class="n"&gt;JSON&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bedrock_kb_embedding_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;bedrock_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bedrock_kb&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;bedrock_kb_chunks_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;bedrock_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bedrock_kb&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'simple'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HNSW index handles approximate nearest-neighbor search on the embedding vector; the GIN index on &lt;code&gt;chunks&lt;/code&gt; enables hybrid search (keyword + semantic) if you ever want it. &lt;code&gt;vector(1024)&lt;/code&gt; matches Titan Embed Text v2's output dimensionality — if you swap embedding models later, the column width has to match or ingestion fails outright.&lt;/p&gt;

&lt;h3&gt;
  
  
  Publishing references via SSM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"kb_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/${local.env}/knowledge-base/kb_id"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"String"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_bedrockagent_knowledge_base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"kb_bucket"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/${local.env}/knowledge-base/bucket"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"String"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kb_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apps and CI pipelines read these instead of hardcoding ARNs — when you rebuild the KB in a new account or region, nothing downstream needs a code change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Syncing docs automatically with GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Provisioning is the easy part. The actual day-to-day value comes from never having to think about ingestion again. Drop a markdown file in a &lt;code&gt;knowledge-base/&lt;/code&gt; folder, push, and it's searchable within minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sync Knowledge Base&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;choice&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Environment to sync knowledge base&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;acc&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;knowledge-base/**'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sync-kb-acc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;github.event_name == 'workflow_dispatch' &amp;amp;&amp;amp; github.event.inputs.environment == 'acc' ||&lt;/span&gt;
      &lt;span class="s"&gt;github.event_name == 'push'&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-org/your-pipelines-repo/.github/workflows/sync-bedrock-knowledge-base.yml@v2&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acc&lt;/span&gt;
      &lt;span class="na"&gt;knowledge-base-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.BEDROCK_KB_ID_ACC }}&lt;/span&gt;
      &lt;span class="na"&gt;data-source-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.BEDROCK_KB_DATA_SOURCE_ID_ACC }}&lt;/span&gt;
      &lt;span class="na"&gt;bucket-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.BEDROCK_KB_BUCKET_ACC }}&lt;/span&gt;
      &lt;span class="na"&gt;bucket-prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;knowledge-base&lt;/span&gt;
      &lt;span class="na"&gt;source-dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;knowledge-base&lt;/span&gt;

  &lt;span class="na"&gt;sync-kb-prod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'workflow_dispatch' &amp;amp;&amp;amp; github.event.inputs.environment == 'prod'&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-org/your-pipelines-repo/.github/workflows/sync-bedrock-knowledge-base.yml@v2&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
      &lt;span class="na"&gt;knowledge-base-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.BEDROCK_KB_ID_PROD }}&lt;/span&gt;
      &lt;span class="na"&gt;data-source-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.BEDROCK_KB_DATA_SOURCE_ID_PROD }}&lt;/span&gt;
      &lt;span class="na"&gt;bucket-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.BEDROCK_KB_BUCKET_PROD }}&lt;/span&gt;
      &lt;span class="na"&gt;bucket-prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;knowledge-base&lt;/span&gt;
      &lt;span class="na"&gt;source-dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;knowledge-base&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gating logic is the whole trick here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;push&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt;, path-filtered to `knowledge-base/&lt;/strong&gt;&lt;code&gt;** — only fires the &lt;/code&gt;acc` job. Routine doc edits land in the acc environment automatically, with zero manual steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt; with &lt;code&gt;environment: acc&lt;/code&gt;&lt;/strong&gt; — also runs the acc job. Useful for re-triggering a sync without a new commit (e.g. after fixing a broken IAM policy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt; with &lt;code&gt;environment: prod&lt;/code&gt;&lt;/strong&gt; — the &lt;em&gt;only&lt;/em&gt; path that touches prod. Promotion to production is always a deliberate, manual action, never a side effect of a push.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both jobs delegate to the same reusable workflow (&lt;code&gt;sync-bedrock-knowledge-base.yml@v2&lt;/code&gt;), parameterized per environment. The reusable workflow does the actual work: sync the &lt;code&gt;source-dir&lt;/code&gt; to the S3 &lt;code&gt;bucket-name&lt;/code&gt; under &lt;code&gt;bucket-prefix&lt;/code&gt;, then call &lt;code&gt;StartIngestionJob&lt;/code&gt; against &lt;code&gt;data-source-id&lt;/code&gt;. Centralizing that logic in one reusable workflow means every team adopting this pattern gets the same sync behavior — and a fix to the sync logic ships everywhere at once instead of needing fifteen copy-pasted workflow files updated individually.&lt;/p&gt;




&lt;h2&gt;
  
  
  Field notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Data API requires &lt;code&gt;enable_http_endpoint = true&lt;/code&gt; on the Aurora cluster.&lt;/strong&gt; Without it, Bedrock's &lt;code&gt;rds-data:ExecuteStatement&lt;/code&gt; calls fail with a confusing connectivity error that has nothing to do with security groups — you'll waste an hour checking VPC routing before you find this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector dimension mismatches fail silently at the wrong layer.&lt;/strong&gt; If &lt;code&gt;vector(1024)&lt;/code&gt; doesn't match your embedding model's output size, the table creation succeeds, the Knowledge Base resource creates fine, and ingestion just fails per-document. Check the embedding model's dimensionality before writing the DDL, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hierarchical chunking is the right default for prose-heavy docs&lt;/strong&gt;, but if your knowledge base is mostly short, structured files (FAQs, glossaries), flat fixed-size chunking is simpler to reason about and debug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't let &lt;code&gt;push&lt;/code&gt;-triggered syncs touch prod.&lt;/strong&gt; It's tempting to make prod sync automatically on a release tag, but a bad doc — wrong numbers, stale instructions — propagating into a production RAG pipeline with no human in the loop is a worse failure mode than a slightly stale prod KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;acc&lt;/code&gt;/&lt;code&gt;prod&lt;/code&gt; split needs two of everything&lt;/strong&gt; — bucket, KB, data source, Aurora cluster — not just two sets of IAM permissions on shared resources. It costs more, but it means a bad ingestion config or chunking change gets caught in acc before it can corrupt the index your production LLM proxy actually queries.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;None of this is exotic — it's a bucket, a Postgres extension, two indexes, and a YAML file with an &lt;code&gt;if:&lt;/code&gt; condition. That's the whole trick to "production RAG": treat the knowledge base like any other piece of infrastructure, version it, gate promotion to prod behind a manual step, and let the boring CI pipeline do the boring sync work. Your LLM stops winging it, and you stop being the person who manually re-uploads PDFs every time someone asks why the bot doesn't know about last week's runbook update.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>pgvector</category>
      <category>rag</category>
    </item>
    <item>
      <title>How to Upgrade EKS 1.32: Making the Switch from bootstrap.sh to nodeadm</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Tue, 28 Oct 2025 17:10:23 +0000</pubDate>
      <link>https://dev.to/hugolesta/how-to-upgrade-eks-132-making-the-switch-from-bootstrapsh-to-nodeadm-9lf</link>
      <guid>https://dev.to/hugolesta/how-to-upgrade-eks-132-making-the-switch-from-bootstrapsh-to-nodeadm-9lf</guid>
      <description>&lt;p&gt;Did you know staying on a deprecated version of Kubernetes in EKS can cost you six times more? At $0.60 per hour instead of the standard $0.10 per hour, you could end up paying nearly $500 per month just for running outdated clusters.&lt;/p&gt;

&lt;p&gt;However, the EKS upgrade from version 1.31 to 1.32 isn't just about avoiding extended support fees—it introduces one of the biggest under-the-hood changes in recent EKS history. Specifically, the traditional bootstrap.sh script used for years to configure worker nodes is now replaced by a new tool called &lt;code&gt;nodeadm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This architectural shift coincides with another critical change: after November 26, 2025, Amazon EKS will no longer publish EKS-optimized Amazon Linux 2 (AL2) AMIs. Furthermore, Kubernetes 1.32 will be the final version with AL2 AMI support, while version 1.33 onwards will only support Amazon Linux 2023 (AL2023) and Bottlerocket based AMIs.&lt;/p&gt;

&lt;p&gt;If your clusters currently run EKS 1.31 or earlier on Amazon Linux 2 AMIs, upgrading to 1.32 will break your node initialization unless you adapt your user-data scripts and switch to AL2023. Throughout this article, we'll walk through exactly what changes between versions 1.31 and 1.32, why &lt;code&gt;nodeadm&lt;/code&gt; is now required, and how to rewrite your user-data and Terraform templates to ensure a smooth transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why EKS 1.32 Requires a New Bootstrap Approach
&lt;/h2&gt;

&lt;p&gt;Amazon's evolution of EKS introduces significant architectural changes with version 1.32. The most critical change affects how worker nodes bootstrap and join your clusters, requiring careful attention during upgrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  End of Support for bootstrap.sh in AL2023
&lt;/h2&gt;

&lt;p&gt;EKS 1.32 marks a pivotal shift as it's the &lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions-standard.html" rel="noopener noreferrer"&gt;final version for which Amazon will release Amazon Linux 2 (AL2) AMIs&lt;/a&gt;. Starting with Kubernetes 1.32, AL2023 introduces a completely different node initialization process that abandons the traditional &lt;code&gt;/etc/eks/bootstrap.sh&lt;/code&gt; script. This script has been the foundation of EKS node bootstrapping since the service launched, but is now replaced entirely in the AL2023 operating system. The familiar bash-based bootstrap approach that many DevOps teams have built automation around is completely absent in the new OS version.&lt;/p&gt;

&lt;h2&gt;
  
  
  nodeadm as the New Default Bootstrap Tool
&lt;/h2&gt;

&lt;p&gt;AL2023 replaces bootstrap.sh with &lt;code&gt;nodeadm&lt;/code&gt;, a tool that uses a YAML configuration schema. Unlike the previous approach where metadata was discovered automatically through the Amazon EKS &lt;code&gt;DescribeCluster&lt;/code&gt; API call, &lt;code&gt;nodeadm&lt;/code&gt; requires explicit provision of cluster information. This fundamental change means you must now specify three critical parameters that were previously auto-discovered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;apiServerEndpoint&lt;/li&gt;
&lt;li&gt;certificateAuthority&lt;/li&gt;
&lt;li&gt;service CIDR&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Additionally, the format for applying parameters to kubelet has changed. Previously accomplished with &lt;code&gt;--kubelet-extra-args&lt;/code&gt;, node customization now requires using &lt;code&gt;NodeConfigSpec&lt;/code&gt;. This shift aims to reduce API throttling risks during large-scale node deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impact on Existing AL2-Based Clusters
&lt;/h2&gt;

&lt;p&gt;The elimination of bootstrap.sh creates immediate backward compatibility issues. When upgrading an EKS cluster to version 1.32 while still using AL2-based node groups, &lt;a href="https://medium.com/@kalidindi-naveen/eks-ami-upgradation-journey-from-amazon-linux-2-to-amazon-linux-2023-385c1d958b27" rel="noopener noreferrer"&gt;nodes will fail to join the cluster&lt;/a&gt;. Any automation depending on bootstrap.sh will break, as files like &lt;code&gt;/etc/eks/bootstrap.sh&lt;/code&gt; and &lt;code&gt;/etc/eks/eni-max-pods.txt&lt;/code&gt; no longer exist.&lt;/p&gt;

&lt;p&gt;For organizations with self-managed node groups or custom AMI configurations, this requires extracting and explicitly providing cluster metadata that was formerly obtained automatically. Consequently, deployment scripts, Terraform modules, and CloudFormation templates must be rewritten to align with the new declarative approach before successfully migrating to EKS 1.32.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing for the Migration to nodeadm
&lt;/h2&gt;

&lt;p&gt;Before migrating to EKS 1.32 with &lt;code&gt;nodeadm&lt;/code&gt;, careful preparation is essential to ensure a smooth transition from the traditional bootstrap approach to the new paradigm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identifying Affected Clusters Running AL2
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/eks-ami-deprecation-faqs.html" rel="noopener noreferrer"&gt;After November 26, 2025&lt;/a&gt;, AWS will end support for EKS AL2-optimized AMIs. Kubernetes version 1.32 represents the final release where Amazon EKS will provide AL2 AMIs. This deadline necessitates prompt action, especially for organizations with multiple clusters.&lt;/p&gt;

&lt;p&gt;To identify affected clusters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the AMI type for each node group using:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws eks describe-nodegroup --cluster-name &amp;lt;cluster-name&amp;gt; --nodegroup-name &amp;lt;nodegroup-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Examine existing user-data scripts that reference &lt;code&gt;/etc/eks/bootstrap.sh&lt;/code&gt;, which won't exist in AL2023.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Choosing Between AL2023 and Bottlerocket AMIs
&lt;/h2&gt;

&lt;p&gt;Upon identifying clusters requiring migration, you must decide between AL2023 and Bottlerocket as your future node operating system.&lt;/p&gt;

&lt;p&gt;AL2023 offers several advantages over AL2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Secure-by-default approach with preconfigured security policies&lt;/li&gt;
&lt;li&gt;SELinux in permissive mode&lt;/li&gt;
&lt;li&gt;IMDSv2-only mode enabled by default&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/eks-ami-deprecation-faqs.html" rel="noopener noreferrer"&gt;Optimized boot times and improved package management&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alternatively, Bottlerocket provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purpose-built container-optimized design with minimal attack surface&lt;/li&gt;
&lt;li&gt;Enhanced security with read-only file systems&lt;/li&gt;
&lt;li&gt;Automatic updates&lt;/li&gt;
&lt;li&gt;Improved compliance with security standards like CIS benchmarks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose AL2023 when you need significant customizations with direct OS-level access. Opt for Bottlerocket if you prefer a container-native approach with minimal node customization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scanning for Deprecated APIs with kubent or pluto
&lt;/h2&gt;

&lt;p&gt;Prior to upgrading, scan for deprecated APIs that might break during the transition. Kubernetes frequently removes beta APIs with each new version, potentially disrupting your workloads.&lt;/p&gt;

&lt;p&gt;The kube-no-trouble tool (kubent) efficiently identifies resources using deprecated APIs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scans all accessible namespaces and lists APIs that will be deprecated compared to your current Kubernetes version. For clusters with hundreds of applications across multiple namespaces, this tool proves invaluable in detecting potential upgrade issues beforehand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backing Up Cluster State and Node Configurations
&lt;/h2&gt;

&lt;p&gt;A comprehensive migration plan should always include thorough backups. Given that you cannot downgrade an EKS cluster after upgrading, backups become crucial.&lt;/p&gt;

&lt;p&gt;Recommended backup steps include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Taking etcd snapshots for core Kubernetes data&lt;/li&gt;
&lt;li&gt;Backing up cluster configuration using tools like Velero&lt;/li&gt;
&lt;li&gt;Documenting node-specific configurations, especially custom user-data scripts&lt;/li&gt;
&lt;li&gt;Preserving IAM role configurations that will need migration to the new nodeadm format&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Additionally, establish a documented rollback procedure with well-defined testing protocols before proceeding with the upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing nodeadm and AL2023
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuelrvrgw68lne8v0iqea.WEBP" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuelrvrgw68lne8v0iqea.WEBP" alt="How EKS cluster works" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Image Source: AWS Documentation&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging and Validating the Upgrade
&lt;/h2&gt;

&lt;p&gt;After implementing &lt;code&gt;nodeadm&lt;/code&gt; and upgrading to EKS 1.32, troubleshooting becomes essential as new components may not function perfectly on the first try. The migration introduces different debugging approaches that we need to master.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common nodeadm Errors and How to Fix Them
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;nodeadm&lt;/code&gt; debug command serves as our first line of defense when troubleshooting unhealthy or misconfigured nodes. It validates critical requirements including network access to AWS APIs, credentials for the IAM role, connectivity to the EKS Kubernetes API endpoint, and authentication with the EKS cluster.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nodeadm debug -c file://nodeConfig.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For configuration validation before implementation, the &lt;code&gt;nodeadm config check&lt;/code&gt; command proves invaluable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nodeadm config check -c file://nodeConfig.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most permission-related issues arise from the Hybrid Nodes IAM role &lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/hybrid-nodes-troubleshooting.html" rel="noopener noreferrer"&gt;missing the necessary eks:DescribeCluster action&lt;/a&gt;. Other common errors include network connectivity problems, incorrect node IP configuration, and timeout issues which can be remedied by extending timeouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nodeadm install K8S_VERSION --credential-provider CREDS_PROVIDER --timeout 20m0s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying kubelet and containerd Startup Logs
&lt;/h2&gt;

&lt;p&gt;Examining kubelet logs offers visibility into node initialization problems. For AL2023 nodes, we can use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;systemctl status kubelet
journalctl -u kubelet -o cat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moreover, checking the status helps verify successful restarts after upgrades. For deeper troubleshooting, we can connect to the node using SSH or kubectl debug and inspect the logs. My personal preference is using &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html" rel="noopener noreferrer"&gt;AWS Systems Manager Session Manager&lt;/a&gt; to enhance security, avoiding opening SSH ports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chroot /host journalctl -u kubelet -o cat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checking Node Readiness and Taints
&lt;/h2&gt;

&lt;p&gt;To verify node status after migration, we use standard kubectl commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl get nodes -o wide
kubectl describe node NODE_NAME
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The STATUS column should display "Ready" for all nodes with the updated version number visible. Untainted nodes are essential for workload scheduling. Furthermore, checking events related to nodes can reveal issues with node registration or initialization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating Add-on Compatibility Post-Upgrade
&lt;/h2&gt;

&lt;p&gt;Post-upgrade add-on validation requires checking deployment versions and ensuring pods are running correctly. For example, to verify the CoreDNS version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl describe deployment coredns -n kube-system | grep Image | cut -d ':' -f 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To inspect add-on logs for errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl logs -n kube-system -l k8s-app=kube-dns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For networking add-ons like Amazon VPC CNI, we should create test pods to validate IP assignment. Additionally, testing CoreDNS functionality using tools like nslookup ensures proper DNS resolution. Finally, checking if the number of replicas equals the number of nodes for daemonset add-ons like vpc-cni and kube-proxy confirms proper deployment.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;Migrating to EKS 1.32 represents a significant paradigm shift for Kubernetes operations on AWS. The transition from &lt;code&gt;bootstrap.sh&lt;/code&gt; to &lt;code&gt;nodeadm&lt;/code&gt; fundamentally changes how worker nodes join your clusters, requiring careful planning and execution. Additionally, the impending deprecation of Amazon Linux 2 AMIs after November 26, 2025, creates urgency for organizations to adapt their infrastructure.&lt;/p&gt;

&lt;p&gt;Throughout this upgrade journey, you must remember that &lt;code&gt;nodeadm&lt;/code&gt; demands explicit configuration through YAML rather than command-line arguments. This declarative approach actually offers better consistency and reproducibility for node configurations once implemented correctly. Undoubtedly, the initial migration might seem daunting, especially when rewriting existing automation scripts or Terraform modules.&lt;/p&gt;

&lt;p&gt;The choice between AL2023 and Bottlerocket AMIs depends largely on your specific operational requirements. AL2023 provides a familiar environment with improved security features, whereas Bottlerocket offers a container-optimized approach with minimal attack surface. Regardless of your choice, both options eliminate the traditional bootstrap files and require adaptation to the new &lt;code&gt;nodeadm&lt;/code&gt; paradigm.&lt;/p&gt;

&lt;p&gt;Before initiating any upgrade, thorough preparation becomes essential. First, identify affected clusters running AL2, then scan for deprecated APIs, and finally back up your cluster state. After implementing the necessary changes, debugging tools like &lt;code&gt;nodeadm debug&lt;/code&gt; and &lt;code&gt;nodeadm config check&lt;/code&gt; help troubleshoot any issues that arise during the migration process.&lt;/p&gt;

&lt;p&gt;The EKS 1.32 upgrade, therefore, presents both challenges and opportunities. While it requires significant changes to existing workflows, it also aligns your infrastructure with AWS's future direction. Consequently, organizations that proactively adapt their node initialization processes will avoid extended support fees and benefit from improved security and performance features of newer operating systems.&lt;/p&gt;

&lt;p&gt;Ultimately, staying current with EKS versions not only saves operational costs but also ensures compatibility with the evolving Kubernetes ecosystem. Though this particular upgrade demands more effort than typical version bumps, the long-term benefits of embracing &lt;code&gt;nodeadm&lt;/code&gt; and AL2023 far outweigh the initial investment required for migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;EKS 1.32 introduces the most significant architectural change in recent history, requiring organizations to abandon the traditional bootstrap.sh script in favor of nodeadm for worker node initialization.&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Critical deadline approaching:&lt;/strong&gt; Amazon ends AL2 AMI support on November 26, 2025, making EKS 1.32 the final version supporting Amazon Linux 2.&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Bootstrap method completely changes:&lt;/strong&gt; nodeadm replaces bootstrap.sh and requires explicit YAML configuration instead of automatic cluster metadata discovery.&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Migration requires careful preparation:&lt;/strong&gt; Identify AL2-based clusters, scan for deprecated APIs with kubent, and backup cluster state before upgrading.&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Choose your AMI strategy:&lt;/strong&gt; Select between AL2023 for familiar environments with enhanced security or Bottlerocket for container-optimized minimal attack surface.&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Debug with new tools:&lt;/strong&gt; Use nodeadm debug and nodeadm config check commands to troubleshoot configuration issues and validate node readiness.&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Avoid costly extended support:&lt;/strong&gt; Staying on deprecated Kubernetes versions costs 6x more ($0.60/hour vs $0.10/hour), potentially adding $500+ monthly per cluster.&lt;/p&gt;

&lt;p&gt;The shift to nodeadm represents AWS's commitment to more secure, declarative infrastructure management. While the initial migration requires significant effort in rewriting automation scripts and Terraform modules, organizations that proactively adapt will benefit from improved security, performance, and long-term cost savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q1. What is the main change in EKS 1.32 that affects node initialization?&lt;/strong&gt; EKS 1.32 replaces the traditional bootstrap.sh script with a new tool called nodeadm for worker node initialization. This change requires explicit YAML configuration instead of automatic cluster metadata discovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q2. When will Amazon stop supporting Amazon Linux 2 (AL2) AMIs for EKS?&lt;/strong&gt; Amazon will end support for EKS AL2-optimized AMIs after November 26, 2025. Kubernetes version 1.32 is the final release where Amazon EKS will provide AL2 AMIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q3. What are the alternatives to Amazon Linux 2 for EKS nodes?&lt;/strong&gt; The main alternatives are Amazon Linux 2023 (AL2023) and Bottlerocket AMIs. AL2023 offers improved security features and a familiar environment, while Bottlerocket provides a container-optimized design with a minimal attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q4. How can I identify and fix common nodeadm errors?&lt;/strong&gt; You can use the 'nodeadm debug' command to validate critical requirements and the 'nodeadm config check' command to validate configurations. Common issues include missing IAM permissions, network connectivity problems, and incorrect node IP configurations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q5. What are the cost implications of staying on a deprecated version of EKS?&lt;/strong&gt; Running a deprecated version of EKS can cost six times more than the standard rate. Instead of $0.10 per hour, you could end up paying $0.60 per hour, potentially adding over $500 per month for each outdated cluster.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>kubernetes</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>The $10,000 Label: How We Used Go, Clean Architecture, and AWS to Build a FinOps-Driven Cloud Tagging Engine 🏷️</title>
      <dc:creator>hugolesta</dc:creator>
      <pubDate>Mon, 29 Sep 2025 16:01:54 +0000</pubDate>
      <link>https://dev.to/hugolesta/the-10000-label-how-we-used-go-clean-architecture-and-aws-to-build-a-finops-driven-cloud-2988</link>
      <guid>https://dev.to/hugolesta/the-10000-label-how-we-used-go-clean-architecture-and-aws-to-build-a-finops-driven-cloud-2988</guid>
      <description>&lt;p&gt;&lt;strong&gt;Why Consistent Tagging is Your Company’s Most Underrated FinOps Tool:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Business Problem:&lt;/strong&gt; Imagine your cloud bill is a massive corporate expense report. Without proper tagging—simple key-value labels like &lt;code&gt;project: crm-migration&lt;/code&gt; or &lt;code&gt;owner: finance-team&lt;/code&gt; —you're paying thousands every month for line items labeled simply “Server.” This isn't just an accounting headache; it's a direct threat to cost control and security.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost Bloat:&lt;/strong&gt; Orphaned or forgotten AWS resources (Shadow IT) continue to generate costs because no one is accountable for terminating them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing Disputes:&lt;/strong&gt; Finance teams struggle to attribute costs accurately, leading to friction and delayed chargebacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Risks:&lt;/strong&gt; Unmanaged resources often fall outside compliance or patch cycles.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We decided to solve this with &lt;strong&gt;sys-tag-manager&lt;/strong&gt;, a powerful, automated system built in Golang that acts as our centralized "Cloud Label Printer," ensuring every AWS resource is correctly accounted for, compliant, and cost-trackable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Is a Tagging Strategy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A tagging strategy is a &lt;strong&gt;structured approach to applying metadata (tags)&lt;/strong&gt; to cloud resources. Tags are simple key–value pairs like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;owner: finance-team
project: crm-migration
environment: production

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On their own, tags look trivial. But when applied consistently across an entire cloud estate, they form the backbone of organization, governance, and cost management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tagging strategy defines:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which tags are required&lt;/strong&gt; (e.g. owner, project, environment).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How tags should be formatted&lt;/strong&gt; (naming conventions, lowercase vs camelCase, separators).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When tags should be applied&lt;/strong&gt; (at creation time vs automated correction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who is responsible&lt;/strong&gt; for maintaining them?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For further guidance, consult the AWS documentation: &lt;a href="https://docs.aws.amazon.com/whitepapers/latest/tagging-best-practices/tagging-best-practices.html" rel="noopener noreferrer"&gt;Best Practices for Tagging AWS Resources&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical Foundation: Go, Clean Architecture, and AWS Cost Savings&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3wzxfdb1qzcfncwiokda.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3wzxfdb1qzcfncwiokda.png" alt="Pretty gopher doing Thumb-up" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Go Advantage: Performance Meets FinOps:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While prototyping in Python is quick, a core, mission-critical tool demands performance and reliability. We chose Golang for &lt;strong&gt;sys-tag-manager&lt;/strong&gt; because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cost-Efficient Execution:&lt;/strong&gt; Go's minimal memory footprint and extremely fast startup time are critical when running as AWS Lambda functions or Kubernetes CronJobs. This translates directly into lower AWS compute costs (less time billed for execution) compared to resource-heavier languages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability:&lt;/strong&gt; Static typing and robust concurrency ensure the system can efficiently handle a rapidly growing number of AWS API calls without failure, ensuring 100% compliance coverage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0m618venq5pdoydk5g1g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0m618venq5pdoydk5g1g.png" alt="The clean architecture, Domain, Adapter and User Case" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Clean Architecture: Decoupling Logic from the AWS SDK&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To ensure the system remains maintainable as our cloud estate scales, we invested in &lt;strong&gt;Clean Architecture&lt;/strong&gt;. This strategic separation is key to our long-term technical debt reduction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Domain (Core Business Logic):&lt;/strong&gt; Pure, independent rules (e.g., "A resource is compliant if it has the required tags: &lt;code&gt;owner&lt;/code&gt; and &lt;code&gt;project&lt;/code&gt;"). This is highly &lt;strong&gt;testable&lt;/strong&gt; and knows nothing about AWS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Cases (Application Logic):&lt;/strong&gt; Defines the "what" (e.g., "Check compliance and apply tags").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adapters (AWS Implementation):&lt;/strong&gt; Isolated logic that interacts directly with specific AWS SDK services (&lt;code&gt;EC2Tagger&lt;/code&gt;, &lt;code&gt;RDSTagger&lt;/code&gt;). This prevents vendor lock-in and allows us to add new services (S3, Lambda) without touching the core business rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Compliance Engine: Terraform, SSM, and Metadata Management&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dmb67cnjqdn7m87s5jz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dmb67cnjqdn7m87s5jz.png" alt="AWS Resource explorer is the discovery layer" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Discovery Layer: Leveraging AWS Resource Explorer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before &lt;strong&gt;sys-tag-manager&lt;/strong&gt; can fix untagged resources, it must efficiently find them across accounts and Regions. We achieved this by using &lt;strong&gt;AWS Resource Explorer&lt;/strong&gt; as our primary discovery and inventory layer.&lt;/p&gt;

&lt;p&gt;Instead of writing complex API calls to list every resource type across every Region, &lt;strong&gt;sys-tag-manager&lt;/strong&gt; utilizes Resource Explorer's unified search capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Workflow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discovery:&lt;/strong&gt; &lt;strong&gt;sys-tag-manager&lt;/strong&gt; uses the Resource Explorer API to query the entire cloud estate for resources that are missing required tags (e.g., &lt;code&gt;tag:owner is absent&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation:&lt;/strong&gt; For each untagged resource found, &lt;strong&gt;sys-tag-manager&lt;/strong&gt; checks its metadata against the centralized, correct rules stored in &lt;strong&gt;AWS SSM Parameter Store&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correction:&lt;/strong&gt; The system then applies the right tags, assigning the resource to the correct owner or project, ensuring immediate compliance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This design significantly streamlined the core tagging loop, ensuring we are not just efficient in applying tags (Golang), but also efficient in &lt;strong&gt;finding&lt;/strong&gt; them (Resource Explorer), saving API call costs and latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harnessing Shared Infrastructure: The Fallback Mechanism&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While our primary goal is to enforce resource-specific tagging, we recognized that some resources are &lt;strong&gt;"Shared Infrastructure"&lt;/strong&gt; (e.g., core networking components, centralized security groups) that don't belong to a single owner. Addressing this was a critical design challenge.&lt;/p&gt;

&lt;p&gt;Our solution was a &lt;strong&gt;smart fallback mechanism&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The tagging engine first checks for the required resource-specific tags.&lt;/li&gt;
&lt;li&gt;If the tags are missing, it then checks a predefined list of &lt;strong&gt;AWS ARNs&lt;/strong&gt; (Amazon Resource Names) that are designated as shared infrastructure.&lt;/li&gt;
&lt;li&gt;If an ARN matches, the system &lt;strong&gt;applies a generic, shared set of tags&lt;/strong&gt; (e.g., &lt;code&gt;owner: platform-team&lt;/code&gt;, &lt;code&gt;charge-code: shared-infra&lt;/code&gt;) instead of flagging it as non-compliant. This prevents false positives and ensures accurate cost attribution for common resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw7mnmtvltdw9tm2yeymp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw7mnmtvltdw9tm2yeymp.png" alt="Hashicorp and AWS hugging" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Terraform + SSM Parameter Store Synergy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The true power of &lt;strong&gt;sys-tag-manager&lt;/strong&gt; lies in its ability to dynamically enforce tagging rules based on centralized, auditable metadata.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Centralized Rule Source:&lt;/strong&gt; We leverage &lt;strong&gt;AWS Systems Manager (SSM) Parameter Store&lt;/strong&gt; to store the required tag keys, values, and compliance rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform as the Single Source of Truth:&lt;/strong&gt; The compliance rules in SSM are managed exclusively by Terraform. This means:

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Immutability:&lt;/strong&gt; Every rule change is tracked, reviewed, and deployed via a GitOps workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automation:&lt;/strong&gt; When a new project is created in Terraform, the required tag values for that project are automatically pushed to SSM, immediately making those tags valid for the tag manager checks.&lt;/li&gt;
&lt;/ol&gt;


&lt;/li&gt;

&lt;/ol&gt;

&lt;p&gt;This integration ensures that the FinOps rules are always aligned with the deployed infrastructure definitions, creating a clean, traceable metadata loop.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Business Impact: Quantifiable Results for FinOps&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjw9s6sh98t6w6is5l7z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjw9s6sh98t6w6is5l7z6.png" alt="Descriptive pie charts" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before sys-tag-manager&lt;/th&gt;
&lt;th&gt;After sys-tag-manager&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Value Proposition&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compliance Time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Weeks (Manual Audits)&lt;/td&gt;
&lt;td&gt;Minutes (Automated Correction)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Faster cost allocation &amp;amp; reduced risk.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Orphaned Resources&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~12% (Estimate)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt;1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Direct savings on wasted AWS spend.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FinOps Accuracy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High Friction/Disputes&lt;/td&gt;
&lt;td&gt;High Trust/Automated Showback&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Enables accurate, automated chargeback.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By investing in this system, we have fundamentally shifted from reactive tag auditing to &lt;strong&gt;proactive, automated compliance enforcement&lt;/strong&gt;. This not only saves engineering hours but directly enables our finance team to confidently utilize &lt;strong&gt;AWS Cost and Usage Reports (CUR)&lt;/strong&gt; for accurate showback and chargeback, making our entire cloud operation more accountable and &lt;strong&gt;financially efficient&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Wrapping Up: Compliance as Code, Savings as the Result&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;sys-tag-manager&lt;/strong&gt; is more than just an automation script; it's the enforcement layer for our FinOps and security policies, ensuring that our cloud environment is self-healing and financially accountable.&lt;/p&gt;

&lt;p&gt;By embracing &lt;strong&gt;Golang&lt;/strong&gt; for performance, &lt;strong&gt;Clean Architecture&lt;/strong&gt; for maintainability, and the &lt;strong&gt;Terraform + SSM&lt;/strong&gt; synergy for centralized metadata management, we've transformed tagging from a manual burden into an automated, cost-saving asset. This shift has given us the confidence that every dollar spent on AWS is trackable, auditable, and directly attributed to a business owner or project.&lt;/p&gt;

&lt;p&gt;The result is a culture of &lt;strong&gt;Compliance as Code&lt;/strong&gt; where engineers can focus on feature delivery, knowing that the foundational governance—the tagging—is handled automatically and efficiently by &lt;strong&gt;sys-tag-manager&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let's Keep the Conversation Going&lt;/strong&gt; 🗣️&lt;br&gt;
We've focused on the technical core of &lt;strong&gt;sys-tag-manager&lt;/strong&gt;, but the true organizational victory was how we scaled this system across dozens of teams without friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would you be interested in learning more about how we automated the communication of compliance status and tagging fixes to developers, FinOps, and management?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;I'd love to hear your thoughts!&lt;/strong&gt; What's the biggest tagging challenge you face in your organization? &lt;strong&gt;Share your experiences and suggestions in the comments below!&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Feel free to connect with me on LinkedIn to discuss our approach to automated communication and team onboarding:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/hugo-lesta-5a058138/" rel="noopener noreferrer"&gt;Hugo Lesta&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/hugolesta" rel="noopener noreferrer"&gt;Hugo Lesta's GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>finops</category>
      <category>terraform</category>
      <category>go</category>
    </item>
  </channel>
</rss>
