<?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: Ajinkya</title>
    <description>The latest articles on DEV Community by Ajinkya (@ajinkya_a3).</description>
    <link>https://dev.to/ajinkya_a3</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%2F3522740%2F46f689f7-963d-46d2-856a-54425776a0aa.jpg</url>
      <title>DEV Community: Ajinkya</title>
      <link>https://dev.to/ajinkya_a3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ajinkya_a3"/>
    <language>en</language>
    <item>
      <title>Stop Storing Access Keys in GitHub Secrets: Use OIDC for AWS</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Fri, 02 Oct 2026 08:09:31 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/stop-storing-access-keys-in-github-secrets-use-oidc-for-aws-i8o</link>
      <guid>https://dev.to/ajinkya_a3/stop-storing-access-keys-in-github-secrets-use-oidc-for-aws-i8o</guid>
      <description>&lt;p&gt;If your GitHub Actions pipeline deploys to AWS, there's a good chance you've seen (or written) 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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
  &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It's also a quiet liability sitting in your repo settings.&lt;/p&gt;

&lt;p&gt;There's a better way: &lt;strong&gt;no static keys, no rotation headaches, no credentials sitting in GitHub Secrets.&lt;/strong&gt; Let's look at why the old way hurts, and how OIDC fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Index
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The problem with static AWS credentials&lt;/li&gt;
&lt;li&gt;What is AWS OIDC?&lt;/li&gt;
&lt;li&gt;
Heads up: GitHub just changed the token format

&lt;ul&gt;
&lt;li&gt;Why GitHub changed it&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Build it with Terraform

&lt;ul&gt;
&lt;li&gt;1. Get your IDs&lt;/li&gt;
&lt;li&gt;2. Variables&lt;/li&gt;
&lt;li&gt;3. Provider and subject&lt;/li&gt;
&lt;li&gt;4. Trust policy, role, and permissions&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The workflow&lt;/li&gt;
&lt;li&gt;Debugging: see what GitHub actually sends&lt;/li&gt;
&lt;li&gt;Why this is secure&lt;/li&gt;
&lt;li&gt;Wrapping up&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The problem with static AWS credentials
&lt;/h2&gt;

&lt;p&gt;A traditional workflow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions
      |
      | AWS_ACCESS_KEY_ID
      | AWS_SECRET_ACCESS_KEY
      v
     AWS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two values are &lt;strong&gt;long-lived credentials&lt;/strong&gt;. They don't expire when the job ends. They don't care which repo, branch, or machine uses them. Whoever holds them &lt;em&gt;is&lt;/em&gt; your pipeline, today, next month, and next year.&lt;/p&gt;

&lt;p&gt;Even when they're stored "securely" as GitHub Secrets, you still have to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotate them&lt;/strong&gt; periodically (and remember to)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manage their lifecycle&lt;/strong&gt; across every repo that uses them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sure they never leak&lt;/strong&gt;, whether in logs, forks, a pasted screenshot, or a compromised dependency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke them&lt;/strong&gt; when a project or teammate is gone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the IAM user's permissions tight&lt;/strong&gt;, forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think about what a leak looks like: a key copied into a chat, printed by a debug step, or lifted by a malicious action. It works from any laptop on the planet until someone notices. Many keys never get rotated at all.&lt;/p&gt;

&lt;p&gt;Here's the thing, though: &lt;strong&gt;a CI/CD pipeline doesn't need permanent credentials.&lt;/strong&gt; It needs credentials that exist only while the pipeline is running.&lt;/p&gt;

&lt;p&gt;That's exactly what OIDC gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is AWS OIDC?
&lt;/h2&gt;

&lt;p&gt;OpenID Connect (OIDC) lets GitHub Actions prove its identity to AWS and receive &lt;strong&gt;temporary credentials&lt;/strong&gt;, with no stored AWS keys.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    GitHub
                      |
                GitHub Actions
                      |
                      | OIDC Token
                      v
                AWS IAM / STS
                      |
                      | AssumeRoleWithWebIdentity
                      v
                 IAM Role
                      |
                      | Temporary Credentials
                      v
                 AWS Resources
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub never hands you an access key to manage. Instead, for every workflow run, it generates a &lt;strong&gt;signed OIDC token&lt;/strong&gt; that says who is asking: which repo, which branch, which run. AWS verifies the signature, checks your role's trust policy, and returns credentials that &lt;strong&gt;expire on their own&lt;/strong&gt; (one hour by default).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Static access keys&lt;/th&gt;
&lt;th&gt;OIDC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stored in GitHub Secrets&lt;/td&gt;
&lt;td&gt;Nothing stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid until rotated (often never)&lt;/td&gt;
&lt;td&gt;Expire in about an hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A leak works from anywhere&lt;/td&gt;
&lt;td&gt;Token only works for your repo and branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual rotation&lt;/td&gt;
&lt;td&gt;Nothing to rotate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The API behind it is &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt;. The name is historical (it was built for web and mobile logins), but it's the same call used for GitHub, GitLab, and EKS. The caller needs no AWS credentials at all, because the signed token is the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heads up: GitHub just changed the token format
&lt;/h2&gt;

&lt;p&gt;This is the part that's breaking older tutorials. The &lt;code&gt;sub&lt;/code&gt; (subject) claim in the token used to look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repo:octo-org/octo-repo:ref:refs/heads/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For repositories created &lt;strong&gt;after July 15, 2026&lt;/strong&gt;, it now includes immutable numeric IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;123456&lt;/code&gt; is the owner ID and &lt;code&gt;456789&lt;/code&gt; is the repo ID. The &lt;code&gt;@&lt;/code&gt; separator is used because it can't appear in a GitHub username or repo name.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New repos&lt;/strong&gt; get the new format automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renamed or transferred repos&lt;/strong&gt; after that date also move to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older repos&lt;/strong&gt; keep the old format unless you opt in (Settings → Actions → OIDC).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Enterprise Server&lt;/strong&gt; isn't part of the rollout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your trust policy expects the old format and your repo sends the new one, AWS answers with &lt;code&gt;Not authorized to perform sts:AssumeRoleWithWebIdentity&lt;/code&gt;. Most guides online still show the old format, so this catches a lot of people.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why GitHub changed it
&lt;/h3&gt;

&lt;p&gt;The old &lt;code&gt;sub&lt;/code&gt; was built from &lt;strong&gt;names&lt;/strong&gt;, and names can be reused:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You own &lt;code&gt;alice/deploy-tools&lt;/code&gt;, and your AWS role trusts that exact name.&lt;/li&gt;
&lt;li&gt;You delete or rename the repo.&lt;/li&gt;
&lt;li&gt;An attacker registers the same name and creates the same repo.&lt;/li&gt;
&lt;li&gt;Their workflows produce the &lt;strong&gt;same &lt;code&gt;sub&lt;/code&gt;&lt;/strong&gt; as yours, and your role accepts them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Numeric IDs are assigned once and never reused. A recreated repo gets different IDs, so its &lt;code&gt;sub&lt;/code&gt; no longer matches your trust policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it with Terraform
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Get your IDs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/YOUR_USERNAME/YOUR_REPO &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'{repo_id: .id, owner_id: .owner.id}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or open &lt;code&gt;https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO&lt;/code&gt; in a browser (public repos) and read &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;owner.id&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Variables
&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"github_username"&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="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"github_repo"&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="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"github_branch"&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="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"github_owner_id"&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="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&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="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"github_repo_id"&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="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&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="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"create_oidc_provider"&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="nx"&gt;bool&lt;/span&gt;
  &lt;span class="nx"&gt;default&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;
  
  
  3. Provider and subject
&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_iam_openid_connect_provider"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&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;create_oidc_provider&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://token.actions.githubusercontent.com"&lt;/span&gt;
  &lt;span class="nx"&gt;client_id_list&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.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;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_openid_connect_provider"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&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;create_oidc_provider&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://token.actions.githubusercontent.com"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;oidc_provider_arn&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;create_oidc_provider&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="err"&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_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;use_ids&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;github_owner_id&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&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;github_repo_id&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;

  &lt;span class="nx"&gt;github_sub&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;use_ids&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"repo:${var.github_username}@${var.github_owner_id}/${var.github_repo}@${var.github_repo_id}:ref:refs/heads/${var.github_branch}"&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"repo:${var.github_username}/${var.github_repo}:ref:refs/heads/${var.github_branch}"&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;AWS allows only &lt;strong&gt;one&lt;/strong&gt; GitHub OIDC provider per account, so set &lt;code&gt;create_oidc_provider = false&lt;/code&gt; if yours already exists. The thumbprint can be omitted on AWS provider 5.81 or newer. The conditional builds the new format when you pass both IDs and falls back to the old one otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Trust policy, role, and permissions
&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;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"trust"&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;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:AssumeRoleWithWebIdentity"&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;"Federated"&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="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oidc_provider_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;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;"token.actions.githubusercontent.com:aud"&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="s2"&gt;"sts.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;"token.actions.githubusercontent.com:sub"&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;github_sub&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&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;"github-actions-deploy"&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;trust&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_attachment"&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;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;github_actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonS3FullAccess"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"github_actions_role_arn"&lt;/span&gt; &lt;span class="p"&gt;{&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_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_actions&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="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"allowed_subject"&lt;/span&gt; &lt;span class="p"&gt;{&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;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_sub&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trust policy answers &lt;strong&gt;who can become the role&lt;/strong&gt;. The policy attachment answers &lt;strong&gt;what the role can do&lt;/strong&gt;. They're separate on purpose.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AmazonS3FullAccess&lt;/code&gt; is fine for a demo, but it allows every S3 action on every bucket. For real use, swap it for a policy scoped to the your need and the resource you need.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
terraform apply
terraform output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The workflow
&lt;/h2&gt;

&lt;p&gt;Save the role ARN as a repository secret or variable named &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt;. Notice what's missing: no access key, no secret key.&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;OIDC Test&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="pi"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;      &lt;span class="c1"&gt;# without this, no token, no login&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&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;OIDC-Test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/checkout@v5&lt;/span&gt;

      &lt;span class="pi"&gt;-&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;aws-actions/configure-aws-credentials@v6&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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ap-south-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws sts get-caller-identity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic mistake is forgetting &lt;code&gt;id-token: write&lt;/code&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging: see what GitHub actually sends
&lt;/h2&gt;

&lt;p&gt;If you hit &lt;code&gt;Not authorized to perform sts:AssumeRoleWithWebIdentity&lt;/code&gt;, add this step before the AWS step:&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="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;Print OIDC claims&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \&lt;/span&gt;
            &lt;span class="s"&gt;"$ACTIONS_ID_TOKEN_REQUEST_URL&amp;amp;audience=sts.amazonaws.com" | jq -r .value)&lt;/span&gt;
          &lt;span class="s"&gt;payload=$(echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+')&lt;/span&gt;
          &lt;span class="s"&gt;while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="${payload}="; done&lt;/span&gt;
          &lt;span class="s"&gt;echo "$payload" | base64 -d | jq '{iss, aud, sub, repository}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the printed &lt;code&gt;sub&lt;/code&gt; with &lt;code&gt;terraform output allowed_subject&lt;/code&gt;, character for character. That's how I found my own bug: my token showed &lt;code&gt;repo:USER@191673596/REPO@1400242638:...&lt;/code&gt; while my trust policy expected the old format. Adding the IDs fixed it instantly.&lt;/p&gt;

&lt;p&gt;Other quick checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Case:&lt;/strong&gt; usernames and repo names are case-sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch:&lt;/strong&gt; a manual run uses whichever branch you picked in the dropdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Credentials could not be loaded&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;role-to-assume&lt;/code&gt; was empty. Use &lt;code&gt;secrets.&lt;/code&gt; for secrets and &lt;code&gt;vars.&lt;/code&gt; for variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Assuming role with OIDC&lt;/code&gt; repeated:&lt;/strong&gt; the action is retrying a rejected call. The real error is on the line after.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this is secure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signed token:&lt;/strong&gt; AWS verifies the signature, so it can't be forged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience check:&lt;/strong&gt; tokens meant for other services are rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact &lt;code&gt;sub&lt;/code&gt; match:&lt;/strong&gt; only your repo and branch get in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutable IDs:&lt;/strong&gt; a recycled name can't impersonate you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-lived credentials:&lt;/strong&gt; they expire on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing stored:&lt;/strong&gt; nothing to steal from settings, logs, or forks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few habits make it stronger. Never use wildcards like &lt;code&gt;repo:OWNER/*&lt;/code&gt; in the &lt;code&gt;sub&lt;/code&gt;. Use one role per purpose. Protect production with a GitHub Environment and required reviewers. And remember that every new repo needs its own IDs in its trust policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Static keys in GitHub Secrets were always a compromise. With OIDC your pipeline gets credentials only when it runs, only for the repo and branch you chose, and they vanish on their own. Just watch the new subject format, because older tutorials won't match what your new repos send.&lt;/p&gt;

&lt;p&gt;If you're still pasting access keys into secrets, try this on one repo this week. You'll wonder why you waited.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: GitHub Docs (OIDC reference, "Immutable subject claims") and the GitHub Changelog announcement on immutable subject claims.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>githubactions</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Upgrade EKS Nodes Without Downtime: The Bridge Add-on Version Trick</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Mon, 28 Sep 2026 11:01:27 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/upgrade-eks-nodes-without-downtime-the-bridge-add-on-version-trick-2f84</link>
      <guid>https://dev.to/ajinkya_a3/upgrade-eks-nodes-without-downtime-the-bridge-add-on-version-trick-2f84</guid>
      <description>&lt;p&gt;&lt;em&gt;Move your add-ons to versions that work on both Kubernetes releases, and the node upgrade stops being the scary part.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with "nodes first, add-ons after"
&lt;/h2&gt;

&lt;p&gt;The AWS upgrade guide gives the order as: &lt;strong&gt;control plane → nodes → add-ons.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That works on paper, but it may leaves a gap. While your nodes roll from 1.35 to 1.36, you are running &lt;strong&gt;new nodes with old add-on versions&lt;/strong&gt;, and those versions were only validated against the old Kubernetes version. The add-ons that hurt most are the ones every pod depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vpc-cni&lt;/strong&gt; hands out pod IPs on every node&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CoreDNS&lt;/strong&gt; resolves names for every workload&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;aws-ebs-csi-driver&lt;/strong&gt; attaches volumes for stateful pods&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of them misbehaves on a freshly rolled node, you find out in the middle of the rollout, with pods rescheduling all around you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: bridge versions
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;bridge version&lt;/strong&gt; is the newest add-on version that is supported on &lt;strong&gt;both&lt;/strong&gt; your current and your target Kubernetes version.&lt;/p&gt;

&lt;p&gt;If every add-on is on its bridge version &lt;em&gt;before&lt;/em&gt; the nodes roll:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Old nodes (1.35) run an add-on version that supports 1.35.&lt;/li&gt;
&lt;li&gt;New nodes (1.36) run the &lt;strong&gt;same&lt;/strong&gt; add-on version, which also supports 1.36.&lt;/li&gt;
&lt;li&gt;No node ever runs an add-on that wasn't validated for its Kubernetes version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because a bridge version is valid on both releases, &lt;strong&gt;you can apply it at any point before the node upgrade&lt;/strong&gt;: at the very start, on 1.35, or right after the control plane moves to 1.36. Both work.&lt;/p&gt;

&lt;p&gt;The order I use:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EKS control plane → bridge add-ons → nodes → (optional) default add-on versions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This doesn't contradict the AWS order. It adds one safe step in front of the node roll.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Find the bridge version for each add-on
&lt;/h2&gt;

&lt;p&gt;EKS can list every version of an add-on for a given Kubernetes version. The bridge is the newest version that appears in both lists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;

&lt;span class="nv"&gt;OLD_K8S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1.35"&lt;/span&gt;
&lt;span class="nv"&gt;NEW_K8S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1.36"&lt;/span&gt;

get_versions&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  aws eks describe-addon-versions &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--addon-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--kubernetes-version&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'addons[0].addonVersions[].addonVersion'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--output&lt;/span&gt; text 2&amp;gt;/dev/null | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-V&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;addon &lt;span class="k"&gt;in &lt;/span&gt;vpc-cni coredns kube-proxy aws-ebs-csi-driver metrics-server&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;old_versions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_versions &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$addon&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD_K8S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;new_versions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_versions &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$addon&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW_K8S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"===== &lt;/span&gt;&lt;span class="nv"&gt;$addon&lt;/span&gt;&lt;span class="s2"&gt; ====="&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old_versions&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_versions&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: no versions returned - check addon name / region"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    &lt;/span&gt;&lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nv"&gt;old_latest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old_versions&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;new_latest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_versions&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nv"&gt;common_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Fxf&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old_versions&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_versions&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-V&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD_K8S&lt;/span&gt;&lt;span class="s2"&gt; latest : &lt;/span&gt;&lt;span class="nv"&gt;$old_latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW_K8S&lt;/span&gt;&lt;span class="s2"&gt; latest : &lt;/span&gt;&lt;span class="nv"&gt;$new_latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$common_version&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No overlapping version found - you may need an intermediate hop"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Bridge version (newest common to both): &lt;/span&gt;&lt;span class="nv"&gt;$common_version&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Output from my cluster (1.35 → 1.36)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== vpc-cni =====
1.35 latest : v1.23.1-eksbuild.1
1.36 latest : v1.23.1-eksbuild.1
Bridge version (newest common to both): v1.23.1-eksbuild.1

===== coredns =====
1.35 latest : v1.14.6-eksbuild.4
1.36 latest : v1.14.6-eksbuild.4
Bridge version (newest common to both): v1.14.6-eksbuild.4

===== kube-proxy =====
1.35 latest : v1.35.3-eksbuild.29
1.36 latest : v1.36.0-eksbuild.25
Bridge version (newest common to both): v1.35.3-eksbuild.29

===== aws-ebs-csi-driver =====
1.35 latest : v1.66.0-eksbuild.1
1.36 latest : v1.66.0-eksbuild.1
Bridge version (newest common to both): v1.66.0-eksbuild.1

===== metrics-server =====
1.35 latest : v0.9.0-eksbuild.11
1.36 latest : v0.9.0-eksbuild.11
Bridge version (newest common to both): v0.9.0-eksbuild.11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How to read it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;vpc-cni, coredns, aws-ebs-csi-driver and metrics-server&lt;/strong&gt;, the latest version is identical on 1.35 and 1.36, so the bridge is simply the latest build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kube-proxy is the exception.&lt;/strong&gt; Its version tracks the Kubernetes minor, so the 1.36 latest (&lt;code&gt;v1.36.0-eksbuild.25&lt;/code&gt;) is not available on 1.35, and the bridge is a &lt;code&gt;1.35.x&lt;/code&gt; build (&lt;code&gt;v1.35.3-eksbuild.29&lt;/code&gt;). Keep it there until the nodes are on 1.36.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the Terraform is wired
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The EKS cluster (control plane)
&lt;/h3&gt;

&lt;p&gt;The control plane version comes from a single variable:&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_eks_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"eks"&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="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&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;eks_cluster&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;version&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;cluster_version&lt;/span&gt;

  &lt;span class="nx"&gt;vpc_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Pass both public and private subnets so the EKS control plane&lt;/span&gt;
    &lt;span class="c1"&gt;# ENIs are spread across AZs inside the VPC.&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_ids&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public&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;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&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;security_group_ids&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_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks_cluster&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;endpoint_public_access&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# keeps kubectl working from your machine&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint_private_access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# nodes in private subnets reach the API internally&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;access_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;authentication_mode&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"API"&lt;/span&gt;
    &lt;span class="nx"&gt;bootstrap_cluster_creator_admin_permissions&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="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;merge&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;tags&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&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;aws_iam_role_policy_attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks_cluster_policy&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;What matters for the upgrade:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;version = var.cluster_version&lt;/code&gt;&lt;/strong&gt; is the only line you change. Going from &lt;code&gt;"1.35"&lt;/code&gt; to &lt;code&gt;"1.36"&lt;/code&gt; makes Terraform update the control plane in place. EKS upgrades one minor version at a time and there is no rollback, so this is the step to plan carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vpc_config&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;access_config&lt;/code&gt;&lt;/strong&gt; stay the same during an upgrade. The public and private endpoints keep &lt;code&gt;kubectl&lt;/code&gt; and the nodes talking to the API, and &lt;code&gt;authentication_mode = "API"&lt;/code&gt; keeps your access entries working.&lt;/li&gt;
&lt;li&gt;The nodes and add-ons don't change when this resource changes. That is why the control plane can be upgraded on its own with &lt;code&gt;-target&lt;/code&gt;, as Step 1 shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The node group
&lt;/h3&gt;

&lt;p&gt;The managed node group reads the cluster version and rolls when it changes:&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_eks_node_group"&lt;/span&gt; &lt;span class="s2"&gt;"ondemand-node"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_eks_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;node_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;node&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;subnet_ids&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&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;capacity_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ON_DEMAND"&lt;/span&gt;
  &lt;span class="nx"&gt;ami_type&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;ami_type&lt;/span&gt;

  &lt;span class="nx"&gt;version&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;cluster_version&lt;/span&gt;  &lt;span class="c1"&gt;# ties node AMI to control plane version&lt;/span&gt;
  &lt;span class="nx"&gt;force_update_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                 &lt;span class="c1"&gt;# forces rolling update when version changes&lt;/span&gt;

  &lt;span class="nx"&gt;update_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;max_unavailable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;# only 1 of nodes can be unavailable at once&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;What each line does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;version = var.cluster_version&lt;/code&gt;&lt;/strong&gt; sets the Kubernetes version of the nodes. With a managed node group and an &lt;code&gt;ami_type&lt;/code&gt;, EKS picks the matching EKS-optimized AMI for that version. Change the variable and the node group rolls onto the new AMI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;force_update_version = true&lt;/code&gt;&lt;/strong&gt; lets the update go through even when pods can't be drained because of a PodDisruptionBudget. Without it, a blocking PDB makes the update fail. With it, EKS eventually proceeds anyway, so pods can be evicted despite the PDB. That keeps upgrades from getting stuck, but it is also the one setting that can cost you availability, so make sure critical workloads have enough replicas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_unavailable = 1&lt;/code&gt;&lt;/strong&gt; rolls one node at a time, so the rest of the group keeps serving traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The add-ons
&lt;/h3&gt;

&lt;p&gt;Add-on versions come from a list variable, so bumping them is a data change:&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_eks_addon"&lt;/span&gt; &lt;span class="s2"&gt;"eks-addons"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addon&lt;/span&gt; &lt;span class="nx"&gt;in&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;addons&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;addon&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_eks_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;addon_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&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;name&lt;/span&gt;
  &lt;span class="nx"&gt;addon_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&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;version&lt;/span&gt;

  &lt;span class="c1"&gt;# attach role ONLY for EBS CSI&lt;/span&gt;
  &lt;span class="nx"&gt;service_account_role_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&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;name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"aws-ebs-csi-driver"&lt;/span&gt; &lt;span class="err"&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;ebs_csi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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_eks_node_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ondemand-node&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;ebs_csi_policy&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;
  
  
  Gotcha: &lt;code&gt;-target&lt;/code&gt; pulls in dependencies
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;-target&lt;/code&gt; applies the resource you name &lt;strong&gt;plus everything it depends on&lt;/strong&gt;. The add-on resource above has &lt;code&gt;depends_on = [aws_eks_node_group.ondemand-node]&lt;/code&gt;. If the node group shares &lt;code&gt;var.cluster_version&lt;/code&gt; with the control plane, then after you bump that variable, &lt;code&gt;terraform apply -target=aws_eks_addon.eks-addons&lt;/code&gt; drags the node group in and rolls your nodes &lt;strong&gt;before&lt;/strong&gt; the bridge add-ons land. That defeats the whole point.&lt;/p&gt;

&lt;p&gt;The fix is a separate variable for the node group, so you can hold nodes on 1.35 while you upgrade the control plane and add-ons, then move them last (nodes are allowed to run behind the control plane):&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;version&lt;/span&gt;              &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_group_version&lt;/span&gt;  &lt;span class="c1"&gt;# was var.cluster_version&lt;/span&gt;
  &lt;span class="nx"&gt;force_update_version&lt;/span&gt; &lt;span class="err"&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;The rest of this post assumes that split.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upgrade, step by step with &lt;code&gt;-target&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Before each step, preview it with &lt;code&gt;terraform plan&lt;/code&gt; using the same &lt;code&gt;-target&lt;/code&gt; flags and check that only the resources you expect will change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Upgrade the EKS control plane
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;cluster_version = "1.36"&lt;/code&gt; and leave &lt;code&gt;node_group_version = "1.35"&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;terraform plan  &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_cluster.eks
terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_cluster.eks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EKS only allows one minor version at a time and there is no rollback, so run your pre-checks first: deprecated APIs, PodDisruptionBudgets, and a supported Karpenter version for 1.36 if you use it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Apply the bridge add-on versions
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;var.addons&lt;/code&gt; to the bridge versions from Step 0:&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;addons&lt;/span&gt; &lt;span class="err"&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc-cni"&lt;/span&gt;&lt;span class="p"&gt;,&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;"v1.23.1-eksbuild.1"&lt;/span&gt; &lt;span class="p"&gt;},&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;"coredns"&lt;/span&gt;&lt;span class="p"&gt;,&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;"v1.14.6-eksbuild.4"&lt;/span&gt; &lt;span class="p"&gt;},&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;"kube-proxy"&lt;/span&gt;&lt;span class="p"&gt;,&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;"v1.35.3-eksbuild.29"&lt;/span&gt; &lt;span class="p"&gt;},&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;"aws-ebs-csi-driver"&lt;/span&gt;&lt;span class="p"&gt;,&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;"v1.66.0-eksbuild.1"&lt;/span&gt; &lt;span class="p"&gt;},&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;"metrics-server"&lt;/span&gt;&lt;span class="p"&gt;,&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;"v0.9.0-eksbuild.11"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan  &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_addon.eks-addons
terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_addon.eks-addons
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also target a single add-on. Because of &lt;code&gt;for_each&lt;/code&gt;, the address includes the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'aws_eks_addon.eks-addons["1"]'&lt;/span&gt;   &lt;span class="c"&gt;# just coredns, for example&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check health before moving on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You don't have to wait for Step 1.&lt;/strong&gt; Since bridge versions work on both releases, you can run this step first, on the old 1.35 control plane, and then upgrade the control plane. Steps 1 and 2 are interchangeable. The only rule is that the bridge add-ons land &lt;strong&gt;before the nodes&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Roll the nodes
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;node_group_version = "1.36"&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;terraform plan  &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_node_group.ondemand-node
terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_node_group.ondemand-node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node group rolls one node at a time. Every new node comes up on 1.36 running add-on versions that already support 1.36.&lt;/p&gt;

&lt;p&gt;If you run Karpenter, its nodes are outside the managed node group. Check that your Karpenter release supports 1.36 before Step 1, then let drift replacement roll them onto the new AMI while respecting PDBs and disruption budgets. Watch progress with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 (optional): Move to the default 1.36 add-on versions
&lt;/h3&gt;

&lt;p&gt;Once all nodes are on 1.36, you can move add-ons to the versions EKS marks as &lt;strong&gt;default&lt;/strong&gt; for 1.36. This command shows them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;K8S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1.36"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;addon &lt;span class="k"&gt;in &lt;/span&gt;vpc-cni coredns kube-proxy aws-ebs-csi-driver metrics-server&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"===== &lt;/span&gt;&lt;span class="nv"&gt;$addon&lt;/span&gt;&lt;span class="s2"&gt; ====="&lt;/span&gt;

  aws eks describe-addon-versions &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--addon-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$addon&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--kubernetes-version&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$K8S_VERSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'addons[0].addonVersions[?compatibilities[?defaultVersion==`true`]].{Version:addonVersion,Default:compatibilities[?defaultVersion==`true`].defaultVersion}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--output&lt;/span&gt; table

  &lt;span class="nb"&gt;echo
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--query&lt;/code&gt; keeps only the versions whose compatibility entry for 1.36 has &lt;code&gt;defaultVersion&lt;/code&gt; set to &lt;code&gt;true&lt;/code&gt;, and prints them as a table. The default is the version EKS would install if you created the add-on fresh on a 1.36 cluster. It can differ from the newest build, so compare it with the latest list from Step 0 before choosing.&lt;/p&gt;

&lt;p&gt;Put the versions you pick into &lt;code&gt;var.addons&lt;/code&gt; and apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eks_addon.eks-addons
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step is optional. A bridge version is already supported on 1.36, so you can stay on it and upgrade add-ons later on your own schedule. The one add-on to move promptly is &lt;strong&gt;kube-proxy&lt;/strong&gt;, so it matches the node version again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: A final plain apply
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;-target&lt;/code&gt; is meant for exceptional cases, and it can leave the rest of your state unreconciled. Finish with a normal run and confirm it reports nothing unexpected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The order at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Find bridge versions&lt;/td&gt;
&lt;td&gt;nothing changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Upgrade control plane (&lt;code&gt;-target=aws_eks_cluster.eks&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;cluster 1.36, nodes 1.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Apply bridge add-ons (&lt;code&gt;-target=aws_eks_addon.eks-addons&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;add-ons valid on 1.35 and 1.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Roll nodes (&lt;code&gt;-target=aws_eks_node_group.ondemand-node&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;nodes 1.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Optional: default 1.36 add-on versions&lt;/td&gt;
&lt;td&gt;add-ons on EKS defaults for 1.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Plain &lt;code&gt;terraform apply&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;state fully reconciled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Steps 1 and 2 are interchangeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A bridge version reduces upgrade risk. It does not remove all of it. You still need multiple replicas for critical workloads, sensible PodDisruptionBudgets, and a check for deprecated APIs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;force_update_version = true&lt;/code&gt; can override a PDB during the node roll. It avoids stuck upgrades, but it also means the PDB is not an absolute guarantee.&lt;/li&gt;
&lt;li&gt;If the script shows &lt;strong&gt;no overlapping version&lt;/strong&gt; for an add-on, you'll need an intermediate hop for that one.&lt;/li&gt;
&lt;li&gt;Read the release notes for each add-on. A version being listed as compatible is not a guarantee that nothing changed.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;The AWS order (control plane, nodes, add-ons) leaves a window where new nodes run old add-ons.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;bridge version&lt;/strong&gt; closes that window because it works on both Kubernetes versions, so it can go in before or after the control plane upgrade, as long as it lands before the nodes.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;-target&lt;/code&gt; to control the order, but watch &lt;code&gt;depends_on&lt;/code&gt;: give the node group its own version variable, and finish with a plain &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building Elastic GitHub Actions Runners on EKS with ARC &amp; Karpenter</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:38:47 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/the-anatomy-of-elastic-ci-github-actions-arc-eks-karpenter-4608</link>
      <guid>https://dev.to/ajinkya_a3/the-anatomy-of-elastic-ci-github-actions-arc-eks-karpenter-4608</guid>
      <description>&lt;p&gt;&lt;strong&gt;Zero idle CI runners. That's the point.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No jobs = zero runners = zero wasted spend.&lt;br&gt;
Traffic shows up. Infrastructure follows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The flow
&lt;/h2&gt;

&lt;p&gt;Workflow triggers → ARC creates a runner pod → pod lands on a Karpenter NodePool → if capacity is missing, it goes &lt;code&gt;Pending&lt;/code&gt; → Karpenter provisions exactly what's needed → job runs → pod disappears → node consolidates.&lt;/p&gt;

&lt;p&gt;No standing fleet. No idle EC2 between builds. No capacity planning spreadsheet. Just compute that exists for exactly the minutes a job needs it.&lt;/p&gt;


&lt;h2&gt;
  
  
  What makes the cost math real
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Guaranteed QoS
&lt;/h3&gt;

&lt;p&gt;CPU requests = limits. Memory requests = limits.&lt;/p&gt;

&lt;p&gt;This gives Karpenter a clean bin-packing target instead of over-allocating — Karpenter sizes the node off what you &lt;em&gt;ask&lt;/em&gt; for, so a sloppy request means a bigger, pricier instance than the job actually needs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Storage reclaim policy matters
&lt;/h3&gt;

&lt;p&gt;Reclaim policy should be &lt;code&gt;Delete&lt;/code&gt;, not &lt;code&gt;Retain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;Retain&lt;/code&gt;, per-job PVCs and their backing EBS volumes can remain after ephemeral runners disappear. The runners scale to zero. The storage bill doesn't.&lt;/p&gt;

&lt;p&gt;This is the single easiest detail to overlook when evaluating the true cost of an ephemeral CI architecture — check it before you trust the "zero idle spend" pitch.&lt;/p&gt;
&lt;h3&gt;
  
  
  Spot for most jobs, On-Demand for critical ones
&lt;/h3&gt;

&lt;p&gt;It doesn't have to be a cluster-wide decision. Use different Karpenter NodePools and target the appropriate runner/workload based on the job.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spot → cost optimization&lt;/li&gt;
&lt;li&gt;On-Demand → workloads that can't tolerate interruption&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same elasticity. Different reliability and cost trade-off — decided per job, not once for the whole cluster.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cold starts are the trade-off
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;minRunners: 0&lt;/code&gt;, there are no warm runners waiting for jobs. The first job after an idle period needs to wait for:&lt;/p&gt;

&lt;p&gt;Pod scheduling → Karpenter provisioning → EC2 startup → runner initialization.&lt;/p&gt;

&lt;p&gt;And in &lt;code&gt;kubernetes&lt;/code&gt; mode, a PVC may also need to be provisioned before the job even starts.&lt;/p&gt;

&lt;p&gt;You're trading idle compute cost for startup latency. Fine for most CI workloads. Not ideal when you need sub-minute pipeline startup times.&lt;/p&gt;


&lt;h2&gt;
  
  
  Not a drop-in swap for &lt;code&gt;ubuntu-latest&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The container mode you choose determines how much your existing workflow needs to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dind&lt;/code&gt; mode&lt;/strong&gt; — The runner gets a real Docker daemon sidecar. Most existing workflows can remain largely unchanged. Trade-off: a privileged pod, plus additional startup overhead on every job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubernetes&lt;/code&gt; mode&lt;/strong&gt; — Every job/action runs as its own unprivileged Kubernetes pod. More isolation, but also more constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No Docker daemon anywhere&lt;/li&gt;
&lt;li&gt;Every job needs an explicit &lt;code&gt;container:&lt;/code&gt; configuration&lt;/li&gt;
&lt;li&gt;Image builds and pushes require daemonless tooling&lt;/li&gt;
&lt;li&gt;Bash-specific steps can break if the container's default shell isn't Bash&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Why this architecture works
&lt;/h2&gt;

&lt;p&gt;Each layer has one responsibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ARC&lt;/strong&gt; → Runner lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes&lt;/strong&gt; → Scheduling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Karpenter&lt;/strong&gt; → Provisioning + consolidation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot / On-Demand&lt;/strong&gt; → Cost vs. reliability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack them together and the infrastructure behaves almost like a function:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input → Compute appears → Work executes → Compute disappears&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  How to actually build it
&lt;/h2&gt;

&lt;p&gt;The architecture above isn't theoretical — here's the exact install path, end to end, for a Karpenter-backed, spot-first, &lt;code&gt;kubernetes&lt;/code&gt;-mode runner scale set on EKS.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Namespaces + controller
&lt;/h3&gt;

&lt;p&gt;One controller per cluster, installed once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace arc-systems
kubectl create namespace arc-runners

helm &lt;span class="nb"&gt;install &lt;/span&gt;arc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; arc-systems &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-systems
&lt;span class="c"&gt;# expect: arc-gha-runner-scale-set-controller-xxxx   1/1   Running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. A PAT scoped to exactly one repo
&lt;/h3&gt;

&lt;p&gt;Fine-grained token → &lt;strong&gt;Repository access: Only select repositories&lt;/strong&gt; → the one target repo, nothing org-wide:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Access level&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Actions&lt;/td&gt;
&lt;td&gt;Read and write&lt;/td&gt;
&lt;td&gt;Register/deregister runners, read the job queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Administration&lt;/td&gt;
&lt;td&gt;Read and write&lt;/td&gt;
&lt;td&gt;Create/remove self-hosted runners on the repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;td&gt;Required baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic eks-spot-amd-gh-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; arc-runners &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;github_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;YOUR_PAT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The values file — where the cost math actually lives
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# values-eks-spot-amd.yaml&lt;/span&gt;
&lt;span class="na"&gt;githubConfigUrl&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://github.com/&amp;lt;your-username&amp;gt;/&amp;lt;your-repo&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;githubConfigSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd-gh-secret&lt;/span&gt;
&lt;span class="na"&gt;runnerScaleSetName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eks-spot-amd"&lt;/span&gt;

&lt;span class="na"&gt;minRunners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;     &lt;span class="c1"&gt;# zero idle runners — this is the whole point&lt;/span&gt;
&lt;span class="na"&gt;maxRunners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;     &lt;span class="c1"&gt;# keep tight for a single repo/workflow&lt;/span&gt;

&lt;span class="na"&gt;containerMode&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kubernetes"&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesModeWorkVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ReadWriteOnce&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gp3&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5Gi&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;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fsGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1001&lt;/span&gt;
    &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot-amd64&lt;/span&gt;
      &lt;span class="na"&gt;capacity-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot&lt;/span&gt;
      &lt;span class="na"&gt;arch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amd64&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;runner&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/actions/actions-runner:latest&lt;/span&gt;
        &lt;span class="na"&gt;command&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;/home/runner/run.sh"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4Gi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# requests == limits → Guaranteed QoS&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4Gi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requests == limits&lt;/code&gt; is not stylistic — it's the line that actually produces Guaranteed QoS and gives Karpenter a clean bin-packing target instead of an over-provisioned guess.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;eks-spot-amd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; arc-runners &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; values-eks-spot-amd.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners
&lt;span class="c"&gt;# expect: eks-spot-amd-xxxx-listener   1/1   Running   (idle, long-poll)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Confirm the reclaim policy before you trust the cost story
&lt;/h3&gt;

&lt;p&gt;This is the one-line check that separates "zero idle spend" from "zero idle spend, plus a slowly growing EBS bill nobody notices":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get storageclass gp3 &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.reclaimPolicy}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;Retain&lt;/code&gt; instead of &lt;code&gt;Delete&lt;/code&gt;, fix it before going further — otherwise every ephemeral job leaves a volume behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Route jobs to Spot or On-Demand per NodePool
&lt;/h3&gt;

&lt;p&gt;Rather than a cluster-wide choice, tag NodePools separately and target them with &lt;code&gt;nodeSelector&lt;/code&gt; per runner scale set — Spot for the bulk of jobs, a second On-Demand scale set for anything interruption-sensitive (release builds, deploy gates). Same controller, same chart, two values files, two &lt;code&gt;nodeSelector&lt;/code&gt; blocks.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Adjust the workflow for your container mode
&lt;/h3&gt;

&lt;p&gt;If you're on &lt;code&gt;kubernetes&lt;/code&gt; mode, every job needs an explicit &lt;code&gt;container:&lt;/code&gt;, and anything touching Docker (builds, registry logins, local-scan patterns) needs daemonless tooling instead — no &lt;code&gt;/var/run/docker.sock&lt;/code&gt; exists anywhere in this mode by design.&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd&lt;/span&gt;
    &lt;span class="na"&gt;container&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;ghcr.io/catthehacker/ubuntu:act-22.04&lt;/span&gt;   &lt;span class="c1"&gt;# glibc-based — musl breaks JS actions&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;   &lt;span class="c1"&gt;# confirm the image's default shell isn't /bin/sh&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If most of your existing workflows assume a live daemon and rewriting them isn't practical yet, start on &lt;code&gt;dind&lt;/code&gt; mode instead — same cost model, none of the container-mode constraints, at the cost of a privileged pod.&lt;/p&gt;

&lt;p&gt;You also don't have to pick one mode for the whole cluster. Run two runner scale sets side by side — one on &lt;code&gt;kubernetes&lt;/code&gt; mode for the bulk of your jobs, one on &lt;code&gt;dind&lt;/code&gt; mode scoped to just the jobs that genuinely need a Docker daemon (image builds, registry pushes, container actions). Same controller, same chart, two &lt;code&gt;values-*.yaml&lt;/code&gt; files, two &lt;code&gt;runnerScaleSetName&lt;/code&gt;s. Point the daemon-dependent jobs at the &lt;code&gt;dind&lt;/code&gt; scale set's &lt;code&gt;runs-on:&lt;/code&gt; and leave everything else on &lt;code&gt;kubernetes&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd&lt;/span&gt;            &lt;span class="c1"&gt;# kubernetes mode — no daemon needed&lt;/span&gt;
    &lt;span class="na"&gt;container&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;ghcr.io/catthehacker/ubuntu:act-22.04&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make test&lt;/span&gt;

  &lt;span class="na"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd-dind&lt;/span&gt;       &lt;span class="c1"&gt;# dind mode — needs a real daemon&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;docker/build-push-action@v6&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;push&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;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry/image:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the smaller, unprivileged blast radius everywhere it's free to have it, and you contain the privileged trade-off to exactly the jobs that can't avoid it — instead of either rewriting your entire Docker-based pipeline around daemonless tooling, or running every job privileged just because one of them needs to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Validate the full elastic loop
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-w&lt;/span&gt;                                           &lt;span class="c"&gt;# ephemeral pod appears on trigger&lt;/span&gt;
kubectl get pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.nodeName}'&lt;/span&gt;           &lt;span class="c"&gt;# landed on the intended NodePool?&lt;/span&gt;
kubectl get node &amp;lt;node&amp;gt; &lt;span class="nt"&gt;--show-labels&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'capacity-type=[a-z]*'&lt;/span&gt;        &lt;span class="c"&gt;# spot or on-demand, as intended?&lt;/span&gt;
kubectl get pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.qosClass}'&lt;/span&gt;         &lt;span class="c"&gt;# actually Guaranteed?&lt;/span&gt;
kubectl get pvc &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners                                                &lt;span class="c"&gt;# gone after the job completes?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trigger a real workflow and watch the whole function execute: &lt;strong&gt;Input → Compute appears → Work executes → Compute disappears.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the PVC and node are both gone a few minutes after the job finishes, the cost math in this post is real for your cluster, not just in theory.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running ARC on EKS, I'd love to hear what NodePool split or container mode you landed on — drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>devops</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Enabling Karpenter on EKS with Terraform: What It Is, Why It's Worth It, and How to Set It Up</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Sun, 06 Sep 2026 05:51:35 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/enabling-karpenter-on-eks-with-terraform-what-it-is-why-its-worth-it-and-how-to-set-it-up-5aj8</link>
      <guid>https://dev.to/ajinkya_a3/enabling-karpenter-on-eks-with-terraform-what-it-is-why-its-worth-it-and-how-to-set-it-up-5aj8</guid>
      <description>&lt;p&gt;Karpenter replaces the old "guess an instance type, set a min/max, wait for Cluster Autoscaler" model with something simpler: watch for pods that can't schedule, and launch exactly the EC2 capacity they need. This guide covers how it actually works, why teams adopt it, and the Terraform you need to wire up to run it on EKS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you begin, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A running Amazon EKS cluster (created through either Terraform, eksctl, or the console)&lt;/li&gt;
&lt;li&gt;Terraform (v1.11+ recommended)&lt;/li&gt;
&lt;li&gt;An existing VPC and subnets&lt;/li&gt;
&lt;li&gt;Existing security groups&lt;/li&gt;
&lt;li&gt;Helm (v3+)&lt;/li&gt;
&lt;li&gt;kubectl configured to access your EKS cluster&lt;/li&gt;
&lt;li&gt;AWS CLI with credentials configured&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Karpenter Actually Works
&lt;/h2&gt;

&lt;p&gt;Traditional autoscaling works in two disconnected layers: the Kubernetes scheduler decides where pods &lt;em&gt;should&lt;/em&gt; go, and a separate Cluster Autoscaler watches Auto Scaling Groups and adds nodes when they're full. The ASG only knows one instance type per group, so it's constantly a step behind what the scheduler actually needs.&lt;/p&gt;

&lt;p&gt;Karpenter collapses those two layers into one loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A pod goes &lt;strong&gt;Pending&lt;/strong&gt; because nothing can schedule it.&lt;/li&gt;
&lt;li&gt;Karpenter looks at that pod's actual requirements CPU, memory, architecture, zone, GPU, whatever's in its spec not a pre-defined group.&lt;/li&gt;
&lt;li&gt;It picks the cheapest EC2 instance type across the &lt;em&gt;entire&lt;/em&gt; fleet that satisfies those requirements, and calls the EC2 Fleet API directly to launch it.&lt;/li&gt;
&lt;li&gt;The node joins the cluster, the pod schedules, done typically in under a minute.&lt;/li&gt;
&lt;li&gt;On the way back down, Karpenter continuously watches for nodes that are empty or underutilized and consolidates workloads to shut them down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no ASG in this loop at all. Karpenter talks to EC2 directly, which is what makes it fast and lets it pick from hundreds of instance types instead of the two or three you'd hand-pick for a node group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Beneficial
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Right-sized capacity, every time.&lt;/strong&gt; Nodes are chosen per-pod's actual needs instead of forcing every workload into whatever instance type a node group happened to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster scaling.&lt;/strong&gt; Direct EC2 Fleet calls skip the ASG polling loop, so new capacity shows up in seconds to a couple of minutes instead of several minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less to manage.&lt;/strong&gt; One &lt;code&gt;NodePool&lt;/code&gt; and &lt;code&gt;EC2NodeClass&lt;/code&gt; replace a sprawl of hand-tuned node groups, one per instance-type-and-AZ combination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safer use of Spot.&lt;/strong&gt; Built-in interruption handling means Spot stops being "risky" and becomes just cheaper capacity with a warning light.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing infrastructure.&lt;/strong&gt; Nodes expire and get replaced automatically, so patching and drift correction happen on a schedule instead of a ticket.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the Cost-Effective Strategies Are Implemented
&lt;/h2&gt;

&lt;p&gt;Karpenter's cost savings aren't a side effect they come from specific, configurable behaviors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spot-first, mixed with On-Demand.&lt;/strong&gt; The &lt;code&gt;NodePool&lt;/code&gt; allows both capacity types, and Karpenter defaults to the cheapest option that satisfies the pod's constraints usually Spot, at up to 90% off On-Demand pricing:&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;requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;karpenter.sh/capacity-type&lt;/span&gt;
    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on-demand"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Consolidation.&lt;/strong&gt; Karpenter continuously re-evaluates the cluster. If pods on an underused node could be bin-packed onto existing capacity, it moves them and terminates the now-empty node:&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;disruption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;consolidationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WhenEmptyOrUnderutilized&lt;/span&gt;
  &lt;span class="na"&gt;consolidateAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Broad instance selection.&lt;/strong&gt; By allowing whole instance &lt;em&gt;categories&lt;/em&gt; (&lt;code&gt;c&lt;/code&gt;, &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;) instead of a single type, Karpenter can pick whichever specific instance is cheapest and most available in that family at that moment rather than you guessing one in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled node expiry.&lt;/strong&gt; Every node is replaced after a fixed lifetime, which doubles as automatic AMI patching no cost from stale, oversized, or drifted long-lived nodes:&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;expireAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Graceful Spot interruption handling.&lt;/strong&gt; Losing a Spot instance mid-job is what makes teams avoid Spot in the first place. Karpenter listens for the 2-minute interruption warning via SQS and EventBridge, drains the node in time, and replaces it so you get the discount without the risk of a workload dying uncleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. IAM Roles
&lt;/h3&gt;

&lt;p&gt;Karpenter needs two separate roles because two separate things assume them: the controller pod, and the EC2 instances it creates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controller role&lt;/strong&gt;: trusted by your OIDC provider (IRSA), scoped to the &lt;code&gt;karpenter&lt;/code&gt; service account:&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_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_controller"&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;"KarpenterControllerRole-${var.cluster_name}"&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;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&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;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&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;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Federated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&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="nx"&gt;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"${local.oidc_issuer}:sub"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"system:serviceaccount:karpenter:karpenter"&lt;/span&gt;
          &lt;span class="s2"&gt;"${local.oidc_issuer}:aud"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts.amazonaws.com"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node role&lt;/strong&gt;: trusted by EC2, attached to every node Karpenter launches:&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_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_node"&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;"KarpenterNodeRole-${var.cluster_name}"&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;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&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;"2012-10-17"&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="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="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2.amazonaws.com"&lt;/span&gt; &lt;span class="p"&gt;},&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;"sts:AssumeRole"&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&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;var&lt;/span&gt;&lt;span class="p"&gt;.&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"node_policies"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPullOnly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"&lt;/span&gt;&lt;span class="p"&gt;,&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;karpenter_node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Controller Policy
&lt;/h3&gt;

&lt;p&gt;The controller role above only has a trust policy so far it says &lt;em&gt;who&lt;/em&gt; can assume it, not &lt;em&gt;what it's allowed to do&lt;/em&gt;. That comes from a separate inline policy granting &lt;code&gt;ec2:RunInstances&lt;/code&gt;, &lt;code&gt;ec2:CreateFleet&lt;/code&gt;, &lt;code&gt;ec2:TerminateInstances&lt;/code&gt;, instance-profile management, and &lt;code&gt;iam:PassRole&lt;/code&gt; on the node role.&lt;/p&gt;

&lt;p&gt;Don't hand-write this from scratch Karpenter's maintainers publish and maintain the canonical version as part of the official Getting Started CloudFormation template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official reference:&lt;/strong&gt; &lt;a href="https://karpenter.sh/docs/reference/cloudformation/" rel="noopener noreferrer"&gt;karpenter.sh/docs/reference/cloudformation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw policy source:&lt;/strong&gt; &lt;code&gt;https://raw.githubusercontent.com/aws/karpenter-provider-aws/v${KARPENTER_VERSION}/website/content/en/preview/getting-started/getting-started-with-karpenter/cloudformation.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pull the &lt;code&gt;KarpenterControllerPolicy&lt;/code&gt; statements out of that template into a local JSON file, templatize the account-specific values, and render it with &lt;code&gt;templatefile()&lt;/code&gt;:&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_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_controller_policy"&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;"KarpenterControllerPolicy-${var.cluster_name}"&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;karpenter_controller&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;templatefile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/policies/karpenter-controller-policy.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cluster_name&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;cluster_name&lt;/span&gt;
    &lt;span class="nx"&gt;region&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;region&lt;/span&gt;
    &lt;span class="nx"&gt;karpenter_node_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;karpenter_node&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;eks_cluster_arn&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_eks_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&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;sqs_queue_arn&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_interruption&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Templating it this way means the policy JSON stays identical to the upstream version — only the account, region, cluster, role, and queue ARNs are substituted per environment so pulling in a future upstream policy update is a diff, not a rewrite.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Let the Nodes Actually Join the Cluster
&lt;/h3&gt;

&lt;p&gt;If your cluster uses the older &lt;code&gt;aws-auth&lt;/code&gt; ConfigMap, you'd map the node role there. On newer clusters with &lt;code&gt;authentication_mode = "API"&lt;/code&gt;, you skip the ConfigMap entirely and create an &lt;strong&gt;access entry&lt;/strong&gt; instead:&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_eks_access_entry"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_node"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&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;cluster_name&lt;/span&gt;
  &lt;span class="nx"&gt;principal_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;karpenter_node&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;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EC2_LINUX"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire equivalent of the old &lt;code&gt;mapRoles&lt;/code&gt; block — one resource, no YAML.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Make Subnets and Security Groups Discoverable
&lt;/h3&gt;

&lt;p&gt;Karpenter doesn't take a list of subnet IDs. It finds them by tag. Tag only your &lt;strong&gt;private&lt;/strong&gt; subnets, and Karpenter will never place a node in a public 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;tags&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"karpenter.sh/discovery"&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;cluster_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the same tag on the node security group. Two tags, and Karpenter's entire networking config is done.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. SQS + EventBridge for Spot Interruptions
&lt;/h3&gt;

&lt;p&gt;This is the piece that makes Spot safe to use. AWS gives a 2-minute warning before reclaiming a Spot instance — but only if something is listening. EventBridge catches that warning and drops it on a queue; Karpenter polls the queue and drains the node before AWS pulls it.&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_sqs_queue"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_interrupt"&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;"${var.cluster_name}-karpenter-spot-events"&lt;/span&gt;
  &lt;span class="nx"&gt;message_retention_seconds&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_rule"&lt;/span&gt; &lt;span class="s2"&gt;"spot_interruption"&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;"${var.cluster_name}-spot-interruption"&lt;/span&gt;
  &lt;span class="nx"&gt;event_pattern&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;source&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aws.ec2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;detail-type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"EC2 Spot Instance Interruption Warning"&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_cloudwatch_event_target"&lt;/span&gt; &lt;span class="s2"&gt;"spot_interruption_to_sqs"&lt;/span&gt; &lt;span class="p"&gt;{&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;aws_cloudwatch_event_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spot_interruption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;arn&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_interrupt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat the rule/target pair for &lt;strong&gt;Rebalance Recommendation&lt;/strong&gt;, &lt;strong&gt;Instance State-change Notification&lt;/strong&gt;, and &lt;strong&gt;AWS Health Event&lt;/strong&gt; — four small rules, all feeding the same queue. Karpenter needs &lt;code&gt;sqs:ReceiveMessage&lt;/code&gt;, &lt;code&gt;DeleteMessage&lt;/code&gt;, and &lt;code&gt;GetQueueAttributes&lt;/code&gt; on it, granted through the controller policy above.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Install Karpenter with Helm
&lt;/h3&gt;

&lt;p&gt;Install the CRDs and the controller as &lt;strong&gt;two separate releases&lt;/strong&gt; — this keeps future CRD upgrades from breaking against an immutable-field error:&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;"kubernetes_namespace_v1"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;metadata&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="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_namespace&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;"helm_release"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_crds"&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;"karpenter-crd"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&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;karpenter_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"oci://public.ecr.aws/karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"karpenter-crd"&lt;/span&gt;
  &lt;span class="nx"&gt;version&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;karpenter_version&lt;/span&gt;
  &lt;span class="nx"&gt;create_namespace&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;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;kubernetes_namespace_v1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter&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;Then the controller itself, wired up to the queue, the IRSA role, and — critically — pinned to your &lt;strong&gt;existing managed node group&lt;/strong&gt;:&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;"helm_release"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&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;"karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&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;karpenter_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"oci://public.ecr.aws/karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;version&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;karpenter_version&lt;/span&gt;
  &lt;span class="nx"&gt;create_namespace&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;wait&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;cleanup_on_fail&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;timeout&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;set&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;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"settings.clusterName"&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;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"settings.interruptionQueue"&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;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_queue_name&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"serviceAccount.annotations.eks&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.amazonaws&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.com/role-arn"&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;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_controller_role_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;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"controller.resources.requests.cpu"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"controller.resources.requests.memory"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1Gi"&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"controller.resources.limits.cpu"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"controller.resources.limits.memory"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1Gi"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# Pin Karpenter pods to the existing managed node group&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;"affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eks.amazonaws.com/nodegroup"&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].operator"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"In"&lt;/span&gt;
    &lt;span class="p"&gt;},&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;"affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].values[0]"&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;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_group_name&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;kubernetes_namespace_v1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;helm_release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_crds&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;strong&gt;Why the node affinity block matters:&lt;/strong&gt; Karpenter's job is to launch nodes for pods that can't schedule — but Karpenter itself runs as a pod. If Karpenter is allowed to schedule onto nodes &lt;em&gt;it&lt;/em&gt; provisions, you get a chicken-and-egg deadlock: no Karpenter-provisioned nodes exist yet, so Karpenter can't start, so no nodes ever get created. The fix is to explicitly schedule Karpenter onto your &lt;strong&gt;base, pre-existing managed node group&lt;/strong&gt; — the one created in the EKS/Terraform bootstrap, not anything Karpenter itself spins up. That node group's only job is to keep a stable home for cluster-critical controllers; everything else in the cluster is fair game for Karpenter to provision, resize, and recycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Tell It What to Launch: NodePool + EC2NodeClass
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2NodeClass&lt;/strong&gt;: the instance blueprint:&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;karpenter.k8s.aws/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;EC2NodeClass&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;default&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;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KarpenterNodeRole-my-cluster&lt;/span&gt;
  &lt;span class="na"&gt;amiSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;al2023@latest&lt;/span&gt;
  &lt;span class="na"&gt;subnetSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;karpenter.sh/discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;my-cluster&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;securityGroupSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;karpenter.sh/discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;my-cluster&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;NodePool&lt;/strong&gt;: the rules for when and what:&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;karpenter.sh/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;NodePool&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;default&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;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;requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&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;karpenter.sh/capacity-type&lt;/span&gt;
          &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on-demand"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="pi"&gt;-&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;karpenter.k8s.aws/instance-category&lt;/span&gt;
          &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;nodeClassRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws&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;EC2NodeClass&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;default&lt;/span&gt;
      &lt;span class="na"&gt;expireAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;
  &lt;span class="na"&gt;disruption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;consolidationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WhenEmptyOrUnderutilized&lt;/span&gt;
    &lt;span class="na"&gt;consolidateAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Two roles plus one templated controller policy, one access entry, two tags, one queue with four EventBridge rules, two Helm releases pinned to your base node group, and two YAML manifests. Nothing here is complicated on its own — it's just a lot of small pieces that all have to point at each other correctly. Once they do, scaling stops being something you configure per-workload and becomes something the cluster just does, at whatever price the market currently offers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@ajinkyaa3xd/enabling-karpenter-on-eks-with-terraform-what-it-is-why-its-worth-it-and-how-to-set-it-up-326467a2ce44" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>kubernetes</category>
      <category>terraform</category>
      <category>karpenter</category>
    </item>
    <item>
      <title>Deploying Envoy Gateway on AWS EKS: The Right Way</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 16:33:49 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/deploying-envoy-gateway-on-aws-eks-the-right-way-24dc</link>
      <guid>https://dev.to/ajinkya_a3/deploying-envoy-gateway-on-aws-eks-the-right-way-24dc</guid>
      <description>&lt;p&gt;Some context first: we were running on GKE Autopilot, where the Gateway API just works out of the box. Google manages the CRDs and the underlying load balancer controller for you create a Gateway, and it gets an external IP without you ever thinking about CRD lifecycle.&lt;/p&gt;

&lt;p&gt;Moving that same ingress layer to EKS meant none of that was ready to use anymore. The first real decision wasn't about Envoy Gateway's configuration at all it was about how to install its CRDs without them colliding with the Gateway API CRDs, or with each other, during future migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing the Gateway API CRDs
&lt;/h2&gt;

&lt;p&gt;We start by installing the Gateway API CRDs first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GatewayClass&lt;/code&gt; CRD&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Gateway&lt;/code&gt; CRD&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HTTPRoute&lt;/code&gt; CRD&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReferenceGrant&lt;/code&gt; CRD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get crd | &lt;span class="nb"&gt;grep &lt;/span&gt;gateway.networking.k8s.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Installing Envoy Gateway's CRDs and Controller via ArgoCD
&lt;/h2&gt;

&lt;p&gt;Next, install Envoy Gateway's own CRDs and the controller itself as two separate ArgoCD Applications, on two separate sync waves:&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;argoproj.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;Application&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;envoy-gateway-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;argocd&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-1"&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;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci://docker.io/envoyproxy&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;gateway-crds-helm&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;helm&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="s"&gt;crds:&lt;/span&gt;
          &lt;span class="s"&gt;gatewayAPI:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: false       # Gateway API CRDs managed separately&lt;/span&gt;
            &lt;span class="s"&gt;channel: experimental&lt;/span&gt;
          &lt;span class="s"&gt;envoyGateway:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: true        # Only Envoy-specific CRDs&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&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;default&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&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;selfHeal&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;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ServerSideApply=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.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;Application&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;envoy-gateway&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;argocd&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&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;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci://docker.io/envoyproxy&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;gateway-helm&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;skipCrds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;             &lt;span class="c1"&gt;# CRDs managed by envoy-gateway-crds app&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&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;envoy-gateway-system&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&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;selfHeal&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;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ServerSideApply=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're not using ArgoCD, the equivalent Helm commands are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;envoy-gateway-crds oci://docker.io/envoyproxy/gateway-crds-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.8.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server-side&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.gatewayAPI.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.gatewayAPI.channel&lt;span class="o"&gt;=&lt;/span&gt;experimental &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.envoyGateway.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true

&lt;/span&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;envoy-gateway oci://docker.io/envoyproxy/gateway-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.8.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-gateway-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-crds&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server-side&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gatewayAPI.enabled: false&lt;/code&gt;&lt;/strong&gt; the shared Gateway API CRDs (&lt;code&gt;GatewayClass&lt;/code&gt;, &lt;code&gt;Gateway&lt;/code&gt;, &lt;code&gt;HTTPRoute&lt;/code&gt;, &lt;code&gt;ReferenceGrant&lt;/code&gt;) aren't installed by this chart. They're installed once, separately, by their own Application, independent of any controller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;envoyGateway.enabled: true&lt;/code&gt;&lt;/strong&gt; this chart installs only Envoy Gateway's own CRDs, including &lt;code&gt;EnvoyProxy&lt;/code&gt;, on sync-wave &lt;code&gt;-1&lt;/code&gt;, before the controller exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;skipCrds: true&lt;/code&gt;&lt;/strong&gt; on the envoy-gateway chart (wave &lt;code&gt;0&lt;/code&gt;) the controller deployment goes in after its CRDs already exist, and never touches CRD lifecycle itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ServerSideApply=true&lt;/code&gt;&lt;/strong&gt; on both field-level ownership instead of whole-object ownership, so multiple Applications can touch overlapping CRDs without one overwriting the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both Applications are templated as part of our cluster-bootstrap ApplicationSet, so every environment gets Envoy Gateway's CRDs-then-controller order automatically no manual sequencing per cluster for this part of the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture at a Glance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
   │
   ▼
AWS NLB  (provisioned by AWS Load Balancer Controller)
   │
   ▼
Envoy Proxy Pods  (managed by Envoy Gateway, autoscaled by HPA)
   │
   ▼
Application Services  (via HTTPRoute rules)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The LoadBalancer Pending Trap
&lt;/h2&gt;

&lt;p&gt;With the CRDs and controller in place, the next thing that breaks on a fresh EKS setup is the Service Envoy Gateway generates for its proxy deployment. By default, it's type &lt;code&gt;LoadBalancer&lt;/code&gt;, and Kubernetes' in-tree cloud controller tries to provision a Classic Load Balancer for it.&lt;/p&gt;

&lt;p&gt;On modern EKS clusters, that fails silently. No CLB gets created, no useful error appears in events, and the Service just sits at &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; indefinitely.&lt;/p&gt;

&lt;p&gt;The fix doesn't live on the Gateway object — Envoy Gateway generates its own Service internally, so there's nothing on &lt;code&gt;Gateway.metadata&lt;/code&gt; to annotate. The fix has to go into the &lt;code&gt;EnvoyProxy&lt;/code&gt; CRD, the same CRD installed separately in the &lt;code&gt;-1&lt;/code&gt; sync wave above, via &lt;code&gt;envoyService.annotations&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;envoyService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Stops the in-tree CLB provisioner - AWS Load Balancer Controller&lt;/span&gt;
    &lt;span class="c1"&gt;# takes over and creates an NLB instead.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external"&lt;/span&gt;

    &lt;span class="c1"&gt;# Public-facing NLB. Use "internal" for private traffic only.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internet-facing"&lt;/span&gt;

    &lt;span class="c1"&gt;# Route NLB traffic directly to Pod IPs via VPC CNI -&lt;/span&gt;
    &lt;span class="c1"&gt;# bypasses kube-proxy and NodePort.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-nlb-target-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ip&lt;/span&gt;

    &lt;span class="c1"&gt;# NLB health check hits Envoy's admin port. /healthz returns 200&lt;/span&gt;
    &lt;span class="c1"&gt;# only once Envoy is fully ready, so the NLB never routes to a&lt;/span&gt;
    &lt;span class="c1"&gt;# pod that's still starting or draining.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol&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;service.beta.kubernetes.io/aws-load-balancer-healthcheck-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;19002"&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single annotation that actually breaks the deadlock is &lt;code&gt;aws-load-balancer-type: "external"&lt;/code&gt; it tells the in-tree controller to back off, and hands the Service to the AWS Load Balancer Controller, which then provisions a real NLB and writes its hostname back to &lt;code&gt;gateway.status.addresses&lt;/code&gt;. The rest of the block (scheme, target type, health check) is what makes that NLB actually production-ready rather than just "not pending."&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together: GatewayClass, Gateway, and EnvoyProxy
&lt;/h2&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;gateway.networking.k8s.io/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;GatewayClass&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;eg&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;controllerName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/gatewayclass-controller&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/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;Gateway&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;external-gateway&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;gateway&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;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
  &lt;span class="na"&gt;infrastructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parametersRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&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;EnvoyProxy&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;external-proxy-config&lt;/span&gt;
  &lt;span class="na"&gt;listeners&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;http&lt;/span&gt;
      &lt;span class="na"&gt;protocol&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;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the &lt;code&gt;EnvoyProxy&lt;/code&gt; CRD that ties resources, autoscaling, and the LB fix together in one place:&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;gateway.envoyproxy.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;EnvoyProxy&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;external-proxy-config&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;gateway&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;provider&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;Kubernetes&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;envoyDeployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;patch&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;StrategicMerge&lt;/span&gt;
          &lt;span class="na"&gt;value&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;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;shutdown-manager&lt;/span&gt;
                      &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                        &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                          &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                            &lt;span class="na"&gt;command&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;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;120"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;

      &lt;span class="na"&gt;envoyHpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;minReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;maxReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
        &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&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;Resource&lt;/span&gt;
            &lt;span class="na"&gt;resource&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;cpu&lt;/span&gt;
              &lt;span class="na"&gt;target&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;Utilization&lt;/span&gt;
                &lt;span class="na"&gt;averageUtilization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;

      &lt;span class="na"&gt;envoyService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external"&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internet-facing"&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-nlb-target-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ip&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol&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;service.beta.kubernetes.io/aws-load-balancer-healthcheck-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;19002"&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying the Gateway triggers the chain reaction: Envoy Gateway creates the proxy deployment, the LoadBalancer Service with the annotations above, and an HPA — then AWS LBC provisions the NLB. HTTPRoute objects attach to the Gateway afterward and define per-service routing, owned by app teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autoscaling Explained
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;envoyHpa&lt;/code&gt; block creates a standard Kubernetes HPA against the proxy deployment. &lt;code&gt;minReplicas: 1&lt;/code&gt; keeps cost down during idle periods, at the cost of zero redundancy for ~15-30s if that pod dies. &lt;code&gt;averageUtilization: 60&lt;/code&gt; (150m of the 250m request) triggers scale-out early enough that new pods are healthy before latency degrades. For zero-downtime guarantees, &lt;code&gt;minReplicas: 2&lt;/code&gt; or &lt;code&gt;3&lt;/code&gt; is the move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Running Envoy Gateway on Amazon EKS isn't just about deploying another ingress controller — it's about understanding where the responsibilities are split.&lt;/p&gt;

&lt;p&gt;Unlike managed Kubernetes offerings where the Gateway API experience is largely invisible, EKS gives you the flexibility to control every layer. That also means you own the lifecycle of the Gateway API CRDs, the Envoy Gateway CRDs, the controller installation, and the integration with the AWS Load Balancer Controller.&lt;/p&gt;

&lt;p&gt;Separating CRDs from the controller, using ArgoCD sync waves to guarantee deployment order, and configuring the EnvoyProxy resource as the single place for infrastructure concerns makes the setup predictable and GitOps-friendly. It also avoids one of the most common migration issues: LoadBalancer Services remaining in a perpetual Pending state because the wrong controller is trying to provision them.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>apigateway</category>
      <category>gitops</category>
    </item>
    <item>
      <title>Why We Moved from GKE to EKS</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Sat, 25 Apr 2026 20:47:53 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/why-we-moved-from-gke-to-eks-1m96</link>
      <guid>https://dev.to/ajinkya_a3/why-we-moved-from-gke-to-eks-1m96</guid>
      <description>&lt;h2&gt;
  
  
  Why We Moved from GKE to EKS
&lt;/h2&gt;

&lt;p&gt;When we initially adopted Kubernetes, &lt;strong&gt;Google Kubernetes Engine (GKE) Autopilot&lt;/strong&gt; seemed like the perfect choice — fully managed, minimal operational overhead, and quick to get started.&lt;/p&gt;

&lt;p&gt;But as our workloads matured, three major challenges started to surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rising and unpredictable costs&lt;/li&gt;
&lt;li&gt;Compliance constraints&lt;/li&gt;
&lt;li&gt;The need for deeper infrastructure control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This blog walks through &lt;strong&gt;why we migrated&lt;/strong&gt; to &lt;strong&gt;Amazon Elastic Kubernetes Service (EKS)&lt;/strong&gt; with Karpenter, the architectural changes we made, and the lessons we learned running production workloads post-migration.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ Why GKE Autopilot Started Falling Short
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Cost Inefficiencies at Scale
&lt;/h3&gt;

&lt;p&gt;GKE Autopilot pricing is based on &lt;strong&gt;requested resources, not actual usage&lt;/strong&gt;. This sounds fine at small scale — but as traffic grows, the gaps between requested and actual usage start to compound.&lt;/p&gt;

&lt;p&gt;Problems we observed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Over-provisioned workloads leading to higher bills&lt;/li&gt;
&lt;li&gt;No access to Spot/Preemptible node strategies with the same level of flexibility&lt;/li&gt;
&lt;li&gt;Very few cost optimization knobs to tune&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As traffic grew, costs increased almost linearly with no meaningful way to optimize without restructuring our entire workload configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Compliance and Governance Constraints
&lt;/h3&gt;

&lt;p&gt;Operating in a regulated environment required:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fine-grained IAM control at the workload level&lt;/li&gt;
&lt;li&gt;Strict network isolation between services&lt;/li&gt;
&lt;li&gt;Audit-level visibility into infrastructure activity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With GKE Autopilot, several configurations are abstracted away or restricted by design. This made it harder to enforce organization-wide security policies and satisfy compliance requirements from auditors. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enforcing per-pod IAM permissions cleanly was non-trivial&lt;/li&gt;
&lt;li&gt;Network policy enforcement had gaps in our specific setup&lt;/li&gt;
&lt;li&gt;Generating audit-ready logs tied to individual workload actions required workarounds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We needed something that gave us &lt;strong&gt;first-class integration with cloud-native IAM and security tooling&lt;/strong&gt; — without layering on custom solutions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Limited Infrastructure Control
&lt;/h3&gt;

&lt;p&gt;When performance-sensitive services started hitting bottlenecks, the inability to choose instance types became a real blocker. We had no control over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU vs. memory-optimized instance selection&lt;/li&gt;
&lt;li&gt;ARM-based workloads on Graviton processors&lt;/li&gt;
&lt;li&gt;Custom AMIs or low-level networking tuning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams running general-purpose workloads, this abstraction is a feature. For us, it was a ceiling.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Why We Chose EKS + Karpenter
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Full Infrastructure Control
&lt;/h3&gt;

&lt;p&gt;Moving to EKS gave us direct control over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instance families&lt;/strong&gt; — CPU-optimized, memory-optimized, ARM (Graviton)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom AMIs&lt;/strong&gt; — hardened images meeting our internal security baseline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking&lt;/strong&gt; — VPC-native networking with fine-grained subnet and security group control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This unlocked workload-specific performance tuning that simply wasn't possible before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Advanced Cost Optimization with Karpenter
&lt;/h3&gt;

&lt;p&gt;Karpenter is not your traditional cluster autoscaler. Instead of scaling pre-defined node groups, it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Watches for unschedulable pods in real time&lt;/li&gt;
&lt;li&gt;Selects the &lt;strong&gt;right-sized instance&lt;/strong&gt; based on actual pod requirements&lt;/li&gt;
&lt;li&gt;Prioritizes &lt;strong&gt;Spot instances&lt;/strong&gt; where workloads allow, falling back to On-Demand seamlessly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bin-packs nodes efficiently&lt;/strong&gt;, reducing idle capacity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: faster scaling reactions and a dramatically lower compute bill — without sacrificing reliability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance Alignment
&lt;/h3&gt;

&lt;p&gt;AWS gave us the compliance story we needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IRSA (IAM Roles for Service Accounts)&lt;/strong&gt; — precise, per-pod IAM permissions with no shared credentials&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPC-level isolation&lt;/strong&gt; — full control over ingress, egress, and inter-service communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail integration&lt;/strong&gt; — every API call, every node action, fully auditable out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Config + Security Hub&lt;/strong&gt; — continuous compliance checks against CIS benchmarks and custom rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This made our next compliance audit significantly smoother. Auditors got clear, traceable logs without us having to build custom instrumentation.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ Target Architecture
&lt;/h2&gt;

&lt;p&gt;Here's what the high-level migration looked like architecturally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Before (GKE)&lt;/th&gt;
&lt;th&gt;After (EKS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cluster&lt;/td&gt;
&lt;td&gt;GKE Autopilot&lt;/td&gt;
&lt;td&gt;EKS (Managed Node Groups + Karpenter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaling&lt;/td&gt;
&lt;td&gt;Built-in Autopilot scaling&lt;/td&gt;
&lt;td&gt;Karpenter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spot Strategy&lt;/td&gt;
&lt;td&gt;NO Control&lt;/td&gt;
&lt;td&gt;Karpenter Spot-first provisioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;GCP Workload Identity&lt;/td&gt;
&lt;td&gt;AWS IRSA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit Logging&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;td&gt;CloudTrail + CloudWatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;GKE VPC-native&lt;/td&gt;
&lt;td&gt;AWS VPC with custom subnets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  ⚙️ Karpenter Setup — The Game Changer
&lt;/h2&gt;

&lt;p&gt;Karpenter replaced our traditional Cluster Autoscaler, and the difference was immediately visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we configured it:&lt;/strong&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;karpenter.sh/v1&lt;/span&gt;
    &lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePool&lt;/span&gt;
    &lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="err"&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;spot-arm64&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;template&lt;/span&gt;&lt;span class="pi"&gt;:&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;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# -----------------------------------------------&lt;/span&gt;
            &lt;span class="c1"&gt;# These labels land on the EC2 node.&lt;/span&gt;
            &lt;span class="c1"&gt;# Your pod affinity rules match against these.&lt;/span&gt;
            &lt;span class="c1"&gt;# -----------------------------------------------&lt;/span&gt;
            &lt;span class="na"&gt;node-pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot-arm64&lt;/span&gt;
            &lt;span class="na"&gt;capacity-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot&lt;/span&gt;
            &lt;span class="na"&gt;arch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arm64&lt;/span&gt;
            &lt;span class="na"&gt;workload-class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard&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;requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&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;kubernetes.io/arch&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arm64"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="pi"&gt;-&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;kubernetes.io/os&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;linux"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="pi"&gt;-&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;karpenter.sh/capacity-type&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="pi"&gt;-&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;karpenter.k8s.aws/instance-category&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="c1"&gt;# c6g, c7g, c8g — compute optimized Graviton&lt;/span&gt;
              &lt;span class="c1"&gt;# m6g, m7g, m8g — general purpose Graviton&lt;/span&gt;
              &lt;span class="c1"&gt;# r6g, r7g, r8g — memory optimized Graviton&lt;/span&gt;
            &lt;span class="pi"&gt;-&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;karpenter.k8s.aws/instance-generation&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;             &lt;span class="c1"&gt;# Graviton2+ only (gen 6,7,8)&lt;/span&gt;
          &lt;span class="na"&gt;nodeClassRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws&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;EC2NodeClass&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;default&lt;/span&gt;
          &lt;span class="na"&gt;expireAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;168h&lt;/span&gt;             &lt;span class="c1"&gt;# 7 days — shorter for spot nodes&lt;/span&gt;


      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2000Gi&lt;/span&gt;
      &lt;span class="na"&gt;disruption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;consolidationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WhenEmptyOrUnderutilized&lt;/span&gt;
        &lt;span class="na"&gt;consolidateAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decisions we made:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spot-first provisioning&lt;/strong&gt; — workloads that tolerate interruptions run on Spot; stateful services stay on On-Demand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple instance families&lt;/strong&gt; — Karpenter picks the cheapest right-sized option across families&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interruption handling&lt;/strong&gt; — we use the Karpenter interruption queue (SQS) to gracefully drain Spot nodes before AWS reclaims them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;consolidateAfter: 2m&lt;/code&gt;&lt;/strong&gt; — nodes deprovision 2m seconds after going idle, eliminating ghost capacity&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📊 Results After Migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  💰 Cost
&lt;/h3&gt;

&lt;p&gt;Compute costs dropped significantly. The main drivers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spot instances covering the majority of our non-critical workloads&lt;/li&gt;
&lt;li&gt;Karpenter's bin-packing eliminating idle node waste&lt;/li&gt;
&lt;li&gt;Right-sized instances instead of over-provisioned static node groups&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚡ Performance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Faster pod scheduling — Karpenter provisions new nodes in under 60 seconds in most cases&lt;/li&gt;
&lt;li&gt;Better workload isolation through custom node selectors and taints&lt;/li&gt;
&lt;li&gt;Graviton (ARM) instances for compatible workloads gave us a meaningful price-performance improvement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🔐 Compliance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Audit reports now generated directly from CloudTrail without custom tooling&lt;/li&gt;
&lt;li&gt;IRSA eliminated shared IAM credential risks&lt;/li&gt;
&lt;li&gt;Security Hub provides continuous posture monitoring against our compliance framework&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚠️ Challenges We Faced
&lt;/h2&gt;

&lt;p&gt;Being honest here — this is what makes a migration story actually useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Karpenter Learning Curve
&lt;/h3&gt;

&lt;p&gt;Karpenter's provisioning model is fundamentally different from Cluster Autoscaler. Debugging why a node wasn't provisioned — or why Karpenter chose a specific instance type — required understanding its internal decision logic. The logs are verbose but not always immediately readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What helped:&lt;/strong&gt; Running Karpenter in dry-run mode first, and adding structured logging to correlate provisioning decisions with pod events.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Networking Model Differences
&lt;/h3&gt;

&lt;p&gt;GKE's VPC-native networking and AWS VPC behave differently in non-obvious ways — especially around CIDR planning, secondary IP ranges, and how pod IPs are allocated. We had to redesign our subnet layout and revisit some service-to-service communication assumptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. IAM Complexity
&lt;/h3&gt;

&lt;p&gt;IRSA is powerful but requires careful role design. Mapping GCP Workload Identity bindings to AWS IRSA role assumptions took time, especially for services that assumed broad IAM permissions under GCP that needed to be tightened properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 Key Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed ≠ always optimal at scale.&lt;/strong&gt; Autopilot is excellent for getting started, but production-grade platforms eventually need control surfaces that fully managed offerings deliberately hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost optimization requires infrastructure access.&lt;/strong&gt; You can't tune what you can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoscaling strategy matters more than cluster size.&lt;/strong&gt; Karpenter's approach of provisioning for the pod rather than scaling a group changed how we think about capacity planning entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance is easier when the platform is designed for it.&lt;/strong&gt; AWS's native compliance tooling removed a category of work that we were previously solving with custom scripts and log forwarding pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration should always be incremental.&lt;/strong&gt; Parallel environment, gradual DNS cutover, canary deployments — this approach meant we caught issues in staging before they became production incidents.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🏁 Conclusion
&lt;/h2&gt;

&lt;p&gt;GKE Autopilot is an excellent choice for teams that want Kubernetes without the operational overhead — and we'd still recommend it for that use case.&lt;/p&gt;

&lt;p&gt;But for production environments that require &lt;strong&gt;cost control at scale&lt;/strong&gt;, &lt;strong&gt;fine-grained compliance posture&lt;/strong&gt;, and &lt;strong&gt;workload-specific infrastructure decisions&lt;/strong&gt;, EKS with Karpenter provided a more flexible and efficient platform.&lt;/p&gt;

&lt;p&gt;The migration wasn't trivial, but the control, visibility, and cost profile on the other side made it worth it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you gone through a similar migration? Or are you evaluating EKS vs GKE for your stack? Drop your questions in the comments — happy to dig into specifics.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;kubernetes&lt;/code&gt; &lt;code&gt;aws&lt;/code&gt; &lt;code&gt;devops&lt;/code&gt; &lt;code&gt;cloud&lt;/code&gt; &lt;code&gt;karpenter&lt;/code&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>devops</category>
      <category>karpenter</category>
    </item>
  </channel>
</rss>
