<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="pretty-atom-feed.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>Ascode - Erik Christiaans</title>
  <subtitle>Notes on AI governance, identity and cloud platforms in regulated enterprises</subtitle>
  <link href="https://ascode.nl/feed/feed.xml" rel="self" />
  <link href="https://ascode.nl/" />
  <updated>2026-10-07T00:00:00Z</updated>
  <id>https://ascode.nl/</id>
  <author>
    <name>Erik Christiaans</name>
  </author>
  <entry>
    <title>Building an AI Landing Zone on Azure — Part 4: Metering every call</title>
    <link href="https://ascode.nl/blog/part-4-metering-every-call/" />
    <updated>2026-10-07T00:00:00Z</updated>
    <id>https://ascode.nl/blog/part-4-metering-every-call/</id>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;This is part 4 of a five-part series on building an AI Landing Zone on Azure.
Part 1: Why every enterprise needs an AI gateway · Part 2: The platform underneath · Part 3: One front door for many models · Part 4: Metering every call (this post) · Part 5: Turning tokens into euros&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/part-4-metering-every-call/G0aqYAT7Yn-1672.avif 1672w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/part-4-metering-every-call/G0aqYAT7Yn-1672.webp 1672w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/part-4-metering-every-call/G0aqYAT7Yn-1672.jpeg&quot; alt=&quot;Metering every call: a pipeline full of glowing particles passing a metering station whose gauge reads zero, above a blank ledger drawer&quot; width=&quot;1672&quot; height=&quot;941&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Parts 1 to 3 described building a private gateway that every AI call goes through. This part is about the thing that makes the gateway worth more than a fancy reverse proxy: for every call, it records exactly what was consumed, by whom, and writes that somewhere a (finance) team can use.&lt;/p&gt;
&lt;p&gt;This is also the part of the project that took the most effort relative to how simple it looks on the diagram, so I am going to spend as much time on what went wrong as on what the design is. If you only read one section, read the one about silent failures.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline&quot;&gt;The pipeline&lt;/h2&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/part-4-metering-every-call/DPFEAfVG0L-2960.avif 2960w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/part-4-metering-every-call/DPFEAfVG0L-2960.webp 2960w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/part-4-metering-every-call/DPFEAfVG0L-2960.png&quot; alt=&quot;Usage metering pipeline: APIM emits a usage record per call to Event Hub, a Flex Consumption Function upserts it into Cosmos DB, Power BI prices it; Application Insights and VictoriaMetrics as side channels, managed identity on every hop&quot; width=&quot;2960&quot; height=&quot;1000&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hop 1: APIM emits the usage record.&lt;/strong&gt; In the outbound section of every API policy, a fragment runs after a successful response. It reads the model and usage information, builds a JSON document, and posts it to the Event Hub REST endpoint with a bearer token obtained through APIM&#39;s user-assigned managed identity. Only 2xx responses produce a record; a 404 or 429 upstream is not considered as usage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hop 2: Event Hub.&lt;/strong&gt; One hub, &lt;code&gt;ai-usage&lt;/code&gt;, with a dedicated consumer group for ingestion, public access disabled, private endpoint in the zone. Retention is a day in non-production and a week in production, which is enough to survive a Function outage without losing records.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hop 3: the Function App.&lt;/strong&gt; A Python Function App on the Flex Consumption plan, VNet-integrated into its own subnet, with a user-assigned identity. It has an Event Hub trigger that batches records and upserts them into Cosmos DB, and pushes the same numbers as Prometheus metrics to VictoriaMetrics on the AKS cluster so the platform team has a live view.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hop 4: Cosmos DB.&lt;/strong&gt; A SQL API account, public access disabled, local authentication disabled. The usage container is partitioned by product name, because product is what every report groups by.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hop 5: the streaming side channel.&lt;/strong&gt; For routes that stream and are not buffered, there is no usage block for APIM to read, so a second function on a five-minute timer queries a token metric from Application Insights and writes aggregate records.&lt;/p&gt;
&lt;h2 id=&quot;the-usage-record&quot;&gt;The usage record&lt;/h2&gt;
&lt;p&gt;The record APIM emits is deliberately richer than &amp;quot;tokens in, tokens out&amp;quot;, because everything you want to report on later has to be captured here. It carries the APIM subscription and product (for chargeback), the model as returned by the API and the deployment name as requested, the gateway name and region, the operation and backend, and then the consumption itself: prompt tokens, cached input tokens, cache-write tokens, completion tokens, reasoning tokens and total tokens for language models, or pages processed and document size for OCR. It also carries a &lt;code&gt;usageSource&lt;/code&gt; field that says whether the numbers came from the response body (exact) or from an estimate, and an &lt;code&gt;isStream&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;Two details here saved me later. Capturing cached and cache-write tokens separately mattered as soon as a model appeared (GTP 5.6) with a separate price for cache writes. And the fragment reads both the Chat Completions shape (&lt;code&gt;prompt_tokens&lt;/code&gt;, &lt;code&gt;completion_tokens&lt;/code&gt;) and the Responses API shape (&lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;output_tokens&lt;/code&gt;, with nested details), so the same fragment meters both the document-extraction workload and the coding assistant.&lt;/p&gt;
&lt;h2 id=&quot;exact-versus-estimate-the-streaming-trade-off&quot;&gt;Exact versus estimate: the streaming trade-off&lt;/h2&gt;
&lt;p&gt;In part 3 I mentioned that whether APIM buffers a response is a per-route decision. What makes that important?&lt;/p&gt;
&lt;p&gt;When a chat completion is &lt;strong&gt;not streamed&lt;/strong&gt;, the response is one JSON body with a &lt;code&gt;usage&lt;/code&gt; block. APIM reads it in the outbound policy and the record is exact.&lt;/p&gt;
&lt;p&gt;When a chat completion is &lt;strong&gt;streamed&lt;/strong&gt;, the response is a series of server-sent events, and unless the backend has been asked to include usage in the final chunk and APIM has buffered the whole stream, there is nothing for the outbound policy to read. You can buffer, in which case the fragment parses the terminal event and you still get exact numbers, at the cost of the caller waiting for the full answer. Or you do not buffer, in which case the fragment falls back to the estimate that APIM&#39;s own token-limit policy computed on the way in, marks the record &lt;code&gt;usageSource = estimate&lt;/code&gt;, and the caller gets live token-by-token output.&lt;/p&gt;
&lt;p&gt;I made this a per-route toggle in the policy rather than a global choice. The coding-assistant route buffers, because those calls are large. Interactive routes stream. As a safety net, the OpenAI policy also emits a token metric to Application Insights on every call, and the timer function scrapes that metric every five minutes and writes aggregate &amp;quot;streaming&amp;quot; records, so even routes that stream without buffering produce a total-tokens number. Those streaming records have no input/output split, which has a consequence for pricing that I will cover in part 5.&lt;/p&gt;
&lt;h2 id=&quot;the-three-silent-failures&quot;&gt;The three silent failures&lt;/h2&gt;
&lt;p&gt;Now the part that took me the most time to figure out. Each of these produced the same symptom: every component reported healthy, every test call returned 200, and the Cosmos container stayed empty. None of them threw an error I could see.&lt;/p&gt;
&lt;h3 id=&quot;1-the-event-hub-logger-that-does-nothing&quot;&gt;1. The Event Hub logger that does nothing&lt;/h3&gt;
&lt;p&gt;APIM has a native &lt;code&gt;log-to-eventhub&lt;/code&gt; policy backed by a logger resource, and that logger supports managed identity authentication. It is the obvious way to send events to Event Hub. I configured it, the logger resource created fine, the policy validated, calls returned 200, and nothing ever arrived in the hub.&lt;/p&gt;
&lt;p&gt;Against a private Event Hub, with public access disabled, the managed-identity logger silently no-ops. I do not know whether that is a limitation of how the logger resolves the endpoint or something about how it acquires the token, and Microsoft&#39;s documentation is not clear on it. What I do know is that after adding diagnostic response headers to surface internal policy state, the logger path was being reached and simply had no effect.&lt;/p&gt;
&lt;p&gt;The fix was to stop using the logger and emit the record with a &lt;code&gt;send-request&lt;/code&gt; policy instead: acquire a token for the Event Hubs audience with &lt;code&gt;authentication-managed-identity&lt;/code&gt;, POST the JSON to the hub&#39;s REST endpoint. That works reliably, and it has a nice feature that the logger does not have: the endpoint is a named value, so the same fragment works in every environment. Both the token acquisition and the send have &lt;code&gt;ignore-error&lt;/code&gt; set so that a metering hiccup never fails a caller&#39;s request, and I left a note in the fragment that flipping those to &lt;code&gt;false&lt;/code&gt; is the fastest way to see the real error when debugging.&lt;/p&gt;
&lt;h3 id=&quot;2-the-empty-setting-that-on-the-function-app&quot;&gt;2. The empty setting that on the Function App&lt;/h3&gt;
&lt;p&gt;The Function App uses managed identity for its host storage as well, because shared keys are disabled on the storage account. That is configured with a triple of settings: &lt;code&gt;AzureWebJobsStorage__accountName&lt;/code&gt;, &lt;code&gt;AzureWebJobsStorage__credential = managedidentity&lt;/code&gt;, and &lt;code&gt;AzureWebJobsStorage__clientId&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;At one point, trying to stop the platform from re-injecting a connection-string value, I set the bare &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; key to an empty string. The Function App started. The Event Hub trigger attached. Invocation counts stayed at zero. Event Hub metrics showed &lt;em&gt;Incoming Messages&lt;/em&gt; climbing steadily and &lt;em&gt;Outgoing Messages&lt;/em&gt; flat at zero.&lt;/p&gt;
&lt;p&gt;When &lt;code&gt;AzureWebJobsStorage&lt;/code&gt; exists, even as an empty value, the Functions host uses it and ignores the three &lt;code&gt;AzureWebJobsStorage__*&lt;/code&gt; settings that configure managed-identity access. The host cannot checkpoint, so the trigger never consumes, and because the host considers itself healthy there is no error to find. The fix is to remove the empty setting entirely, not set it to anything. This one is now the first line in the operations handover, because the symptom (incoming up, outgoing zero) is so specific that it should be the first thing anyone checks.&lt;/p&gt;
&lt;h3 id=&quot;3-contributor-is-not-a-data-reader&quot;&gt;3. Contributor is not a data reader&lt;/h3&gt;
&lt;p&gt;Cosmos DB has two separate permission systems. Azure RBAC (Owner, Contributor, Reader) controls the control plane: the account, the containers, the throughput. The &lt;strong&gt;data plane&lt;/strong&gt;, actually reading and writing documents, is governed by Cosmos SQL role definitions, assigned with a separate command, and Azure RBAC is not used.&lt;/p&gt;
&lt;p&gt;&amp;quot;But I&#39;m Contributor, why can&#39;t I read&amp;quot;? The permission errors are easy to misread as a networking problem, because everything else that fails does so for networking reasons. The fix is the built-in &lt;em&gt;Cosmos DB Built-in Data Contributor&lt;/em&gt; role for the Function App identity and &lt;em&gt;Data Reader&lt;/em&gt; for the reporting identity, assigned at the data plane, which Terraform can do, and which I added to the IaC code obviously.&lt;/p&gt;
&lt;h2 id=&quot;two-smaller-things-that-also-cost-time&quot;&gt;Two smaller things that also cost time&lt;/h2&gt;
&lt;p&gt;The Flex Consumption plan does not support &lt;code&gt;WEBSITE_RUN_FROM_PACKAGE&lt;/code&gt;, so the code is deployed with a zip deploy triggered from Terraform. A &lt;code&gt;terraform_data&lt;/code&gt; resource hashes the function source, and when the hash changes, a provisioner exports pinned requirements with &lt;code&gt;uv&lt;/code&gt;, downloads copies of every Python dependency for the Function App&#39;s runtime and bundles them with the code, zips the result and pushes it. It is not elegant, but it means a change to the ingestion code goes through the same merge request and pipeline as everything else.&lt;/p&gt;
&lt;p&gt;It looks something like this:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;terraform_data&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;function_build_deploy&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;triggers_replace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    local.fn_src_hash,
    azurerm_function_app_flex_consumption.ingestion.id,
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;provisioner&lt;span class=&quot;token type variable&quot;&gt; &quot;local-exec&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;interpreter&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;/bin/bash&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;-c&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;working_dir&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.fn_src_dir
    &lt;span class=&quot;token property&quot;&gt;command&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token heredoc string&quot;&gt;&amp;lt;&amp;lt;-EOT
      set -euo pipefail

      # 1. Pin deps from the uv lock.
      uv export --no-hashes --no-dev --format requirements-txt -o requirements.txt

      # 2. Vendor packages for the target runtime.
      rm -rf .python_packages
      uv pip install &#92;
        --target .python_packages/lib/site-packages &#92;
        --python-version 3.14 --only-binary=:all: &#92;
        -r requirements.txt

      # 3. Build the package, excluding local-only stuff.
      mkdir -p ../.terraform.tmp
      rm -f ../.terraform.tmp/function_pkg.zip
      zip -r -q ../.terraform.tmp/function_pkg.zip . &#92;
        -x &#39;.venv/*&#39; -x &#39;__pycache__/*&#39; -x &#39;*.pyc&#39; -x &#39;.python_packages/**/__pycache__/*&#39;

      # 4. Deploy without remote build.
      az functionapp deployment source config-zip &#92;
        --resource-group &quot;${azurerm_resource_group.ailz.name}&quot; &#92;
        --name &quot;${azurerm_function_app_flex_consumption.ingestion.name}&quot; &#92;
        --src ../.terraform.tmp/function_pkg.zip &#92;
        --build-remote true
    EOT&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And APIM policy expressions are C# with a type-locked variable store. The built-in token-limit policy writes its token estimate as a 64-bit integer; an early version of my fragment read it as a 32-bit one, and the collision produced a policy error that only appeared on some routes. A conversion in the fragment solved it, but it is a reminder that policy expressions are real code and deserve the same care.&lt;/p&gt;
&lt;h2 id=&quot;the-runbook-that-came-out-of-it&quot;&gt;The runbook that came out of it&lt;/h2&gt;
&lt;p&gt;Everything above went into a troubleshooting section at the top of the operations handover, each step tells you which side of the pipeline is broken:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Are records actually missing, or is the report stale? Count recent documents in Cosmos.&lt;/li&gt;
&lt;li&gt;Is the gateway returning 200? Only successful calls are metered.&lt;/li&gt;
&lt;li&gt;Is the hub flowing? Incoming flat means the policy is not emitting; incoming up and outgoing zero means the Function is not consuming.&lt;/li&gt;
&lt;li&gt;Data present but report stale? Refresh, and check the rate card is seeded.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;next-turning-it-into-euros&quot;&gt;Next: turning it into Euros&lt;/h2&gt;
&lt;p&gt;The pipeline now produces one trustworthy record per call, with enough detail to price it. Part 5 is about the rate card, computing cost at query time, handling cached and reasoning tokens, and the report that finally gave the finance team a monthly AI bill per cost centre.&lt;/p&gt;
&lt;p&gt;If you have hit any of these three failures yourself, I would enjoy hearing about it; and if you would rather not hit them at all, this pipeline is part of the landing zone accelerator I am packaging. Connect with me on LinkedIn or reach out via ascode.nl.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Building an AI Landing Zone on Azure — Part 3: One front door for many models</title>
    <link href="https://ascode.nl/blog/part-3-one-front-door-for-many-models/" />
    <updated>2026-09-30T00:00:00Z</updated>
    <id>https://ascode.nl/blog/part-3-one-front-door-for-many-models/</id>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;This is part 3 of a five-part series on building an AI Landing Zone on Azure.
Part 1: Why every enterprise needs an AI gateway · Part 2: The platform underneath · Part 3: One front door for many models (this post) · Part 4: Metering every call · Part 5: Turning tokens into euros&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/part-3-one-front-door-for-many-models/imMTHLunpb-1672.avif 1672w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/part-3-one-front-door-for-many-models/imMTHLunpb-1672.webp 1672w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/part-3-one-front-door-for-many-models/imMTHLunpb-1672.jpeg&quot; alt=&quot;One front door for many models&quot; width=&quot;1672&quot; height=&quot;941&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;With the foundation from part 2 in place, this post is about the gateway itself. The job of the gateway is deceptively simple: be the only way to reach any AI model, and make every call look the same to the platform regardless of which model it goes to. Azure API Management does that job well, but only if you make a few decisions early and stick to them.&lt;/p&gt;
&lt;h2 id=&quot;which-apim-sku&quot;&gt;Which APIM SKU&lt;/h2&gt;
&lt;p&gt;The gateway is APIM &lt;strong&gt;Premium v2&lt;/strong&gt;, and it is &lt;strong&gt;VNet-injected&lt;/strong&gt;, not VNet-integrated. The distinction is a bit confusing, so let me explain the difference.&lt;/p&gt;
&lt;p&gt;With VNet &lt;em&gt;integration&lt;/em&gt; (available on Standard v2 and Premium v2), APIM stays publicly reachable on its own public IP; only its &lt;em&gt;outbound&lt;/em&gt; traffic to backends goes through your VNet. It is the right choice when you want a public API gateway that can reach private backends.&lt;/p&gt;
&lt;p&gt;With VNet &lt;em&gt;injection&lt;/em&gt; (Premium v2 only in the v2 tiers), APIM is deployed &lt;em&gt;into&lt;/em&gt; a subnet of your VNet and gets a private IP. There is no public IP for the gateway. Inbound and outbound both flow through the VNet, and the NSG on the APIM subnet actually controls who can reach it.&lt;/p&gt;
&lt;p&gt;For a regulated environment the choice was obvious, which is VNet injection. The gateway is reachable only from inside the spoke and peered networks, which in practice means the workloads on the AKS cluster and developer tooling on the private network, the developer portal and management endpoints are private, and the security team can point at an NSG rule as the control. The cost is that the APIM subnet needs service endpoints and NSG rules for everything APIM&#39;s control plane talks to (Entra ID, Event Hub, Key Vault, SQL, Storage, Azure Monitor), and that took some iteration to get right. Microsoft&#39;s documentation on the differences is quite good, have a look at https://learn.microsoft.com/en-us/azure/api-management/virtual-network-concepts.&lt;/p&gt;
&lt;p&gt;A big downside of this solution is obviously cost. Premium v2 is the most exepnsive SKU in the APIM arsenal, but in the case of a regulated financial, there is not much choice. I scaled down to 1 instance in the non-prod environment to keep costs at a &amp;quot;minimum&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;one-identity-for-the-gateway&quot;&gt;One identity for the gateway&lt;/h2&gt;
&lt;p&gt;APIM runs with a single &lt;strong&gt;user-assigned managed identity&lt;/strong&gt;. I prefer user-assigned over system-assigned, because the identity&#39;s lifecycle is independent of the resource. I can create it, assign roles, and then create the APIM instance, rather than the other way around, which makes Terraform dependency ordering much cleaner and means rebuilding APIM does not mess up role assignments.&lt;/p&gt;
&lt;p&gt;That identity has exactly two capabilities: &lt;strong&gt;Cognitive Services User&lt;/strong&gt; on the AI Services accounts (so it can call models and Content Safety), and &lt;strong&gt;Event Hubs Data Sender&lt;/strong&gt; on the usage Event Hub (so it can emit usage events, the subject of part 4). It cannot read from Event Hub, cannot touch Cosmos, cannot read Key Vault secrets it does not need. If the gateway were somehow made to misbehave, that list is the full extent of the damage.&lt;/p&gt;
&lt;p&gt;The identity&#39;s client ID is stored as an APIM named value, along with the audience for AI Services. Policy fragments reference those named values, which means the same policy XML works unchanged across environments.&lt;/p&gt;
&lt;h2 id=&quot;policy-fragments-as-the-unit-of-reuse&quot;&gt;Policy fragments as the unit of reuse&lt;/h2&gt;
&lt;p&gt;The single best decision in the gateway design was to build almost nothing directly into an API&#39;s policy and instead compose each API from &lt;strong&gt;policy fragments&lt;/strong&gt;. A fragment is a reusable chunk of policy XML that you include by ID. The pattern is borrowed from Microsoft&#39;s AI Hub Gateway solution accelerator, which I used as the reference architecture.&lt;/p&gt;
&lt;p&gt;The library ended up looking like this:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fragment&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aad-auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optionally validates an Entra ID bearer token from the caller, toggled by a named value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strip-caller-credentials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deletes the caller&#39;s &lt;code&gt;api-key&lt;/code&gt; and subscription key headers so they are never forwarded upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backend-mi-auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Acquires a token with the gateway&#39;s managed identity and sets it as the upstream &lt;code&gt;Authorization&lt;/code&gt; header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content-safety-aoai-inbound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sends the user prompt to Content Safety (prompt shield + harm analysis) in audit or enforce mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content-safety-mistral-outbound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same idea for OCR output, scanning the extracted text on the way back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;throttling-events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Emits a custom metric whenever a backend answers 429 (rate limiting), dimensioned by backend and subscriptiion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rate-limit-logging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logs rate-limit outcomes for the operations team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;usage-ingestion-aoai&lt;/code&gt; / &lt;code&gt;usage-ingestion-mistral&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Emit the usage record to Event Hub.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;An API&#39;s policy is then mostly a list of includes:&lt;/p&gt;
&lt;pre class=&quot;language-xml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-xml&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;inbound&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;base&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;include-fragment&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;fragment-id&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;aad-auth&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;include-fragment&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;fragment-id&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;strip-caller-credentials&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;include-fragment&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;fragment-id&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;content-safety-aoai-inbound&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;include-fragment&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;fragment-id&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;backend-mi-auth&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;azure-openai-token-limit&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;azure-openai-emit-token-metric&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;set-backend-service&lt;/span&gt; &lt;span class=&quot;token attr-name&quot;&gt;backend-id&lt;/span&gt;&lt;span class=&quot;token attr-value&quot;&gt;&lt;span class=&quot;token punctuation attr-equals&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;azure-openai-backend&lt;span class=&quot;token punctuation&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token tag&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;/&lt;/span&gt;inbound&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things make this work well in practice. First, the fragments are managed by Terraform from XML files in the repository, so a change to how caller credentials are stripped is one file, one merge request, and it takes effect on every API at once. Second, behaviour that differs per environment or per rollout stage, like whether Entra authentication is required or whether Content Safety blocks or only audits, is driven by named values, not by editing policy. Switching Content Safety from audit to enforce in production is a Terraform variable change.&lt;/p&gt;
&lt;h2 id=&quot;the-credential-handover&quot;&gt;The credential handover&lt;/h2&gt;
&lt;p&gt;The most important sequence in the gateway is what happens to authentication between the caller and the model, and it is the reason no application in the eco-system holds a model key.&lt;/p&gt;
&lt;p&gt;The caller authenticates to APIM. Depending on the product this is an APIM subscription key, an Entra ID token, or both. APIM validates that, and then the &lt;code&gt;strip-caller-credentials&lt;/code&gt; fragment deletes every credential header the caller sent. The &lt;code&gt;backend-mi-auth&lt;/code&gt; fragment then asks Entra ID for a token for the AI Services audience using APIM&#39;s own managed identity and puts it in the &lt;code&gt;Authorization&lt;/code&gt; header. The model backend sees a request from APIM&#39;s identity and nothing else.&lt;/p&gt;
&lt;p&gt;The consequence is that the model deployments have local key authentication effectively unused (result being they cannot be used directly), the consumer&#39;s identity is known to APIM but not to the model, and swapping the backend from one Azure OpenAI account to another, or from one model version to another, is a change to an APIM backend definition that no consumer sees. I used that twice during the build when a preview model version turned out to be unstable and had to be rolled back.&lt;/p&gt;
&lt;h2 id=&quot;many-backends-one-shape&quot;&gt;Many backends, one shape&lt;/h2&gt;
&lt;p&gt;Behind the gateway there are currently six backends. Azure OpenAI in the gpt-5 family serves three distinct use cases, each on its own AI Services account: a document-extraction workload and a summarisation workload that use Chat Completions, and a developer coding-assistant workload that uses the newer Responses API through the unified &lt;code&gt;/openai/v1&lt;/code&gt; endpoint. Mistral Document AI, served through an AI Foundry serverless endpoint, handles OCR, and there are two versions of it live so that consumers can be migrated one at a time. Content Safety is itself a backend, called from policy.&lt;/p&gt;
&lt;p&gt;Each backend is exposed as its own API with its own path (&lt;code&gt;/openai&lt;/code&gt;, &lt;code&gt;/chatgpt&lt;/code&gt;, &lt;code&gt;/mistral&lt;/code&gt;, and so on) and its own policy, but because the policies are built from the same fragments, every one of them has the same authentication handover, the same safety scanning where it applies, and the same usage emission.&lt;/p&gt;
&lt;p&gt;Where the backends differ, the policies differ in the obvious places. The OpenAI APIs use the built-in &lt;code&gt;azure-openai-token-limit&lt;/code&gt; policy for per-subscription tokens-per-minute limits and &lt;code&gt;azure-openai-emit-token-metric&lt;/code&gt; to push token counts to Application Insights. The Mistral API is billed per page, not per token, so it uses generic rate and quota limits per subscription instead, and its usage record carries pages processed and document size rather than token counts.&lt;/p&gt;
&lt;h2 id=&quot;streaming-or-not-you-must-choose&quot;&gt;Streaming or not, you must choose&lt;/h2&gt;
&lt;p&gt;There is one design decision in the gateway that I want to point out here because it comes back in a big way in part 4. Chat completions can be streamed, with the response arriving as a stream of server-sent events, or returned as a single JSON body. Whether APIM buffers the response is set per route in the backend section of the policy, and it has consequences for both the caller experience and for what the gateway can observe.&lt;/p&gt;
&lt;p&gt;If APIM buffers the response, it can read the whole body on the way out, including the usage block with exact token counts, but the caller only receives the answer once it is complete. If APIM does not buffer, the caller gets token-by-token output, but the outbound policy cannot read the body and the exact token counts are gone.&lt;/p&gt;
&lt;p&gt;I made it a per-route choice. The coding-assistant route, where cost accuracy matters more than a few seconds of latency, buffers and gets exact numbers. Interactive chat routes stream live and fall back to an estimate. How that fallback works is explained in part 4.&lt;/p&gt;
&lt;h2 id=&quot;products-as-the-unit-of-chargeback&quot;&gt;Products as the unit of chargeback&lt;/h2&gt;
&lt;p&gt;APIM products are what turn &amp;quot;a gateway&amp;quot; into &amp;quot;a platform with tenants&amp;quot;. Each consuming team or use case gets a product, such as &lt;em&gt;Code Assist&lt;/em&gt;, &lt;em&gt;Summariser&lt;/em&gt;, or a &lt;em&gt;POC&lt;/em&gt; product for experiments, and each product bundles the APIs that team is allowed to call. Subscriptions live under products, and every usage record the gateway emits carries both the subscription and the product name.&lt;/p&gt;
&lt;p&gt;That is the whole basis for chargeback in part 5. Finance does not need to know which model a team used; they need to know that the &lt;em&gt;Summariser&lt;/em&gt; product cost this much in September. The product is the cost centre. Approval-required products give the platform team a gate for new consumers, and the developer portal (private, of course) gives teams self-service for keys and documentation.&lt;/p&gt;
&lt;h2 id=&quot;observability-at-the-gateway&quot;&gt;Observability at the gateway&lt;/h2&gt;
&lt;p&gt;Every API&#39;s diagnostics go to Application Insights, and the policies emit custom metrics: token consumption per subscription, product and deployment; OCR requests per subscription; throttling events per backend. Those are what the operations team alerts on. When a backend starts returning 429 because an Azure OpenAI deployment ran out of capacity, the throttling metric spikes with the backend name as a dimension, and the platform team knows which quota to raise before anyone opens a ticket.&lt;/p&gt;
&lt;h2 id=&quot;next-the-pipeline-that-watches-every-call&quot;&gt;Next: the pipeline that watches every call&lt;/h2&gt;
&lt;p&gt;The gateway now does what it needs to do on the request path. Part 4 is about what happens after the response: the usage-emission fragment, the Event Hub, the Function App and Cosmos DB, and the bugs I tackled, each of which silently produced zero records while everything looked healthy.&lt;/p&gt;
&lt;p&gt;As always, if you are working on something similar, or want to see how this gateway pattern could land in your own Azure estate, connect with me on LinkedIn or reach out via ascode.nl.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Building an AI Landing Zone on Azure — Part 2: The platform underneath</title>
    <link href="https://ascode.nl/blog/part-2-the-platform-underneath/" />
    <updated>2026-09-22T00:00:00Z</updated>
    <id>https://ascode.nl/blog/part-2-the-platform-underneath/</id>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;This is part 2 of a five-part series on building an AI Landing Zone on Azure.
Part 1: Why every enterprise needs an AI gateway · Part 2: The platform underneath (this post) · Part 3: One front door for many models · Part 4: Metering every call · Part 5: Turning tokens into euros&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/part-2-the-platform-underneath/Szd-31FNab-1672.avif 1672w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/part-2-the-platform-underneath/Szd-31FNab-1672.webp 1672w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/part-2-the-platform-underneath/Szd-31FNab-1672.jpeg&quot; alt=&quot;The platform underneath: a small building above ground, a deep engineered foundation of network, identity, containers and version control below it&quot; width=&quot;1672&quot; height=&quot;941&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;In part 1 I described the shape of the AI Landing Zone I built for a regulated insurer and the principles behind it. Before I get to the interesting AI-specific parts, I need to cover the foundation, because the gateway and the metering pipeline only work the way they do because of decisions made down here. If you have built Azure landing zones before, some of this will be familiar; the AI-specific twist is mostly in how far the &amp;quot;no secrets, private everything&amp;quot; principle is pushed.&lt;/p&gt;
&lt;h2 id=&quot;network-a-spoke-with-a-very-strict-door-policy&quot;&gt;Network: a spoke with a very strict door policy&lt;/h2&gt;
&lt;p&gt;The zone is a single spoke VNet per environment, connected to the Virtual WAN hub. Internet security is enabled on the hub connection, which means all egress from the spoke is forced through the hub Azure Firewall. The firewall is also the VNet&#39;s DNS server, so private DNS resolution for all the private endpoints is handled centrally rather than per-spoke.&lt;/p&gt;
&lt;p&gt;Inside the spoke there are seven subnets, each with a single purpose:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subnet&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snet-apim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;APIM VNet injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snet-appgw&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Application Gateway (the only subnet with a route to the internet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snet-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AKS nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snet-db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL Flexible Server (delegated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snet-fn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Function App VNet integration (delegated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snet-plink&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every private endpoint in the zone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Every subnet gets its own NSG. Each NSG has specific allow rules for what that subnet legitimately needs, such as the APIM subnet reaching Entra ID, Event Hub, Key Vault and Azure Monitor, or the database subnet accepting connections from the app and gateway subnets, and then a deny-all inbound and deny-all outbound at priority 4096. Remember by default, all traffic within a VNet is allowed with priority 65000 which breaks the zero-trust principle we want to maintain. The one exception is the Application Gateway subnet, where the default deny breaks the platform&#39;s own management traffic (default rule with priority 65001 is in place for that but never reached); I learned that one the hard way and left a comment in the code so nobody &amp;quot;fixes&amp;quot; it back.&lt;/p&gt;
&lt;p&gt;Route tables send everything from the app, database, function and APIM subnets to the firewall as next hop. The Application Gateway is a Private Deployment (https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-private-deployment) and therefor is also routed to the firewall. You need to register the feature to make this work:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;az feature register &lt;span class=&quot;token parameter variable&quot;&gt;--name&lt;/span&gt; EnableApplicationGatewayNetworkIsolation &lt;span class=&quot;token parameter variable&quot;&gt;--namespace&lt;/span&gt; Microsoft.Network&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The practical consequence of all this is that nothing in the zone can talk to anything unless explicitly allowed. That is exactly what you want for a compliant workload.&lt;/p&gt;
&lt;h2 id=&quot;aks-private-cilium-and-no-node-pools-to-manage&quot;&gt;AKS: private, Cilium, and no node pools to manage&lt;/h2&gt;
&lt;p&gt;The cluster is a private AKS cluster running Azure Linux 3 on every node. I picked Azure Linux over Ubuntu for the smaller footprint and because Microsoft maintains the image end to end, which matters for the patching story in a regulated environment.&lt;/p&gt;
&lt;p&gt;Networking is Azure CNI in overlay mode with Cilium as both the network policy engine and the data plane, with Advanced Container Networking Services enabled for observability and security (this enables Hubble). Outbound type is user-defined routing, so node egress goes through the hub firewall like everything else; there is no NAT gateway or load balancer outbound path to worry about.&lt;/p&gt;
&lt;p&gt;The cluster has the OIDC issuer and workload identity enabled. This is the single most important setting on the cluster for this project, and I will come back to it below.&lt;/p&gt;
&lt;p&gt;For compute I made a deliberate choice to lean on node auto-provisioning (Karpenter) rather than static node pools. There is a small system node pool for critical add-ons, and a default pool that starts at zero nodes. Karpenter owns everything else, driven by a single &lt;code&gt;NodePool&lt;/code&gt; that says: E-series, on-demand, amd64, at least four vCPUs and eight gigabytes, and consolidate empty nodes after thirty minutes. The workloads that run here are memory-hungry document processors that are bursty by nature, so paying for idle nodes made no sense, and Karpenter does a much better job of picking the right SKU for a given pod shape than I would with a fixed pool.&lt;/p&gt;
&lt;p&gt;One gotcha worth mentioning: when you combine Karpenter with Cilium you must add the Cilium startup taint to the node template, otherwise pods get scheduled onto nodes before the Cilium agent is ready and they never get networking. It is documented, but it is easy to miss.&lt;/p&gt;
&lt;p&gt;Diagnostics for the API server, audit logs and controllers go to Log Analytics, and Defender for Containers is wired to the same workspace. The Azure Policy add-on is on, though I keep policies in audit mode where Terraform owns the configuration, because a policy that mutates resources out-of-band gives you drift in your Terraform state.&lt;/p&gt;
&lt;h2 id=&quot;gitops-argocd-as-the-only-way-things-get-onto-the-cluster&quot;&gt;GitOps: ArgoCD as the only way things get onto the cluster&lt;/h2&gt;
&lt;p&gt;Terraform builds the cluster and installs exactly two Helm charts: ArgoCD itself and the ArgoCD &amp;quot;apps&amp;quot; chart that bootstraps an app-of-apps. From that point on, Terraform never touches the cluster again. Everything that runs on it, whether that is cert-manager, external-dns, the ingress controller, VictoriaMetrics or the business applications, is defined in a separate Git repository and reconciled by ArgoCD.&lt;/p&gt;
&lt;p&gt;The bootstrap uses ApplicationSets with a Git directory generator: every directory under &lt;code&gt;charts/&amp;lt;namespace&amp;gt;/&amp;lt;chart&amp;gt;&lt;/code&gt; in the GitOps repository becomes an ArgoCD application, deployed into the namespace named by the path, with per-environment values files. Adding a new platform component is a directory and a merge request. There is also a separate ArgoCD project for merge-request preview environments, so developers get an ephemeral deployment of their branch without anyone touching production tooling. That is what makes ApplicationSets so powerful: it makes it very easy to deploy an entire application landscape in a short-lived branch.&lt;/p&gt;
&lt;p&gt;ArgoCD&#39;s UI is behind Entra ID single sign-on with group claims. Admins, developers and readers are Entra groups, and the mapping to ArgoCD roles is in the Helm values. Nobody has an ArgoCD local admin password, because the admin user is disabled at install.&lt;/p&gt;
&lt;p&gt;The reason I am strict about this split is auditability. The cluster&#39;s state is the Git history of one repository. If the risk department wants to know what changed in production last Tuesday, that question has a precise answer.&lt;/p&gt;
&lt;h2 id=&quot;ingress-and-certificates&quot;&gt;Ingress and certificates&lt;/h2&gt;
&lt;p&gt;Ingress is Application Gateway WAF v2 with the Application Gateway Ingress Controller (AGIC) running in-cluster. The WAF policy runs OWASP 3.2 plus the bot manager ruleset. Note that the Application Gateway fronts the applications on the cluster, not APIM; the AI gateway is only reachable from inside the private network.&lt;/p&gt;
&lt;p&gt;cert-manager issues Let&#39;s Encrypt certificates with a DNS-01 challenge against the client&#39;s public DNS zone, and external-dns keeps the private DNS zone in sync with what is running on the cluster. Both need to write to DNS zones in other subscriptions, which brings me to the part that ties the whole platform together.&lt;/p&gt;
&lt;h2 id=&quot;workload-identity-the-applications-never-see-a-secret&quot;&gt;Workload identity: the applications never see a secret&lt;/h2&gt;
&lt;p&gt;Every controller and every application on the cluster that needs to talk to Azure does so through a user-assigned managed identity and a federated credential. The pattern is always the same and it is entirely in Terraform:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a user-assigned managed identity for the component.&lt;/li&gt;
&lt;li&gt;Create a federated identity credential on it, with the cluster&#39;s OIDC issuer as the issuer and &lt;code&gt;system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;serviceaccount&amp;gt;&lt;/code&gt; as the subject.&lt;/li&gt;
&lt;li&gt;Assign that identity exactly the roles it needs, at the narrowest scope that works. This implements least-privilege.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Kubernetes side is just a service account annotated with the identity&#39;s client ID. When a pod using that service account asks the Azure SDK for a token, the SDK exchanges the pod&#39;s projected Kubernetes token for an Entra ID token. No client secret, no certificate, nothing to rotate, nothing to leak.&lt;/p&gt;
&lt;p&gt;Here is what that looks like in practice for this zone:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Identity gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AGIC&lt;/td&gt;
&lt;td&gt;Contributor on the Application Gateway, Reader on the node resource group, a custom &amp;quot;join VNet&amp;quot; role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager&lt;/td&gt;
&lt;td&gt;DNS Zone Contributor on the public zone (in the shared subscription)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;external-dns&lt;/td&gt;
&lt;td&gt;Private DNS Zone Contributor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AKS Key Vault CSI&lt;/td&gt;
&lt;td&gt;Key Vault Secrets reader on the workload&#39;s Key Vault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any AI workload app (frontend + backend)&lt;/td&gt;
&lt;td&gt;Key Vault Secrets User on its own Key Vault, Storage Blob Data Contributor on its own container&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Notice that each application gets two identities, one for the frontend and one for the backend, and each application gets its own Key Vault. That is more resources than strictly necessary, but it means the blast radius of a compromised frontend pod is one Key Vault and one storage container, and it means I can show a reviewer the exact permissions of any running process in a single table.&lt;/p&gt;
&lt;p&gt;Where an application genuinely needs a secret at runtime, such as the connection string for its PostgreSQL database or an OAuth client secret for its own SSO, the CSI driver pulls it from Key Vault into a Kubernetes secret, again authenticated with workload identity. The secret is created by Terraform, stored in Key Vault, and read by CSI. At no point does anyone gets or needs access to the secrets.&lt;/p&gt;
&lt;p&gt;The same principle carries across to the PaaS side. Storage accounts have shared key access disabled. Cosmos DB has local authentication disabled and is accessed through its data-plane RBAC roles. Event Hub is managed identity only. Key Vaults use RBAC authorisation with purge protection and 90-day soft delete. When I say &amp;quot;no shared keys anywhere&amp;quot; in part 1, this is what it means in practice: the option is turned off at the resource, so a future engineer cannot quietly turn it back on in an app setting.&lt;/p&gt;
&lt;h2 id=&quot;observability&quot;&gt;Observability&lt;/h2&gt;
&lt;p&gt;Cluster-level diagnostics go to Log Analytics and Application Insights, and Network Watcher flow logs with traffic analytics are enabled on the VNETs, which is unmissable if you want to do proper zero trust in your VNet. Without VNET flow logs, I would have never found out that even inter-cluster traffic (over the cluster CIDR) is also subject to NSGs. Inside the cluster, VictoriaMetrics handles metrics, logs and traces. I went with VictoriaMetrics over Azure Monitor managed Prometheus mainly for cost and control: the usage pipeline in part 4 pushes token metrics straight into it, and a Grafana dashboard on top gives the platform team a live view of gateway traffic without waiting for a Power BI refresh.&lt;/p&gt;
&lt;h2 id=&quot;how-it-is-all-deployed&quot;&gt;How it is all deployed&lt;/h2&gt;
&lt;p&gt;Everything described here is a single Terraform root module with in-repo modules for APIM, Cognitive Services and Cosmos DB, plus the client&#39;s shared modules for VNet, AKS, Key Vault, storage and PostgreSQL. One variable, &lt;code&gt;environment&lt;/code&gt;, selects non-production or production and drives subscriptions, IP ranges, retention and DNS domains from a locals map.&lt;/p&gt;
&lt;p&gt;The pipeline is GitLab CI using a shared multi-environment Terraform template. It authenticates to Azure with OIDC (also with managed identities, see my other blog post on https://ascode.nl/blog/gitlab-runners-aks/), so the pipeline has no service principal secret either, and it runs plan and apply per environment. Locally, pre-commit runs terraform fmt, tflint, terraform-docs, checkov and secret detection before anything can be committed. If you don&#39;t run pre-commit locally, the pipelien forces it and will fail if pre-commit fails. The README&#39;s resource inventory is generated by terraform-docs, which sounds like a small thing but means the documentation cannot drift from the code.&lt;/p&gt;
&lt;h2 id=&quot;what-this-foundation-buys-the-ai-layer&quot;&gt;What this foundation buys the AI layer&lt;/h2&gt;
&lt;p&gt;Everything in the next three parts sits on top of this. The APIM gateway is injected into &lt;code&gt;snet-apim&lt;/code&gt; and talks to model backends through private endpoints in &lt;code&gt;snet-plink&lt;/code&gt;. The Function App that ingests usage runs in &lt;code&gt;snet-fn&lt;/code&gt; with a user-assigned identity, exactly like the pods on the cluster. End users reach the frontend applications on AKS through the Application Gateway; the backend workloads on the cluster call the APIM gateway directly over the private network with an API subscription key, and never hold a model key.&lt;/p&gt;
&lt;p&gt;If you take one thing from this post, let it be this: the AI-specific parts of an AI Landing Zone are maybe thirty percent of the work. The rest is a properly built, identity-first, private-by-default platform, and if you skip it, you end up with an AI gateway that has a public IP and a connection string in an app setting, which is not much better than the whatever number of OpenAI resources you started with.&lt;/p&gt;
&lt;p&gt;Part 3 is where it gets AI-specific: the gateway itself, and how one APIM instance becomes a single front door for OpenAI and Mistral.&lt;/p&gt;
&lt;p&gt;If you are planning a similar platform and want to compare notes, or would like to know what it takes to get this running in your own estate, connect with me on LinkedIn or reach out via ascode.nl.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Building an AI Landing Zone on Azure - Part 1: Why every enterprise needs an AI gateway</title>
    <link href="https://ascode.nl/blog/part-1-why-an-ai-landing-zone/" />
    <updated>2026-09-15T00:00:00Z</updated>
    <id>https://ascode.nl/blog/part-1-why-an-ai-landing-zone/</id>
    <content type="html">&lt;!-- Series navigation --&gt;
&lt;blockquote&gt;
&lt;p&gt;This is part 1 of a five-part series on building an AI Landing Zone on Azure.
Part 1: Why every enterprise needs an AI gateway (this post) · Part 2: The platform underneath · Part 3: One front door for many models · Part 4: Metering every call · Part 5: Turning tokens into euros&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Over the last year I have been building and running an AI Landing Zone for a regulated financial services client operating under DORA. It took considerably more effort than I expected when I started, and most of that effort went into things that are not in any Microsoft quickstart. So I wanted to write it down, because I think the pattern is going to be needed by a lot more organisations than currently realise it.&lt;/p&gt;
&lt;p&gt;This first post is about the &lt;em&gt;why&lt;/em&gt; and the overall solution. The next four go deeper into the platform, the gateway, the metering pipeline and the chargeback reporting.&lt;/p&gt;
&lt;h2 id=&quot;the-problem-ai-adoption-is-happening-whether-you-plan-for-it-or-not&quot;&gt;The problem: AI adoption is happening whether you plan for it or not&lt;/h2&gt;
&lt;p&gt;If you work in a mid-size or large organisation, you have heard the phrase &amp;quot;we need to do something with AI&amp;quot; more than you want to. Somebody knows a great company they have worked with before, that can implement a PoC in a couple weeks with pre-configured resources (in IaC if you are &amp;quot;lucky&amp;quot;) expecting to integrate with your business systems with a click of a button. Nobody seems to care about security, compliance or governance; speed is the message.&lt;/p&gt;
&lt;p&gt;Three months later there are a number of these PoCs running. Nobody knows which ones are in production. Nobody knows what they cost, because the bill is one line item per resource and the resource is shared by three teams. Nobody knows what data is being sent to which model. And when the auditor, or in our case the DORA-driven ICT risk function, asks &amp;quot;which third-party AI services do you depend on, how are they authenticated and what happens if one goes down?&amp;quot; the honest answer is &amp;quot;we don&#39;t know&amp;quot;.&lt;/p&gt;
&lt;p&gt;We all hear this story quite often. It is not a technology failure, it is a platform failure: there was no solution available, so people built their own.&lt;/p&gt;
&lt;h2 id=&quot;what-landing-zone-means-here&quot;&gt;What &amp;quot;landing zone&amp;quot; means here&lt;/h2&gt;
&lt;p&gt;The general term landing zone means a pre-configured, governed environment that workloads can land in. An AI Landing Zone applies that idea specifically to the consumption of AI models. The goal I set was simple to state and hard to deliver:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every call to any AI model goes through one private front door, is authenticated with an identity rather than a key, is inspected for safety, is metered, and is attributed to the team that made it, in euros.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Everything else in the architecture follows from that sentence.&lt;/p&gt;
&lt;h2 id=&quot;the-architecture-in-one-picture&quot;&gt;The architecture in one picture&lt;/h2&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/part-1-why-an-ai-landing-zone/3ct2pg94z2-1527.avif 1527w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/part-1-why-an-ai-landing-zone/3ct2pg94z2-1527.webp 1527w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/part-1-why-an-ai-landing-zone/3ct2pg94z2-1527.png&quot; alt=&quot;&amp;quot;High Level Architecture&amp;quot;&quot; width=&quot;1527&quot; height=&quot;690&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Read it left to right. End users reach the frontend applications running on the Kubernetes cluster through an Application Gateway with a WAF in front. Those frontends talk to backend workloads in the same cluster (protected by Cilium network policies) and it is the backends, along with developer tooling on the private network, that call the AI gateway: Azure API Management in its most locked-down configuration, Premium v2, injected into the VNet, no public IP. Consumers authenticate to APIM with an API subscription key that identifies their product and team. APIM validates it, strips it, checks the request against Content Safety, acquires a token with its own managed identity and forwards the call to the right model backend over a private endpoint.&lt;/p&gt;
&lt;p&gt;On the way back, APIM emits a usage event: which subscription, which product, which model, how many prompt tokens, cached tokens, completion tokens, reasoning tokens, or for the OCR model, how many pages. That event lands in Event Hub, gets picked up by a small Function App, and is written to Cosmos DB. A rate card, also in Cosmos, prices it. Power BI turns it into a report that the team actually uses.&lt;/p&gt;
&lt;p&gt;The whole thing is Terraform. One codebase, two environments, deployed by a GitLab pipeline authenticating with OIDC. No click-ops.&lt;/p&gt;
&lt;h2 id=&quot;the-design-principles-and-why-each-one-is-there&quot;&gt;The design principles, and why each one is there&lt;/h2&gt;
&lt;p&gt;I want to spell out the principles because they explain almost every decision in the later posts. Each one exists because of a real requirement, not because it looked good on a slide.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Private endpoints everywhere.&lt;/strong&gt; Every PaaS service in the zone, from Cosmos DB to Event Hub to the model endpoints, has public network access disabled and is reachable only through a private endpoint in the VNet. Egress leaves through the hub firewall. For a financial company this is not optional, and it also turns out to be the source of the most interesting challenges, which I will get to in part 4.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No shared keys, anywhere.&lt;/strong&gt; Storage accounts, Cosmos, Event Hub, Key Vault: shared key authentication is disabled. Every component authenticates with a user-assigned managed identity, and every identity has exactly the roles it needs. The APIM identity can send to Event Hub but not read from it. The Function App identity can read from Event Hub and write to Cosmos, but cannot call a model. When a security reviewer asks &amp;quot;what can this thing do if compromised&amp;quot;, the answer fits on one line.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Caller credentials never reach the model.&lt;/strong&gt; The consumer authenticates to APIM with an Entra ID token or an APIM subscription key. APIM throws that away and authenticates to the backend with its own identity. The model endpoint has no idea the consumer exists. That means model keys do not exist in any application&#39;s configuration, and rotating or replacing a backend is invisible to consumers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Metering is a platform concern, not an application concern.&lt;/strong&gt; Applications do not report their own usage. The gateway observes it. That is the only way to get numbers you can trust for chargeback, because nobody can forget to instrument, and nobody can under-report.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cost is computed at query time, from data.&lt;/strong&gt; The pipeline stores raw usage, never prices. The rate card is a table. When Microsoft changes a price, or a new model appears with a separate rate for cache writes (which happened during the build), you update one row and history re-prices itself. I have seen enough &amp;quot;we hard-coded the price in the ingestion function&amp;quot; pipelines to know how that ends.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One codebase, many environments.&lt;/strong&gt; Non-production and production are the same Terraform with a different variable. Anything that differs per environment, like the Event Hub endpoint the APIM policy posts to, is a named value or a local, never a copy of the code.&lt;/p&gt;
&lt;h2 id=&quot;what-is-actually-in-the-ai-landing-zone&quot;&gt;What is actually in the AI Landing Zone&lt;/h2&gt;
&lt;p&gt;To make this concrete, here is the inventory. I will go through each of these in the coming parts, so this is just a quick summary.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;network&lt;/strong&gt; is a spoke VNet in a hub-and-spoke topology over Azure Virtual WAN, with dedicated subnets for the APIM injection, the Function App integration, private endpoints, the application gateway, the Kubernetes nodes, and the database. Every subnet has an NSG with an explicit deny-all at the bottom.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;platform&lt;/strong&gt; is a private AKS cluster running Azure Linux, Cilium, workload identity and Karpenter node auto-provisioning, with ArgoCD doing GitOps for everything that runs on it. Two internal applications live there today, each with their own identities, Key Vault and PostgreSQL database.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;gateway&lt;/strong&gt; is APIM Premium v2, VNet-injected, with a library of reusable policy fragments and a product per consuming team. It fronts Azure OpenAI (the gpt-5 family, both Chat Completions and the Responses API) and Mistral Document AI for OCR through an AI Foundry serverless endpoint. Supporting services the applications need, such as Azure Maps for geocoding, live in the zone under the same rules: private endpoint, local authentication disabled, managed identity only.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;metering pipeline&lt;/strong&gt; is APIM → Event Hub → Flex Consumption Function App (Python) → Cosmos DB, with a side channel into VictoriaMetrics for live dashboards.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;reporting&lt;/strong&gt; is a Cosmos rate card in EUR plus a Power BI / Fabric report that computes cost per product, per subscription, per model, per day.&lt;/p&gt;
&lt;p&gt;And the &lt;strong&gt;governance&lt;/strong&gt; wrapper is Terraform with pre-commit hooks running tflint, checkov and secret detection, a GitLab pipeline with OIDC federation to Azure, and remote state shared with the rest of the client&#39;s landing zones.&lt;/p&gt;
&lt;h2 id=&quot;was-it-worth-it&quot;&gt;Was it worth it?&lt;/h2&gt;
&lt;p&gt;Yes, and the reason is not the technology. The reason is what changed organisationally once it existed.&lt;/p&gt;
&lt;p&gt;Teams stopped asking for their own OpenAI resource, because getting an APIM subscription in the AI product is faster and comes with a dashboard. The finance team got a daily view of AI spend by user for the first time. The risk function got a single answer to &amp;quot;where do we call AI models from and how&amp;quot;, with a diagram they could put in the DORA register. And when a model version turned out to be unstable in preview, I swapped the backend and nobody outside the platform team noticed.&lt;/p&gt;
&lt;p&gt;None of that is possible when every team has its own key.&lt;/p&gt;
&lt;h2 id=&quot;what-comes-next&quot;&gt;What comes next&lt;/h2&gt;
&lt;p&gt;In part 2 I will cover the platform underneath: the network, the private AKS cluster, GitOps with ArgoCD, and how workload identity replaces every secret an application would normally need. Part 3 is the gateway itself. Part 4 is the metering pipeline. Part 5 is the chargeback model and the report.&lt;/p&gt;
&lt;p&gt;If you are looking at a similar problem, whether it is the regulatory angle, the cost angle or just the &amp;quot;we have eleven OpenAI resources and no idea what they do&amp;quot; angle, I am packaging this landing zone as an accelerator that can be deployed into an existing Azure estate in a matter of weeks rather than months. Feel free to connect with me on LinkedIn or reach out via ascode.nl if you would like to talk it through.&lt;/p&gt;
&lt;p&gt;Thanks for reading, see you in part 2.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Building a Cloud-Native PKI with HashiCorp Vault</title>
    <link href="https://ascode.nl/blog/vault/" />
    <updated>2026-04-06T00:00:00Z</updated>
    <id>https://ascode.nl/blog/vault/</id>
    <content type="html">&lt;p&gt;So here is a problem that sounds straightforward until you actually try to solve it: Microsoft Global Secure Access (GSA) needs a subordinate CA certificate to do TLS inspection, and getting that certificate signed turns out to be a rabbit hole that goes surprisingly deep.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It ends with Vault losing.&lt;/strong&gt; I will not make you read eleven minutes to find that out: GSA needs a certificate that is both a CA and carries a Server Auth extended key usage, and Vault cannot issue one. Its role endpoint will give you the EKU but not the CA bit; its intermediate endpoints will give you the CA bit but have no &lt;code&gt;ext_key_usage&lt;/code&gt; parameter at all. Not in the Terraform provider, and not in the API underneath it.&lt;/p&gt;
&lt;p&gt;I finished the build anyway. Partly because by that point I wanted to know whether the rest of it would work (it does) and partly because the interesting part of this was never the GSA certificate. It was a production-grade, HA HashiCorp Vault PKI on Kubernetes, with Azure Key Vault auto-unseal, Entra ID OIDC authentication and a two-tier CA hierarchy, deployed entirely as code with no clicking around in portals.&lt;/p&gt;
&lt;p&gt;So: the full build, then the blocker, then what I used instead. If you only came for the blocker fix, jump to &lt;a href=&quot;https://ascode.nl/blog/vault/#signing-the-gsa-csr-where-this-falls-apart&quot;&gt;signing the GSA CSR&lt;/a&gt;.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p class=&quot;k&quot;&gt;Update, September 2026&lt;/p&gt;
&lt;p&gt;Microsoft has since added a &lt;strong&gt;managed certificate&lt;/strong&gt; option for GSA TLS inspection, currently
in preview. Microsoft generates and operates a tenant-specific root CA for you, so the entire
bring-your-own-CA problem this post is about simply does not arise. If you are starting today, read
&lt;a href=&quot;https://ascode.nl/blog/vault/#update-microsoft-now-issues-the-ca-for-you&quot;&gt;the update at the end&lt;/a&gt; first, then decide whether
you still need any of this.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;
&lt;p&gt;GSA TLS inspection works by acting as a man-in-the-middle for HTTPS traffic. To do this, it needs a subordinate CA certificate - specifically one with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;basicConstraints: critical, CA:TRUE, pathlen:1
keyUsage: critical, keyCertSign, cRLSign
extendedKeyUsage: serverAuth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;CA:TRUE&lt;/code&gt; constraint is the key detail here. It means this certificate cannot be issued by a publicly trusted CA - Let&#39;s Encrypt, for example, will flat out refuse it. cert-manager will also refuse to even submit a CSR with &lt;code&gt;IsCA: true&lt;/code&gt; to Let&#39;s Encrypt.&lt;/p&gt;
&lt;p&gt;See &lt;a href=&quot;https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-transport-layer-security-settings#global-secure-access-admin-create-a-csr-and-upload-the-signed-certificate-for-tls-termination&quot;&gt;this&lt;/a&gt; link for more details on the actual challenge I was facing.&lt;/p&gt;
&lt;p&gt;So you need your own CA. And if you are going to run your own CA, you might as well do it properly.&lt;/p&gt;
&lt;h2 id=&quot;why-not-lets-encrypt&quot;&gt;Why Not Let&#39;s Encrypt?&lt;/h2&gt;
&lt;p&gt;Let&#39;s Encrypt only issues end-entity certificates. There is no workaround, no special flag, no exception process. This is by design - a publicly trusted CA issuing subordinate CA certificates to arbitrary customers would be a significant security problem.&lt;/p&gt;
&lt;p&gt;If you are running cert-manager and try to submit a CSR with &lt;code&gt;IsCA: true&lt;/code&gt;, you will get:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;admission webhook &amp;quot;webhook.cert-manager.io&amp;quot; denied the request:
spec.request: Invalid value: &amp;quot;...&amp;quot;: encoded CSR error:
IsCA true does not match expected value false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;cert-manager catches this before it even reaches Let&#39;s Encrypt. So that removes Let&#39;s Encrypt as an option. Still love cert-manager though!&lt;/p&gt;
&lt;h2 id=&quot;why-not-ezca-or-azure-key-vault&quot;&gt;Why Not EZCA or Azure Key Vault?&lt;/h2&gt;
&lt;p&gt;I had a look at EZCA (from Keytos) first. It is a managed CA service that integrates nicely with Azure and Entra ID. The problem is that it does not support the full set of extensions that GSA TLS inspection requires - specifically the &lt;code&gt;pathLenConstraint&lt;/code&gt; and the combination of &lt;code&gt;basicConstraints=critical,CA:TRUE&lt;/code&gt; with the required key usages. Another dead end.&lt;/p&gt;
&lt;p&gt;Azure Key Vault&#39;s managed CA integration (via DigiCert or GlobalSign) has the same problem. You do not get full control over the certificate profile, and subordinate CA issuance with custom constraints is not supported.&lt;/p&gt;
&lt;p&gt;ADCS would work, but it introduces a significant dependency on Windows infrastructure that I would rather not have. Adding AD services feels... like something from the previous decade. VM&#39;s to manage, managing services that are complex for even hardened veterans, and the lack of proper automation - no thanks.&lt;/p&gt;
&lt;h2 id=&quot;why-hashicorp-vault&quot;&gt;Why HashiCorp Vault?&lt;/h2&gt;
&lt;p&gt;Vault looked like it gave complete control over certificate profiles. The &lt;code&gt;pki/root/sign-intermediate&lt;/code&gt; endpoint lets you specify what goes into the signed certificate - &lt;code&gt;max_path_length&lt;/code&gt;, key usages, basic constraints - with no managed-service limitations in the way.&lt;/p&gt;
&lt;p&gt;That turned out to be true for everything except the one extension I needed. Hold that thought.&lt;/p&gt;
&lt;p&gt;It also runs perfectly well on Kubernetes, has a solid Terraform provider, integrates with Entra ID via OIDC, and supports Azure Key Vault for auto-unseal. The operational model fits well into a Kubernetes-native stack - which in my case means ArgoCD and cert-manager already running.&lt;/p&gt;
&lt;p&gt;Plus, I&#39;ve always wanted to use Vault but never had the right use case for it.&lt;/p&gt;
&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;
&lt;p&gt;Here is what we are building:&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/vault/1zgSgzG7Q_-1500.avif 1500w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/vault/1zgSgzG7Q_-1500.webp 1500w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/vault/1zgSgzG7Q_-1500.png&quot; alt=&quot;&amp;quot;Vault PKI Architecture&amp;quot;&quot; width=&quot;1500&quot; height=&quot;760&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;The two-tier CA hierarchy is important. The root CA private key never leaves Vault and is only accessible via a break-glass token. Day-to-day operations - signing workload certificates, signing the GSA CSR - go through the issuing CA. If the issuing CA is ever compromised, you revoke it and re-issue from the root without touching the root key.&lt;/p&gt;
&lt;h2 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;AKS cluster with OIDC issuer and Workload Identity enabled&lt;/li&gt;
&lt;li&gt;cert-manager already running (we use it for Vault&#39;s own TLS certificate)&lt;/li&gt;
&lt;li&gt;ArgoCD for GitOps deployment&lt;/li&gt;
&lt;li&gt;Helm, Terraform, vault CLI, az CLI, kubectl, jq&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;azure-key-vault-and-managed-identity&quot;&gt;Azure: Key Vault and Managed Identity&lt;/h2&gt;
&lt;p&gt;The first step is creating the Azure Key Vault and the (HSM-backed) key that Vault will use for auto-unseal. We also create a User-Assigned Managed Identity that the Vault pods will use to authenticate to Azure Key Vault via Workload Identity - no client secrets involved.&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_user_assigned_identity&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vault_unseal&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;id-vault-unseal&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;resource_group_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.vault.name
  &lt;span class=&quot;token property&quot;&gt;location&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.vault.location
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_key_vault&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vault_unseal&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;                       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;kv-vault-unseal&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;resource_group_name&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.vault.name
  &lt;span class=&quot;token property&quot;&gt;location&lt;/span&gt;                   &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.vault.location
  &lt;span class=&quot;token property&quot;&gt;sku_name&lt;/span&gt;                   &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;standard&quot;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;# HSM-backed keys require premium&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;enable_purge_protection&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;enable_rbac_authorization&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_key_vault_key&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;unseal&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vault-unseal-key&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;key_vault_id&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_key_vault.vault_unseal.id
  &lt;span class=&quot;token property&quot;&gt;key_type&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;RSA&quot;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# or RSA-HSM if you want to be really secure&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;key_size&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;4096&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;key_opts&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;wrapKey&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;unwrapKey&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;rotation_policy&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;automatic&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;time_before_expiry&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;P30D&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;expire_after&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;P90D&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;notify_before_expiry&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;P29D&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_role_assignment&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vault_unseal_crypto_user&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;scope&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_key_vault.vault_unseal.id
  &lt;span class=&quot;token property&quot;&gt;role_definition_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Key Vault Crypto User&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;principal_id&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_user_assigned_identity.vault_unseal.principal_id
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_federated_identity_credential&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vault_unseal&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vault-k8s-federated&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;resource_group_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.vault.name
  &lt;span class=&quot;token property&quot;&gt;parent_id&lt;/span&gt;           &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_user_assigned_identity.vault_unseal.id
  &lt;span class=&quot;token property&quot;&gt;audience&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;api://AzureADTokenExchange&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;issuer&lt;/span&gt;              &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.aks_oidc_issuer_url &lt;span class=&quot;token comment&quot;&gt;# the oidc issuer url of your AKS cluster&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;subject&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;system:serviceaccount:vault:vault&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The federated credential is what links the AKS service account to the managed identity. When the Vault pod starts, the Workload Identity webhook injects a signed OIDC token into the pod, which Azure exchanges for an access token for Key Vault. No secret, no rotation, no risk of credential leakage.&lt;/p&gt;
&lt;h2 id=&quot;deploying-vault-on-kubernetes&quot;&gt;Deploying Vault on Kubernetes&lt;/h2&gt;
&lt;p&gt;Vault is deployed via Helm, managed by ArgoCD. The key configuration elements in &lt;code&gt;values.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auto-unseal via Azure Key Vault:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;extraEnvs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  AZURE_TENANT_ID=xxxxxx
  AZURE_CLIENT_ID=xxxxxx

seal &quot;azurekeyvault&quot; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  vault_name = &quot;kv&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;vault&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;unseal&quot;
  key_name   = &quot;vault&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;unseal&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;key&quot;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With Workload Identity, &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt;, &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;, and &lt;code&gt;AZURE_FEDERATED_TOKEN_FILE&lt;/code&gt; are all injected automatically by the webhook. Vault&#39;s Azure SDK picks them up without any additional configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HA Raft with auto-pilot:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;storage &quot;raft&quot; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
path    = &quot;/vault/data&quot;
node_id = &quot;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;HOSTNAME&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&quot;

retry_join &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
leader_api_addr = &quot;https&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//vault&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;0.vault&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;internal&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;8200&quot;
&lt;span class=&quot;token punctuation&quot;&gt;...&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

autopilot &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
cleanup_dead_servers      = &quot;true&quot;
min_quorum                = 3
server_stabilization_time = &quot;10s&quot;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pod anti-affinity&lt;/strong&gt; to spread the three nodes across different Kubernetes nodes - standard for any HA setup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TLS&lt;/strong&gt; via a cert-manager &lt;code&gt;Certificate&lt;/code&gt; resource using a self-signed bootstrap issuer. After the PKI is operational, this gets switched to the Vault issuer.&lt;/p&gt;
&lt;h2 id=&quot;initialising-the-cluster&quot;&gt;Initialising the Cluster&lt;/h2&gt;
&lt;p&gt;With auto-unseal active, initialisation looks different from the classic Vault setup. There are no unseal keys - instead, Vault generates recovery keys that are only needed for disaster recovery scenarios.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl &lt;span class=&quot;token builtin class-name&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; vault vault-0 -- &lt;span class=&quot;token function&quot;&gt;sh&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;
  &lt;span class=&quot;token string&quot;&gt;&quot;VAULT_CACERT=/vault/userconfig/vault-tls/ca.crt &#92;
   vault operator init &#92;
   -recovery-shares=5 &#92;
   -recovery-threshold=3 &#92;
   -format=json&quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; vault-recovery-keys.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Store those recovery keys somewhere safe - &lt;strong&gt;not in git&lt;/strong&gt;. After init, vault-0 unseals automatically via Azure Key Vault. Then join the other nodes:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl &lt;span class=&quot;token builtin class-name&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; vault vault-1 -- vault operator raft &lt;span class=&quot;token function&quot;&gt;join&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;
  https://vault-0.vault-internal:8200

kubectl &lt;span class=&quot;token builtin class-name&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-n&lt;/span&gt; vault vault-2 -- vault operator raft &lt;span class=&quot;token function&quot;&gt;join&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;
  https://vault-0.vault-internal:8200&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both nodes will also auto-unseal after joining.&lt;/p&gt;
&lt;h2 id=&quot;pki-bootstrap-with-terraform&quot;&gt;PKI Bootstrap with Terraform&lt;/h2&gt;
&lt;p&gt;Rather than running shell scripts, the entire PKI setup is done in Terraform. Here is the resource chain:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Root CA&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_mount&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;pki_root&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;path&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;pki_root&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;type&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;pki&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;max_lease_ttl_seconds&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;315360000&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;# 10 years&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_pki_secret_backend_root_cert&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;root&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;backend&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_mount.pki_root.path
  &lt;span class=&quot;token property&quot;&gt;type&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;internal&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;common_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Internal Root CA&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;issuer_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;internal-root-ca&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ttl&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;87600h&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;key_type&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;rsa&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;key_bits&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;4096&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Issuing CA&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_mount&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;pki_int&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;path&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;pki_int&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;type&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;pki&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;max_lease_ttl_seconds&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;157680000&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;# 5 years&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_pki_secret_backend_intermediate_cert_request&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;issuing&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;backend&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_mount.pki_int.path
  &lt;span class=&quot;token property&quot;&gt;type&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;internal&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;common_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Internal Issuing CA&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_pki_secret_backend_root_sign_intermediate&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;issuing&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;backend&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_mount.pki_root.path
  &lt;span class=&quot;token property&quot;&gt;csr&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_pki_secret_backend_intermediate_cert_request.issuing.csr
  &lt;span class=&quot;token property&quot;&gt;issuer_ref&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_pki_secret_backend_root_cert.root.issuer_id
  &lt;span class=&quot;token property&quot;&gt;ttl&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;43800h&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;max_path_length&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_pki_secret_backend_intermediate_set_signed&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;issuing&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;backend&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_mount.pki_int.path
  &lt;span class=&quot;token property&quot;&gt;certificate&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; join(&lt;span class=&quot;token string&quot;&gt;&quot;&#92;n&quot;&lt;/span&gt;, &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    vault_pki_secret_backend_root_sign_intermediate.issuing.certificate,
    vault_pki_secret_backend_root_sign_intermediate.issuing.issuing_ca,
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;)
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key thing here is &lt;code&gt;type = &amp;quot;internal&amp;quot;&lt;/code&gt; on the root CA. Vault generates the key pair internally and it never leaves Vault. Combined with the policy setup that restricts &lt;code&gt;pki_root/*&lt;/code&gt; access to a break-glass token only, the root CA key is as isolated as you can get in a software PKI.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;signing-the-gsa-csr-where-this-falls-apart&quot;&gt;Signing the GSA CSR - where this falls apart&lt;/h2&gt;
&lt;p&gt;Line the four requirements up against &lt;code&gt;sign-intermediate&lt;/code&gt;, the endpoint you would obviously reach for:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;&lt;code&gt;sign-intermediate&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;basicConstraints=critical,CA:TRUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ automatic&lt;/td&gt;
&lt;td&gt;Hardcoded for this endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pathLenConstraint=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ via &lt;code&gt;max_path_length=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Explicit parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keyCertSign, cRLSign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ automatic&lt;/td&gt;
&lt;td&gt;Hardcoded for this endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Server Auth&lt;/code&gt; EKU&lt;/td&gt;
&lt;td&gt;❌ no such parameter&lt;/td&gt;
&lt;td&gt;And this one is not negotiable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Three out of four is not a passing grade when the fourth is mandatory.&lt;/p&gt;
&lt;p&gt;Extended Key Usage is normally an end-entity property - standard subordinate CA certificates do not carry
it - and Vault&#39;s PKI engine is built on that assumption. So the question becomes whether any other path
through Vault can produce a certificate that is &lt;em&gt;both&lt;/em&gt; a CA and carries &lt;code&gt;serverAuth&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I worked through all of them.&lt;/p&gt;
&lt;h3 id=&quot;every-route-and-where-each-one-stops&quot;&gt;Every route, and where each one stops&lt;/h3&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&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;&lt;code&gt;vault_pki_secret_backend_root_sign_intermediate&lt;/code&gt; with &lt;code&gt;ext_key_usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The field does not exist on the resource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vault_pki_secret_backend_intermediate_cert_request&lt;/code&gt; with &lt;code&gt;ext_key_usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The field does not exist on the resource - it supports &lt;code&gt;key_usage&lt;/code&gt; only, and has no &lt;code&gt;issuer_name&lt;/code&gt; either&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vault_generic_endpoint&lt;/code&gt; → &lt;code&gt;sign-intermediate&lt;/code&gt;, passing &lt;code&gt;ext_key_usage&lt;/code&gt; as raw JSON&lt;/td&gt;
&lt;td&gt;Accepted by Terraform, then ignored by the Vault API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vault_generic_endpoint&lt;/code&gt; → &lt;code&gt;sign-verbatim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Refuses a CSR with &lt;code&gt;isCA=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role endpoint &lt;code&gt;sign/gsa-subordinate&lt;/code&gt; with &lt;code&gt;ext_key_usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets the EKU, but will not set &lt;code&gt;basicConstraints CA:TRUE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Read that last pair together, because it is the whole story: &lt;strong&gt;the role endpoint gives you the EKU but not
the CA bit, and the intermediate endpoints give you the CA bit but not the EKU.&lt;/strong&gt; There is no endpoint
that gives you both.&lt;/p&gt;
&lt;p&gt;The CSR route closes off for the same reason. Even if you could get &lt;code&gt;ext_key_usage&lt;/code&gt; into the request -
and the Terraform resource gives you no field for it - &lt;code&gt;sign-intermediate&lt;/code&gt; would only carry CSR
extensions through with &lt;code&gt;use_csr_values=true&lt;/code&gt;, and that endpoint will not take &lt;code&gt;isCA=true&lt;/code&gt; from a CSR in
the first place.&lt;/p&gt;
&lt;h3 id=&quot;this-is-not-a-provider-gap&quot;&gt;This is not a provider gap&lt;/h3&gt;
&lt;p&gt;It is worth being precise about where the limitation actually lives, because &amp;quot;use the API directly
instead of Terraform&amp;quot; is the first thing anyone suggests. Dropping to &lt;code&gt;vault_generic_endpoint&lt;/code&gt; - or to
&lt;code&gt;curl&lt;/code&gt; - changes nothing. The &lt;code&gt;sign-intermediate&lt;/code&gt; endpoint does not accept &lt;code&gt;ext_key_usage&lt;/code&gt; as a
parameter at all. The gap is in Vault&#39;s server, and no amount of cleverness in the provider or the
request body works around a parameter the API does not implement.&lt;/p&gt;
&lt;p&gt;Upload the chain without &lt;code&gt;serverAuth&lt;/code&gt; and GSA rejects it, which is GSA behaving correctly: it published
the requirement and the certificate does not meet it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;So Vault was the wrong tool for this specific certificate.&lt;/strong&gt; Everything else in this post works. This
one thing does not, and it happened to be the reason I started.&lt;/p&gt;
&lt;h2 id=&quot;what-i-used-instead&quot;&gt;What I used instead&lt;/h2&gt;
&lt;p&gt;Nathan McNulty has published a script that does exactly this job: &lt;a href=&quot;https://github.com/nathanmcnulty/nathanmcnulty/blob/main/Entra/global-secure-access/Initialize-GSATLSInspection.ps1&quot;&gt;&lt;code&gt;Initialize-GSATLSInspection.ps1&lt;/code&gt;&lt;/a&gt;. It is worth reading even if you never run it, because it shows what the certificate has to look like when nothing is abstracting it away from you.&lt;/p&gt;
&lt;p&gt;The approach is different in a way that matters. Rather than asking a PKI product to emit a profile it does not want to emit, it constructs the certificate directly with .NET&#39;s &lt;code&gt;CertificateRequest&lt;/code&gt; and &lt;code&gt;X509SignatureGenerator&lt;/code&gt; (PowerShell 7.4+), builds the DER by hand, and performs the signing through the Azure Key Vault REST API. The root lives in Key Vault as a non-exportable RSA-HSM 4096 key with a ten-year validity, so the private key never leaves the HSM - which gets you most of what the Vault root gave me, without the cluster.&lt;/p&gt;
&lt;p&gt;The extensions it sets on the subordinate are the ones GSA actually wants:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;basicConstraints:     critical, CA:TRUE, pathlen:1
keyUsage:             critical, digitalSignature, keyCertSign, cRLSign
extendedKeyUsage:     serverAuth (1.3.6.1.5.5.7.3.1)   &amp;lt;- non-critical
subjectKeyIdentifier: derived from the public key
authorityKeyIdentifier: references the issuer SKI
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note &lt;code&gt;digitalSignature&lt;/code&gt; in the key usage - my original requirement block above left it out, copied from the documentation page, and it belongs there. The &lt;code&gt;pathlen:1&lt;/code&gt; is deliberate: it permits GSA to create the one issuing CA it needs beneath this root and prevents it going deeper.&lt;/p&gt;
&lt;p&gt;When the abstraction will not produce the profile, drop to the layer that will. Building DER by hand feels like a step backwards until you notice it is the only approach that puts you in charge of every byte.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;update-microsoft-now-issues-the-ca-for-you&quot;&gt;Update: Microsoft now issues the CA for you&lt;/h2&gt;
&lt;p&gt;Some time after I finished this, Microsoft added a
&lt;a href=&quot;https://learn.microsoft.com/en-us/entra/global-secure-access/how-to-transport-layer-security-settings-managed-certificate&quot;&gt;managed certificate option for TLS inspection&lt;/a&gt;.
It is in preview at the time of writing.&lt;/p&gt;
&lt;p&gt;The short version: Microsoft generates a tenant-specific root CA, operates it, holds the private key in
its own infrastructure and handles the whole certificate lifecycle including rotation. Default validity is
ten years, configurable shorter via the API. Everything this post fought with - producing a certificate
that is simultaneously a CA and carries Server Auth EKU, and finding a PKI willing to emit that profile -
is gone, because you never produce a certificate at all.&lt;/p&gt;
&lt;p&gt;What you still do yourself is the part that was never the hard bit: download the root CA from the TLS
inspection settings, push it to your endpoints with Intune or whatever MDM you run, verify it landed in
the Trusted Root store on a test device, then enable it and assign your inspection policies. Rotation is
the same loop: create the new certificate, deploy the new root, enable it, validate, delete the old one.&lt;/p&gt;
&lt;p&gt;You need an Entra Internet Access licence (trial or full) and the Global Secure Access Administrator role.&lt;/p&gt;
&lt;h3 id=&quot;so-was-any-of-this-wasted&quot;&gt;So was any of this wasted?&lt;/h3&gt;
&lt;p&gt;For the GSA use case specifically, if you are starting now: use the managed certificate. It is less work,
there is no key for you to lose, and the rotation story is better than anything you will hand-roll.&lt;/p&gt;
&lt;p&gt;There are still cases where you would not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is in preview.&lt;/strong&gt; Plenty of the organisations I work with cannot put a preview feature on the path of
all outbound TLS, and that alone settles it until it goes GA.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key custody.&lt;/strong&gt; The private key of a CA that can mint a certificate for any site your staff visit now
lives with Microsoft rather than with you. That is a reasonable trade for most, and a conversation with
your risk function for some, particularly where a control explicitly requires the organisation to
operate its own CA.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You needed a private CA anyway.&lt;/strong&gt; If workload certificates inside your clusters were already on the
roadmap, the Vault build stands on its own merits and GSA was only the thing that prompted it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last one is where I landed, and it is covered at the end.&lt;/p&gt;
&lt;h2 id=&quot;entra-id-oidc-authentication&quot;&gt;Entra ID OIDC Authentication&lt;/h2&gt;
&lt;p&gt;With the PKI working, the next step is replacing static Vault tokens with proper Entra ID authentication. The setup uses OIDC with the &lt;code&gt;provider = &amp;quot;azure&amp;quot;&lt;/code&gt; config, which tells Vault to read groups directly from the ID token rather than calling the userinfo endpoint. This matters because Microsoft Graph&#39;s userinfo endpoint is not reliably reachable from within a Kubernetes cluster, and the groups claim is in the ID token anyway.&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;vault_jwt_auth_backend&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;oidc&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;type&lt;/span&gt;               &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;oidc&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;path&lt;/span&gt;               &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;oidc&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;oidc_discovery_url&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;https://login.microsoftonline.com/&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;tenant_id&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;/v2.0&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;oidc_client_id&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_user_assigned_identity.vault_oidc.client_id
  &lt;span class=&quot;token property&quot;&gt;oidc_client_secret&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; data.azurerm_key_vault_secret.vault_oidc_secret.value
  &lt;span class=&quot;token property&quot;&gt;default_role&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;reader&quot;&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;provider_config&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;provider&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;azure&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that even though we use a managed identity as the OIDC client, Vault&#39;s OIDC auth backend still requires a &lt;code&gt;client_secret&lt;/code&gt;. The managed identity handles the unseal path - for the OIDC login flow, a client secret is unavoidable. We store it in Azure Key Vault and pull it via Terraform, so it never lives in tfvars or state in plaintext.&lt;/p&gt;
&lt;p&gt;Group membership is enforced through Vault external groups, not through &lt;code&gt;bound_claims&lt;/code&gt; on the role. The external group aliases map Entra group Object IDs to Vault groups, and policies are attached to the groups. Adding a new group is a one-liner in the Terraform locals:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;vault_groups&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&quot;msam-role-sg-vaultadmins&quot;&lt;/span&gt;   &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_policy.superadmin.name
    &lt;span class=&quot;token property&quot;&gt;&quot;msam-role-sg-wladmins&quot;&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_policy.pki_issuing_ca.name
    &lt;span class=&quot;token property&quot;&gt;&quot;msam-role-sg-pkioperators&quot;&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_policy.gsa_pki_admin.name
    &lt;span class=&quot;token property&quot;&gt;&quot;msam-role-sg-readers&quot;&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vault_policy.pki_read.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;for_each&lt;/code&gt; on the group resources picks this up automatically.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;getting-rid-of-the-root-token&quot;&gt;Getting Rid of the Root Token&lt;/h2&gt;
&lt;p&gt;Once OIDC is working and you have verified that your admin group can do everything it needs to, revoke the root token:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;vault token revoke &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token variable&quot;&gt;${VAULT_TOKEN}&lt;/span&gt;&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A superadmin policy with &lt;code&gt;capabilities = [&amp;quot;create&amp;quot;, &amp;quot;read&amp;quot;, &amp;quot;update&amp;quot;, &amp;quot;delete&amp;quot;, &amp;quot;list&amp;quot;, &amp;quot;sudo&amp;quot;, &amp;quot;patch&amp;quot;]&lt;/code&gt; on &lt;code&gt;path &amp;quot;*&amp;quot;&lt;/code&gt; covers almost everything the root token can do. The exceptions are a handful of system-level operations like &lt;code&gt;vault operator seal&lt;/code&gt; - for those, generate a new root token from recovery keys when needed. That process requires a quorum of recovery key holders, which is exactly the right amount of friction for that level of access.&lt;/p&gt;
&lt;p&gt;The root CA mount is isolated separately. The &lt;code&gt;pki-root-admin&lt;/code&gt; policy is never assigned to any OIDC role. The only way to touch the root CA is a break-glass token with a short TTL and use limit, generated manually by someone with appropriate access. All access is logged via Vault&#39;s audit log.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cert-manager-integration&quot;&gt;cert-manager Integration&lt;/h2&gt;
&lt;p&gt;cert-manager connects to Vault via the &lt;code&gt;vault-issuer&lt;/code&gt; &lt;code&gt;ClusterIssuer&lt;/code&gt;, using Kubernetes auth so there are no long-lived credentials:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;apiVersion&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cert&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;manager.io/v1
&lt;span class=&quot;token key atrule&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ClusterIssuer
&lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; vault&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;issuer
&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;vault&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; https&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//vault.vault.svc.cluster.local&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;8200&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; pki_int/sign/workloads
    &lt;span class=&quot;token key atrule&quot;&gt;caBundle&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &amp;lt;base64&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;root&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;ca&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;auth&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;kubernetes&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cert&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;manager
        &lt;span class=&quot;token key atrule&quot;&gt;mountPath&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; /v1/auth/kubernetes
        &lt;span class=&quot;token key atrule&quot;&gt;serviceAccountRef&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cert&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;manager&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The cert-manager policy only grants access to &lt;code&gt;pki_int/sign/workloads&lt;/code&gt; and &lt;code&gt;pki_int/issue/workloads&lt;/code&gt;. It cannot touch the root CA, cannot sign intermediate certificates, and cannot access any other secrets engine. Minimum viable permissions.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;A few things that cost me time that might save you some:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vault cannot issue a CA certificate carrying an EKU.&lt;/strong&gt; The role endpoint sets &lt;code&gt;ext_key_usage&lt;/code&gt; but not
&lt;code&gt;basicConstraints CA:TRUE&lt;/code&gt;; &lt;code&gt;sign-intermediate&lt;/code&gt; sets the CA bit but has no &lt;code&gt;ext_key_usage&lt;/code&gt; parameter;
&lt;code&gt;sign-verbatim&lt;/code&gt; refuses an &lt;code&gt;isCA=true&lt;/code&gt; CSR outright. Check that your chosen PKI can emit the exact
certificate profile you need before you build a cluster around it. Ten minutes reading endpoint
parameters at the start would have saved me the whole detour.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Just call the API directly&amp;quot; is not always an escape hatch.&lt;/strong&gt; My instinct when the Terraform resource
lacked a field was to drop to &lt;code&gt;vault_generic_endpoint&lt;/code&gt; and pass the parameter as raw JSON. Terraform
accepted it and Vault ignored it. When a field is missing from a well-maintained provider, consider that
it may be missing because the API has nothing to bind it to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verify the requirement list against a working implementation, not just the documentation.&lt;/strong&gt; The Microsoft docs page I worked from omits &lt;code&gt;digitalSignature&lt;/code&gt; from the key usage. A script that had actually been run in anger had it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The userinfo endpoint.&lt;/strong&gt; If you get &lt;code&gt;error reading /userinfo endpoint: EOF&lt;/code&gt; in Vault logs, add &lt;code&gt;provider_config = { provider = &amp;quot;azure&amp;quot; }&lt;/code&gt; to your OIDC backend config. This tells Vault to read claims from the ID token directly and skip the userinfo call entirely. The groups claim is in the ID token anyway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;External groups are not optional.&lt;/strong&gt; OIDC auth in Vault does not automatically map group memberships to policies. You need &lt;code&gt;vault_identity_group&lt;/code&gt; resources of type &lt;code&gt;external&lt;/code&gt;, with &lt;code&gt;vault_identity_group_alias&lt;/code&gt; resources mapping the Entra group Object IDs to those groups. Without this, users authenticate successfully but get no policies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Raft bootstrap sequence matters.&lt;/strong&gt; Only initialise vault-0. The other nodes join after init. If you try to initialise before vault-0 is unsealed, the other nodes will loop on &lt;code&gt;failed to get raft challenge&lt;/code&gt;. Let vault-0 unseal via Azure Key Vault first, then join the others.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Application Gateway health probes.&lt;/strong&gt; If you are fronting Vault with an Azure Application Gateway, the health probe needs to hit &lt;code&gt;/v1/sys/health&lt;/code&gt; and accept status codes &lt;code&gt;200-399,429&lt;/code&gt;. The default &lt;code&gt;200-399&lt;/code&gt; range will mark standby nodes as unhealthy. Also make sure the backend hostname override matches a SAN in Vault&#39;s TLS certificate, and upload the Vault CA certificate to App Gateway as a trusted root.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vault&#39;s OIDC backend requires a client secret.&lt;/strong&gt; Even with a managed identity as the OIDC client identity, Vault&#39;s OIDC auth backend requires &lt;code&gt;oidc_client_secret&lt;/code&gt; to be set. The Azure &lt;code&gt;vault_auth_backend&lt;/code&gt; type is for VM/workload MSI auth, not for human interactive login. Keep OIDC for humans, Kubernetes auth for workloads.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;where-this-ended-up&quot;&gt;Where this ended up&lt;/h2&gt;
&lt;p&gt;I tore the cluster down. It was built for one certificate it could not produce, nothing else in that environment needed a private CA at the time, and an HA Vault cluster is not something you leave running to admire. The GSA certificate came from Key Vault via the script above and TLS inspection has been running on it since.&lt;/p&gt;
&lt;p&gt;I do not regard the build as wasted. The two-tier hierarchy, the Key Vault auto-unseal, the OIDC group mapping and the cert-manager integration all worked exactly as designed, and the Terraform for all of it is reusable the next time a client genuinely needs a private CA - which, in a regulated estate issuing its own workload certificates, happens more often than you would think. What I would not do again is pick the PKI first and check the certificate profile second.&lt;/p&gt;
&lt;p&gt;If you are here because you are about to try the same thing, the order is now: use the managed certificate
if preview is acceptable to you, fall back to Key Vault and the script if it is not, and do not reach for
Vault for this particular certificate at all. What changed is not that the Vault build was wrong. It is
that GSA is no longer a reason to do it.&lt;/p&gt;
&lt;p&gt;The Terraform, Helm values and ArgoCD manifests behind this live in a private client repository, so there is no link to give you - everything load-bearing is inlined above. If you are putting something similar together and want to talk through the shape of it, &lt;a href=&quot;https://ascode.nl/contact/&quot;&gt;get in touch&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Until the next one!&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>GitLab CI/CD for Cloudflare Pages: Automated Deployments with Preview Environments</title>
    <link href="https://ascode.nl/blog/gitlab-cicd-cloudflare-pages/" />
    <updated>2026-01-30T00:00:00Z</updated>
    <id>https://ascode.nl/blog/gitlab-cicd-cloudflare-pages/</id>
    <content type="html">&lt;p&gt;Remember when the &lt;a href=&quot;https://ascode.nl/blog/new-website-2025/&quot;&gt;previous post&lt;/a&gt; ended with &lt;em&gt;&amp;quot;Automating deployments - When I find some more time...&amp;quot;&lt;/em&gt;? Well, I found the time. Let me walk you through how I set up a GitLab CI/CD pipeline that deploys this website to Cloudflare Pages, complete with preview environments on merge requests.&lt;/p&gt;
&lt;h2 id=&quot;the-goal&quot;&gt;The Goal&lt;/h2&gt;
&lt;p&gt;Simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Push to a branch, create a merge request → &lt;strong&gt;preview deploy&lt;/strong&gt; on a unique URL&lt;/li&gt;
&lt;li&gt;Merge to main → &lt;strong&gt;production deploy&lt;/strong&gt; to &lt;a href=&quot;https://ascode.nl&quot;&gt;ascode.nl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;No manual steps. No clicking around in dashboards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;You need two things from Cloudflare:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Account ID&lt;/strong&gt; - find this on the sidebar of any zone in your Cloudflare dashboard&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API Token&lt;/strong&gt; - create one at &lt;a href=&quot;https://dash.cloudflare.com/profile/api-tokens&quot;&gt;dash.cloudflare.com/profile/api-tokens&lt;/a&gt; with these permissions:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Account · Cloudflare Pages&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zone · Zone&lt;/td&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Add both as CI/CD variables in your GitLab project (Settings → CI/CD → Variables):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; - masked, protected&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt; - masked, protected&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wrangler (the Cloudflare CLI) picks these up automatically, no extra configuration needed.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline&quot;&gt;The Pipeline&lt;/h2&gt;
&lt;p&gt;The full &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; is surprisingly short. Two stages: build the static site with Eleventy, then deploy it with Wrangler.&lt;/p&gt;
&lt;h3 id=&quot;build-stage&quot;&gt;Build Stage&lt;/h3&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; build
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; deploy

&lt;span class=&quot;token key atrule&quot;&gt;variables&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;CLOUDFLARE_PROJECT_NAME&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ascode&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;web
  &lt;span class=&quot;token key atrule&quot;&gt;NODE_VERSION&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;20&quot;&lt;/span&gt;

&lt;span class=&quot;token key atrule&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; build
  &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; node&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;NODE_VERSION&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;alpine
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; npm ci
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; npx @11ty/eleventy
  &lt;span class=&quot;token key atrule&quot;&gt;artifacts&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;paths&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; _site/
    &lt;span class=&quot;token key atrule&quot;&gt;expire_in&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; 1 hour
  &lt;span class=&quot;token key atrule&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $CI_MERGE_REQUEST_IID
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing fancy. Install dependencies, run Eleventy, and pass the &lt;code&gt;_site/&lt;/code&gt; output as an artifact to the deploy stage. The &lt;code&gt;rules&lt;/code&gt; ensure it runs on both merge requests and pushes to main.&lt;/p&gt;
&lt;h3 id=&quot;preview-deploys&quot;&gt;Preview Deploys&lt;/h3&gt;
&lt;p&gt;This is where it gets nice:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;deploy:preview&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; deploy
  &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; node&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;NODE_VERSION&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;alpine
  &lt;span class=&quot;token key atrule&quot;&gt;needs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; build
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; npx wrangler pages deploy _site/
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;project&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;name=&quot;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CLOUDFLARE_PROJECT_NAME&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&quot;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;branch=&quot;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CI_MERGE_REQUEST_SOURCE_BRANCH_NAME&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&quot;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;commit&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;hash=&quot;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CI_COMMIT_SHORT_SHA&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&quot;
  &lt;span class=&quot;token key atrule&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; preview/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
    &lt;span class=&quot;token key atrule&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; https&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CI_MERGE_REQUEST_SOURCE_BRANCH_NAME&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;.$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CLOUDFLARE_PROJECT_NAME&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;.pages.dev
  &lt;span class=&quot;token key atrule&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $CI_MERGE_REQUEST_IID&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every merge request gets its own preview URL based on the branch name. Create a branch called &lt;code&gt;new-blog-post&lt;/code&gt;, and you get &lt;code&gt;https://new-blog-post.ascode-web.pages.dev&lt;/code&gt;. The GitLab environment link shows up directly in the merge request, so reviewers can click through to the preview without having to figure out the URL.&lt;/p&gt;
&lt;p&gt;Wrangler handles everything - if the Cloudflare Pages project does not exist yet, it creates it. It uploads the files, configures the deployment, and gives you the URL. No need to pre-configure anything in the Cloudflare dashboard.&lt;/p&gt;
&lt;h3 id=&quot;stopping-preview-environments&quot;&gt;Stopping Preview Environments&lt;/h3&gt;
&lt;p&gt;When a merge request is merged or closed, the preview environment should be cleaned up in GitLab:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;stop:preview&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; deploy
  &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; node&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;NODE_VERSION&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;alpine
  &lt;span class=&quot;token key atrule&quot;&gt;needs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; echo &quot;Preview environment stopped&quot;
  &lt;span class=&quot;token key atrule&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; preview/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
    &lt;span class=&quot;token key atrule&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; stop
  &lt;span class=&quot;token key atrule&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $CI_MERGE_REQUEST_IID
      &lt;span class=&quot;token key atrule&quot;&gt;when&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; manual
      &lt;span class=&quot;token key atrule&quot;&gt;allow_failure&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that this only stops the GitLab environment - the Cloudflare preview deployment itself stays around until Cloudflare cleans it up (they do, eventually). If you want to actively delete it, you can add a &lt;code&gt;wrangler pages deployment delete&lt;/code&gt; command, but I have not found the need for that.&lt;/p&gt;
&lt;h3 id=&quot;production-deploy&quot;&gt;Production Deploy&lt;/h3&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;deploy:production&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; deploy
  &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; node&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;NODE_VERSION&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;alpine
  &lt;span class=&quot;token key atrule&quot;&gt;needs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; build
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; npx wrangler pages deploy _site/
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;project&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;name=&quot;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CLOUDFLARE_PROJECT_NAME&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&quot;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;branch=&quot;main&quot;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;commit&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;hash=&quot;$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;CI_COMMIT_SHORT_SHA&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&quot;
  &lt;span class=&quot;token key atrule&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; production
    &lt;span class=&quot;token key atrule&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; https&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//ascode.nl
  &lt;span class=&quot;token key atrule&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Merge to main → deploys to production. That&#39;s it. The &lt;code&gt;--branch=&amp;quot;main&amp;quot;&lt;/code&gt; flag tells Cloudflare this is the production deployment, which means it goes live on your custom domain.&lt;/p&gt;
&lt;h2 id=&quot;a-note-on-wrangler-and-npx&quot;&gt;A Note on Wrangler and npx&lt;/h2&gt;
&lt;p&gt;You might notice I am using &lt;code&gt;npx wrangler&lt;/code&gt; instead of installing it as a project dependency. This keeps the blog repository clean - Wrangler is a deployment tool, not a site dependency. The &lt;code&gt;npx&lt;/code&gt; command downloads it on the fly in the CI environment, which adds a few seconds to the deploy stage but keeps &lt;code&gt;package.json&lt;/code&gt; focused on what matters: Eleventy and its plugins.&lt;/p&gt;
&lt;p&gt;If the download time bothers you, add &lt;code&gt;wrangler&lt;/code&gt; to your &lt;code&gt;devDependencies&lt;/code&gt; and it will be installed during &lt;code&gt;npm ci&lt;/code&gt; in the build stage. Either way works.&lt;/p&gt;
&lt;h2 id=&quot;the-result&quot;&gt;The Result&lt;/h2&gt;
&lt;p&gt;The workflow is now:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a branch and write content&lt;/li&gt;
&lt;li&gt;Push and open a merge request&lt;/li&gt;
&lt;li&gt;Pipeline builds and deploys a preview - check the result&lt;/li&gt;
&lt;li&gt;Merge to main&lt;/li&gt;
&lt;li&gt;Pipeline deploys to production - live on &lt;a href=&quot;https://ascode.nl&quot;&gt;ascode.nl&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No more manual uploads to Cloudflare Pages. No more &amp;quot;when I find some more time.&amp;quot; Just git push and go.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Building a Raspberry Pi5 Cluster with Talos Linux and Cilium</title>
    <link href="https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/" />
    <updated>2025-10-23T00:00:00Z</updated>
    <id>https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/</id>
    <content type="html">&lt;p&gt;You know when you want to have an environment to play with, test some things, and do some general tinkering around?&lt;/p&gt;
&lt;p&gt;Of course you can run this on your laptop (Very Happy Macbook Pro M4 Max owner!) but where is the fun in that? So, I decided to build a Kubernetes cluster consisting of Raspberry Pi&#39;s. Now I am the kind of guy that does not go half way, so this cluster not only needs to be small and quiet, but also powerful. Which means I am building this with not a single Raspberry Pi board, but a full-fledged cluster with high-availability and everything.&lt;/p&gt;
&lt;p&gt;Here is a picture of the end result:&lt;/p&gt;
&lt;p&gt;Let me take you through how I build this!&lt;/p&gt;
&lt;h2 id=&quot;table-of-contents&quot;&gt;Table of Contents&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#kit-list&quot;&gt;Kit List&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#putting-it-all-together&quot;&gt;Putting it all together&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#installing-talos&quot;&gt;Installing Talos&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#preparing-the-raspberry-pi&quot;&gt;Preparing the Raspberry Pi&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#flashing-the-os-to-an-sd-card&quot;&gt;Flashing the OS to an SD Card&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#preparing-talos-config&quot;&gt;Preparing Talos Config&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#install-talos-on-the-first-node&quot;&gt;Install Talos on the first node&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#install-talos-on-other-nodes&quot;&gt;Install Talos on other nodes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#install-cilium&quot;&gt;Install Cilium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/#whats-next&quot;&gt;What&#39;s next?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;kit-list&quot;&gt;Kit list&lt;/h2&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to view complete shopping list&lt;/strong&gt;&lt;/summary&gt;
&lt;p&gt;For those of you that want to know and possibly want to build this themselves, here is the full kit list:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;th&gt;Cost at time of writing of article&lt;/th&gt;
&lt;th&gt;Total Cost&lt;/th&gt;
&lt;th&gt;Where ordered?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raspberry Pi 5 - 16GB&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;€ 131,50&lt;/td&gt;
&lt;td&gt;€ 526&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.raspberrystore.nl/PrestaShop/nl/raspberry-pi-5/639-raspberry-pi-5-16gb-5056561803333.html&quot;&gt;RaspberryPi Store&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512GB Raspberry Pi NVMe SSD, 22x30mm&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;€ 54,95&lt;/td&gt;
&lt;td&gt;€ 219,8&lt;/td&gt;
&lt;td&gt;RaspberryPi Store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sandisk 32Gb MicroSDHC Extreme PRO&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;€ 16,95&lt;/td&gt;
&lt;td&gt;€ 16,95&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.raspberrystore.nl/PrestaShop/nl/storage-sd-kaarten-en-ssd-s/206-sandisk-32gb-microsdhc-extreme-pro-r100-w90-c10-u-0619659155414.html&quot;&gt;RaspberryPi Store&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geekworm Cooler for Raspberry Pi 5, passive aluminium heatsink&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;€ 9&lt;/td&gt;
&lt;td&gt;€ 36&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.amazon.nl/dp/B0DDPWYLY1&quot;&gt;Amazon&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M.2 NVME M-Key and PoE+ HAT for RPi5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;€ 36,29&lt;/td&gt;
&lt;td&gt;€ 145,16&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.kiwi-electronics.com/nl/m-2-nvme-m-key-en-poeplus-hat-voor-rpi5-20117?search=3362&quot;&gt;Kiwi El4ctronics&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubiquiti UniFi Flex 2.5G PoE&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;€ 199,65&lt;/td&gt;
&lt;td&gt;€ 199,65&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.kommago.nl/p/ubiquiti-unifi-flex-2-5g-poe-8-switch-compacte-8-poorts-managed-2-5g-switch-8x-poe-88004&quot;&gt;KommaGo&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubiquiti AC Adapter 210W&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;€ 105,88&lt;/td&gt;
&lt;td&gt;€ 105,88&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.kommago.nl/p/ubiquiti-ac-adapter-210w-voor-usw-ultra-en-usw-flex-2-5g-8-poe-88056&quot;&gt;KommaGo&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C4Labs 8-Slot Cloudlet Cluster Case&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;€ 72,74&lt;/td&gt;
&lt;td&gt;€ 72,74&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://thepihut.com/products/8-slot-cloudlet-cluster-case?variant=37531942387907&quot;&gt;The Pi Hut&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grand total&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;€ 1249,44 incl. BTW&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;/details&gt;
&lt;p&gt;I realise this list does not fit everyone&#39;s budget, but it is what I went with. Yes, you could get a cheaper PoE switch, but I love Ubiquiti and it is my go-to choice for networking gear. You could get Raspberry Pi4 with just SD Cards in stead of SSD&#39;s, of course you can! That is the beauty of these projects, you can do it however you want! In this case, this is my blog and my project so this is what I used.&lt;/p&gt;
&lt;p&gt;The cluster case I ordered from a company in the UK, and if you are not located in the UK then, it gets a bit more expensive - like a lot more expensive. That was a bit of an oversight from my part, there is a good alternative here: https://www.amazon.com/GeeekPi-Cabinet-Equipment-RackMate-Rackmount/dp/B0DPGZPTPP?th=1. I don&#39;t own a 3D printer but if you do, there are some very good cluster cases out there for you to print yourself. I also removed the fans from the case cause I hate noise - that is where the passive heatsink comes in.&lt;/p&gt;
&lt;p&gt;Lastly I had to purchase some network cables to make it all look nice.&lt;/p&gt;
&lt;h2 id=&quot;putting-it-all-together&quot;&gt;Putting it all together&lt;/h2&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/B33gVAuLYw-600.avif 600w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/B33gVAuLYw-600.webp 600w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/building-a-raspberrypi-kubernetes-cluster/B33gVAuLYw-600.png&quot; alt=&quot;Pi5 Cluster&quot; width=&quot;600&quot; height=&quot;800&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2 id=&quot;installing-talos&quot;&gt;Installing Talos&lt;/h2&gt;
&lt;p&gt;For those of you that are unfamiliar with Talos: &lt;a href=&quot;https://docs.siderolabs.com/talos/v1.11/overview/what-is-talos&quot;&gt;Talos&lt;/a&gt; is a Linux distro that is optimised for Kubernetes and is secure, immutable and minimal by default, limiting your Kubernetes attack surface. All system management is done via an API - there is no shell or interactive console.&lt;/p&gt;
&lt;p&gt;If you are interested in the philosophy behind this, have a read &lt;a href=&quot;https://docs.siderolabs.com/talos/v1.11/learn-more/philosophy&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Installing Talos was a little less easy than I expected, mainly because the Raspberry Pi5 is not officially supported yet. Here is an overview of the (non-Pi5) supported Raspberry Pi series: (https://docs.siderolabs.com/talos/v1.11/platform-specific-installations/single-board-computers/rpi_generic). After a quick search I found a Github repo &lt;a href=&quot;https://github.com/talos-rpi5/talos-builder&quot;&gt;here&lt;/a&gt; that ported the official Talos image to work on Raspberry Pi5. Give them a star to support their work!&lt;/p&gt;
&lt;h3 id=&quot;preparing-the-raspberry-pi&quot;&gt;Preparing the Raspberry Pi&lt;/h3&gt;
&lt;p&gt;I had a previous Raspberry Pi OS Lite installed with K3S so my Pi had to be cleared out first:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;umount&lt;/span&gt; /dev/nvme0n1p?
&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; wipefs &lt;span class=&quot;token parameter variable&quot;&gt;--all&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--force&lt;/span&gt; /dev/nvme0n1p?
&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; wipefs &lt;span class=&quot;token parameter variable&quot;&gt;--all&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--force&lt;/span&gt; /dev/nvme0n1
&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;dd&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;/dev/zero &lt;span class=&quot;token assign-left variable&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;/dev/nvme0n1 &lt;span class=&quot;token assign-left variable&quot;&gt;bs&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1024&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also, you want to make sure the boot order is changed so that the Pi attempts NVMe boot first. This does assume you already have an OS on your Pi5:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Open the Raspberry Pi configuration editor&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; raspi-config

&lt;span class=&quot;token comment&quot;&gt;# Navigate to Advanced Options &gt; Boot &gt; Boot Order&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# Highlight &#39;NVMe/USB Boot&#39; and press enter&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# Follow the prompts&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you do not have an OS on your Pi5 yet, you can flash your SD card using the &lt;a href=&quot;https://www.raspberrypi.com/software/&quot;&gt;Raspberry Pi Imager&lt;/a&gt;. For OS, choose &lt;code&gt;Misc utility images&lt;/code&gt;, &lt;code&gt;Bootloader (Pi5 Family)&lt;/code&gt;, &lt;code&gt;NVMe/USB Boot&lt;/code&gt; and start your Pi5 using this card. Once the green light flashes, the Pi5 is configured to boot from NVMe first.&lt;/p&gt;
&lt;h3 id=&quot;flashing-the-os-to-an-sd-card&quot;&gt;Flashing the OS to an SD Card&lt;/h3&gt;
&lt;p&gt;On a side note, make sure you use specific fast SD cards as the Pi5 does not like slow cards. The type from the shopping list is good. On another note, I used 1 card. You can get more cards if you are impatient and do not want to re-flash in between tasks.&lt;/p&gt;
&lt;p&gt;Here is where it gets a little more interesting. You would think that we flash the Talos OS onto the SD card, right? Well, no, we don&#39;t. After spending quite a few hours, I found out that even when you select the NVMe card as the install disk, it just would not install to it. Here is the associated Talos config (that, to be clear, did not work for me):&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;machine&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;install&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;disk&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; /dev/nvme0n1 &lt;span class=&quot;token comment&quot;&gt;# The disk used for installations.&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ghcr.io/siderolabs/installer&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;v1.11.1 &lt;span class=&quot;token comment&quot;&gt;# Allows for supplying the image used to perform the installation.&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;wipe&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# Indicates if the installation disk should be wiped at installation time.&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I worked around the problem by flashing the SD Card with Raspberry Pi OS using the Raspberry Pi Imager &lt;code&gt;Raspberry Pi OS (other)&lt;/code&gt;, &lt;code&gt;Raspberry Pi OS Lite (64 bit)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I then used this card to boot the first Pi into Pi OS and then downloading the Talos OS:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;curl&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-LO&lt;/span&gt; https://github.com/talos-rpi5/talos-builder/releases/download/v1.11.1-pre/metal-arm64.raw.zst
unzstd metal-arm64.raw.zst&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You only need to do this once. Make sure you set the hostname and then use the downloaded image to flash your NVMe disk. Repeat this on each of your nodes:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; hostnamectl set-hostname rpi5-1
&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;dd&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;metal-arm64.raw &lt;span class=&quot;token assign-left variable&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;/dev/nvme0n1 &lt;span class=&quot;token assign-left variable&quot;&gt;bs&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;4M &lt;span class=&quot;token assign-left variable&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;progress&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure you set a unique hostname for each node! Shutdown the node after this step is complete, remove the SD card and power on the node again; it should now boot from the SSD drive.&lt;/p&gt;
&lt;h3 id=&quot;preparing-talos-config&quot;&gt;Preparing Talos Config&lt;/h3&gt;
&lt;p&gt;To perform any kind of management on Talos, you need to communicate (securely) with the API. In this blog, I use &lt;code&gt;talosctl&lt;/code&gt; for management.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;brew &lt;span class=&quot;token function&quot;&gt;install&lt;/span&gt; siderolabs/tap/talosctl&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once you have talosctl installed, we can create the config to deploy the cluster. We are going to install Cilium, for which we need a Talos Patch to prepare Talos for it. Specifically, no cni should be enabled and kube-proxy will be disabled as it will be replaced by Cilium. The last line will allow workloads to be scheduled on control plane nodes, which is fine for this lab environment but in a production environment maybe not such a good idea.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;cat&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt;EOF &lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; ./cilium-patch.yaml
  machine:
    install:
      disk: /dev/nvme0
      image: ghcr.io/siderolabs/installer:v1.11.3
      wipe: &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
    network:
      interfaces:
      - deviceSelector:
          physical: &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
        dhcp: &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;token comment&quot;&gt;# view more in the complete command sequence&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see the complete command sequence&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;cat&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;EOF&lt;span class=&quot;token bash punctuation&quot;&gt; &lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; ./cilium-patch.yaml&lt;/span&gt;
  machine:
    install:
      disk: /dev/nvme0
      image: ghcr.io/siderolabs/installer:v1.11.3
      wipe: true
    network:
      interfaces:
      - deviceSelector:
          physical: true
        dhcp: true
        vip:
          ip: 10.10.10.15
  cluster:
    network:
      cni:
        name: none
    proxy:
      disabled: true
  allowSchedulingOnControlPlanes: true
EOF&lt;/span&gt;

&lt;span class=&quot;token builtin class-name&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;CONTROL_PLANE_IP&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;10.10&lt;/span&gt;.10.11
&lt;span class=&quot;token function&quot;&gt;mkdir&lt;/span&gt; _out
talosctl gen config talosrpi5 https://&lt;span class=&quot;token variable&quot;&gt;$CONTROL_PLANE_IP&lt;/span&gt;$:6443 --output-dir _out --config-patch @cilium-patch.yaml&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;This will create 3 files in the &lt;code&gt;_out&lt;/code&gt; directory:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;talosconfig - this is the unique context for your cluster that contains the certificates for authentication&lt;/li&gt;
&lt;li&gt;controlplane.yaml - for configuring control plane nodes&lt;/li&gt;
&lt;li&gt;worker.yaml - for configuring worker nodes&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Note:&lt;/strong&gt; You want your cluster nodes to have a fixed IP address. I found out that starting with DHCP and then setting a fixed IP as part of configuration basically broke the setup. The solution was to make a DHCP reservation for each node in your cluster, making sure that the nodes will always get the same IP address from DHCP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;install-talos-on-the-first-node&quot;&gt;Install Talos on the first node&lt;/h3&gt;
&lt;p&gt;We are now ready to configure the first cluster node. Make sure the node is up and has the correct IP address; if you hooked up a display to your RPi then you should see this on screen.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token builtin class-name&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;CONTROL_PLANE_IP&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;10.10&lt;/span&gt;.10.11
talosctl apply-config &lt;span class=&quot;token parameter variable&quot;&gt;--insecure&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--nodes&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$CONTROL_PLANE_IP&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--file&lt;/span&gt; _out/controlplane.yaml
&lt;span class=&quot;token builtin class-name&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;TALOSCONFIG&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;_out/talosconfig&quot;&lt;/span&gt;
talosctl config endpoint &lt;span class=&quot;token variable&quot;&gt;$CONTROL_PLANE_IP&lt;/span&gt;
talosctl config &lt;span class=&quot;token function&quot;&gt;node&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$CONTROL_PLANE_IP&lt;/span&gt;
talosctl bootstrap &lt;span class=&quot;token comment&quot;&gt;## &amp;lt;-- this command only needs to be run ONCE on a control plane node!&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If all went well, you can use &lt;code&gt;talosctl health&lt;/code&gt; to check the status of your Talos cluster. Get the Kubernetes config using the following command:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;talosctl kubeconfig &lt;span class=&quot;token parameter variable&quot;&gt;--nodes&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$CONTROL_PLANE_IP&lt;/span&gt;
kubectl get nodes&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# you should see something like this&lt;/span&gt;
NAME     STATUS   ROLES           AGE   VERSION
rpi5-1   Ready    control-plane   1h    v1.34.0&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;install-talos-on-other-nodes&quot;&gt;Install Talos on other nodes&lt;/h3&gt;
&lt;p&gt;I would recommend to take care of Cilium first, then install the rest of the nodes.&lt;/p&gt;
&lt;h2 id=&quot;install-cilium&quot;&gt;Install Cilium&lt;/h2&gt;
&lt;p&gt;For those of you that are unfamiliar with Cilium: &lt;a href=&quot;https://github.com/cilium/cilium&quot;&gt;Cilium&lt;/a&gt; is an open-source, eBPF-based networking, security, and observability platform designed for cloud-native environments such as Kubernetes clusters and containerized workloads. Cilium operates as a Container Network Interface (CNI) and provides an advanced, kernel-native framework for container networking, combining eBPF-based performance, granular security, and deep observability.&lt;/p&gt;
&lt;p&gt;Start by installing the Cilium CLI as described on https://docs.cilium.io/en/stable/gettingstarted/k8s-install-default/#install-the-cilium-cli&lt;/p&gt;
&lt;p&gt;Next I am going to use Helm to install Cilium onto the cluster using Terraform (actually, I used OpenTofu). For a complete list of values and how to further customise the Helm deployment, look &lt;a href=&quot;https://github.com/cilium/cilium/blob/main/install/kubernetes/cilium/values.yaml&quot;&gt;here&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;terraform&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;required_providers&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;kubernetes&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;hashicorp/kubernetes&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&gt;= 2.38.0&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;helm&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;hashicorp/helm&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&gt;= 3.0.2&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;required_version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&gt;= 1.10.0&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# view more in the complete command sequence&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see the complete Terraform configuration with all Helm values&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;terraform&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;required_providers&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;kubernetes&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;hashicorp/kubernetes&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&gt;= 2.38.0&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;helm&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;hashicorp/helm&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&gt;= 3.0.2&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;required_version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&gt;= 1.10.0&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;provider&lt;span class=&quot;token type variable&quot;&gt; &quot;kubernetes&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;config_path&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;~/.kube/config&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;provider&lt;span class=&quot;token type variable&quot;&gt; &quot;helm&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;kubernetes&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;config_path&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;~/.kube/config&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;helm_release&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cilium&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;chart&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cilium&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cilium&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;repository&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;https://helm.cilium.io/&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;1.18.2&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;create_namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;kube-system&quot;&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;set&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ipam.mode&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;kubernetes&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;kubeProxyReplacement&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;true&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;securityContext.capabilities.ciliumAgent&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;{CHOWN,KILL,NET_ADMIN,NET_RAW,IPC_LOCK,SYS_ADMIN,SYS_RESOURCE,DAC_OVERRIDE,FOWNER,SETGID,SETUID}&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;securityContext.capabilities.cleanCiliumState&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;{NET_ADMIN,SYS_ADMIN,SYS_RESOURCE}&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cgroup.autoMount.enabled&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cgroup.hostRoot&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;/sys/fs/cgroup&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;k8sServiceHost&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;localhost&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;k8sServicePort&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;7445&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gatewayAPI.enabled&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gatewayAPI.enableAlpn&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;,
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gatewayAPI.enableAppProtocol&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;If you want to check what versions are available for Cilium, run the following commands:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;helm repo &lt;span class=&quot;token function&quot;&gt;add&lt;/span&gt; cilium https://helm.cilium.io/
helm search repo cilium &lt;span class=&quot;token parameter variable&quot;&gt;--versions&lt;/span&gt;


```bash
&lt;span class=&quot;token comment&quot;&gt;# results are something like this&lt;/span&gt;
NAME            CHART VERSION   APP VERSION     DESCRIPTION
cilium/cilium   &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.2          &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.2          eBPF-based Networking, Security, and Observability
cilium/cilium   &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.1          &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.1          eBPF-based Networking, Security, and Observability
cilium/cilium   &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.0          &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.0          eBPF-based Networking, Security, and Observability
&lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the Terraform code and wait for Cilium to be fully deployed. This can take a bit, be patient.&lt;/p&gt;
&lt;p&gt;You can check the status of deployment with either kubectl or the cilium cli.&lt;/p&gt;
&lt;h3 id=&quot;install-talos-on-other-nodes-2&quot;&gt;Install Talos on other nodes&lt;/h3&gt;
&lt;p&gt;Now that Cilium is installed, we can install the other Talos nodes. When you install Cilium straight after the first Talos node is deployed, the rest of the nodes automatically have the correct Cilium config.&lt;/p&gt;
&lt;p&gt;Because of etcd cluster quorum (a two-node etcd cluster is not high availability (HA) because if one node is lost, the cluster will have quorum loss and enter read-only mode), we are going to deploy a total of 3 control plane nodes (hence the &lt;code&gt;allowSchedulingOnControlPlanes: true&lt;/code&gt; config, otherwise we would only have 1 worker node on which to deploy workloads).&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token assign-left variable&quot;&gt;CONTROL_PLANE_IP&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.12&quot;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.13&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token assign-left variable&quot;&gt;WORKER_IP&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.14&quot;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token for-or-select variable&quot;&gt;ip&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token variable&quot;&gt;${CONTROL_PLANE_IP&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;@&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;}&lt;/span&gt;&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;do&lt;/span&gt;
  &lt;span class=&quot;token builtin class-name&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Applying config to worker node: &lt;span class=&quot;token variable&quot;&gt;$ip&lt;/span&gt;&quot;&lt;/span&gt;
  talosctl apply-config &lt;span class=&quot;token parameter variable&quot;&gt;--insecure&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--nodes&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token variable&quot;&gt;$ip&lt;/span&gt;&quot;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--file&lt;/span&gt; _out/controlplane.yaml
&lt;span class=&quot;token keyword&quot;&gt;done&lt;/span&gt;
talosctl apply-config &lt;span class=&quot;token parameter variable&quot;&gt;--insecure&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--nodes&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token variable&quot;&gt;${WORKER_IP}&lt;/span&gt;&quot;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--file&lt;/span&gt; _out/worker.yaml&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The end result will be a 4-node Talos cluster that looks something like this:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl get nodes

NAME     STATUS   ROLES           AGE   VERSION
rpi5-1   Ready    control-plane   1h    v1.34.0
rpi5-2   Ready    control-plane   1h    v1.34.0
rpi5-3   Ready    control-plane   1h    v1.34.0
rpi5-4   Ready    &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;none&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;          1h    v1.34.0

&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; cilium status

    /¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;
 /¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;    Cilium:             OK
 &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/    Operator:           OK
 /¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;    Envoy DaemonSet:    OK
 &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/    Hubble Relay:       disabled
    &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/       ClusterMesh:        disabled

&lt;span class=&quot;token comment&quot;&gt;# view more in the complete command sequence&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see complete cluster status and pod listings&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl get nodes

NAME     STATUS   ROLES           AGE   VERSION
rpi5-1   Ready    control-plane   1h    v1.34.0
rpi5-2   Ready    control-plane   1h    v1.34.0
rpi5-3   Ready    control-plane   1h    v1.34.0
rpi5-4   Ready    &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;none&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;          1h    v1.34.0

cilium status

    /¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;
 /¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;    Cilium:             OK
 &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/    Operator:           OK
 /¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;    Envoy DaemonSet:    OK
 &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/¯¯&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/    Hubble Relay:       disabled
    &lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;__/       ClusterMesh:        disabled

DaemonSet              cilium                   Desired: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;, Ready: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;/4, Available: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;/4
DaemonSet              cilium-envoy             Desired: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;, Ready: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;/4, Available: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;/4
Deployment             cilium-operator          Desired: &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;, Ready: &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;/2, Available: &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;/2
Containers:            cilium                   Running: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;
                       cilium-envoy             Running: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;
                       cilium-operator          Running: &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
                       clustermesh-apiserver
                       hubble-relay
Cluster Pods:          &lt;span class=&quot;token number&quot;&gt;20&lt;/span&gt;/20 managed by Cilium
Helm chart version:    &lt;span class=&quot;token number&quot;&gt;1.18&lt;/span&gt;.2
Image versions         cilium             quay.io/cilium/cilium:v1.18.2@sha256:858f807ea4e20e85e3ea3240a762e1f4b29f1cb5bbd0463b8aa77e7b097c0667: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;
                       cilium-envoy       quay.io/cilium/cilium-envoy:v1.34.7-1757592137-1a52bb680a956879722f48c591a2ca90f7791324@sha256:7932d656b63f6f866b6732099d33355184322123cfe1182e6f05175a3bc2e0e0: &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;
                       cilium-operator    quay.io/cilium/operator-generic:v1.18.2@sha256:cb4e4ffc5789fd5ff6a534e3b1460623df61cba00f5ea1c7b40153b5efb81805: &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;

talosctl health
discovered nodes: &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.11&quot;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.12&quot;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.13&quot;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;10.10.10.14&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; etcd to be healthy: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; etcd to be healthy: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; etcd members to be consistent across nodes: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; etcd members to be consistent across nodes: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; etcd members to be control plane nodes: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; etcd members to be control plane nodes: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; apid to be ready: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; apid to be ready: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all nodes memory sizes: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all nodes memory sizes: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all nodes disk sizes: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all nodes disk sizes: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; no diagnostics: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; no diagnostics: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; kubelet to be healthy: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; kubelet to be healthy: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all nodes to finish boot sequence: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all nodes to finish boot sequence: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all k8s nodes to report: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all k8s nodes to report: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all control plane static pods to be running: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all control plane static pods to be running: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all control plane components to be ready: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all control plane components to be ready: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all k8s nodes to report ready: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all k8s nodes to report ready: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; kube-proxy to report ready: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; kube-proxy to report ready: SKIP
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; coredns to report ready: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; coredns to report ready: OK
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all k8s nodes to report schedulable: &lt;span class=&quot;token punctuation&quot;&gt;..&lt;/span&gt;.
waiting &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; all k8s nodes to report schedulable: OK

kubectl get pods &lt;span class=&quot;token parameter variable&quot;&gt;-A&lt;/span&gt;

NAMESPACE       NAME                                               READY   STATUS    RESTARTS      AGE
kube-system     cilium-9tff5                                       &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     cilium-envoy-f8z4f                                 &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   o             1h
kube-system     cilium-envoy-jnj94                                 &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     cilium-envoy-qdwcn                                 &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   o             1h
kube-system     cilium-envoy-ww9pq                                 &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   o             1h
kube-system     cilium-kxkxb                                       &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     cilium-mvggt                                       &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     cilium-operator-54d6f6cccb-bkn8k                   &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     cilium-operator-54d6f6cccb-cdvz9                   &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     cilium-w9fvm                                       &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     coredns-7bb49dc74c-nbrd2                           &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     coredns-7bb49dc74c-xjkw8                           &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-apiserver-rpi5-1                              &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-apiserver-rpi5-2                              &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-apiserver-rpi5-3                              &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-controller-manager-rpi5-1                     &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-controller-manager-rpi5-2                     &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-controller-manager-rpi5-3                     &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-scheduler-rpi5-1                              &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-scheduler-rpi5-2                              &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h
kube-system     kube-scheduler-rpi5-3                              &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;/1     Running   &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;             1h&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#39;s next?&lt;/h2&gt;
&lt;p&gt;Now that Talos and Cilium are ready, start deploying other services to your cluster. Think about &lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/user-guide/helm/&quot;&gt;ArgoCD&lt;/a&gt;, &lt;a href=&quot;https://cert-manager.io/docs/installation/helm/&quot;&gt;cert-manager&lt;/a&gt;, &lt;a href=&quot;https://github.com/kubernetes-sigs/external-dns&quot;&gt;external-dns&lt;/a&gt; or get an intro to what Cilium can actually do with the &lt;a href=&quot;https://docs.cilium.io/en/stable/gettingstarted/demo/&quot;&gt;Star Wars Demo&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Have fun!&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Self-hosted Gitlab Runners on AKS, with Managed Identities</title>
    <link href="https://ascode.nl/blog/gitlab-runners-aks/" />
    <updated>2025-09-30T00:00:00Z</updated>
    <id>https://ascode.nl/blog/gitlab-runners-aks/</id>
    <content type="html">&lt;p&gt;This post is long overdue, I&#39;ve been meaning to write this for a long time but did not get around to doing it. So here goes!&lt;/p&gt;
&lt;p&gt;The code used in this post is located in &lt;a href=&quot;https://gitlab.com/ascodenl/gitlab-runner-aks&quot;&gt;https://gitlab.com/ascodenl/gitlab-runner-aks&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The great thing about using Managed Identities in Azure is that they cannot be (ab)used to run elsewhere, like Service Principals can. Yes, you can use workload identities conditional access policies nowadays, but SP&#39;s are basically just another username/password combination that can be abused for other purposes than they were meant for. Managed Identities are a special type of Service Principal (which is an Azure Enterprise Application Registration under the hood) that can only be used when attached to an Azure resource. When connected to a Kubernetes service account, the managed identity permissions can be assumed by the pod that has that service account attached. This allows for fine-grained permissions for specific purposes, for specific Gitlab runners. For example, a runner that is purpose-built for building container images that pushes to Azure Container Registry - using a Managed Identity that only has the &lt;code&gt;AcrPush&lt;/code&gt; permission on the ACR(s) it needs to push to.&lt;/p&gt;
&lt;h2 id=&quot;the-case-for-self-hosted-gitlab-runners&quot;&gt;The case for self-hosted Gitlab Runners&lt;/h2&gt;
&lt;p&gt;&amp;quot;Why not use the Gitlab provided runners&amp;quot;? I hear that quite often. In summary, self-hosted GitLab runners are ideal when you need maximum control, security, and flexibility for your CI/CD jobs, want to optimise costs at scale, or have specific compliance and infrastructure requirements that (what Gitlab calls) &lt;code&gt;Instance runners&lt;/code&gt; cannot meet. Yes, it requires maintenance but it outweighs the added benefits - especially in environments with heavy compliance requirements like the Financial industry.&lt;/p&gt;
&lt;p&gt;Slightly more detailed, for several reasons:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Full Control Over the Build Environment&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Self-hosted runners give complete control over the (virtual) hardware, operating system, and software installed on the machines (in our case, Kubernetes) running CI/CD jobs. This allows you to customise environments to match your production setup, install proprietary or legacy tools, and fine-tune performance for specific workloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;2. Security and Compliance&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;By running jobs on your own infrastructure, you can ensure sensitive code and data never leave your network. This is especially important for organisations with strict compliance, data residency, or privacy requirements, such as those in the public sector.&lt;/li&gt;
&lt;li&gt;Self-hosted runners can be placed behind firewalls or VPNs, further reducing exposure to potential threats.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3. Cost Efficiency and Scalability&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For teams with high CI/CD usage, self-hosted runners can be more cost-effective than paying for shared or cloud-hosted runners, especially at scale. You avoid per-minute billing and can utilise existing cloud resources as needed.&lt;/li&gt;
&lt;li&gt;You can scale your runners as your needs grow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;4. Performance and Flexibility&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Self-hosted runners can be optimised for your specific workloads, providing faster builds and more reliable performance than shared runners.&lt;/li&gt;
&lt;li&gt;You can run jobs on specialised hardware (e.g., GPUs, large-memory machines) or in specific environments (e.g., on-premises, in particular cloud regions) that aren&#39;t available with GitLab-hosted runners.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;5. Advanced Customisation&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You can create custom caching strategies, use local Docker registries, or integrate with internal systems and services that aren&#39;t accessible from public runners.&lt;/li&gt;
&lt;li&gt;Self-hosted runners allow for advanced monitoring, logging, and debugging, giving greater visibility into CI/CD processes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;types-of-runners&quot;&gt;Types of runners&lt;/h2&gt;
&lt;p&gt;Gitlab has 3 types of runners:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Instance runners: Shared runners that are built, deployed and managed by Gitlab for generic use.&lt;/li&gt;
&lt;li&gt;Group runners: Deployed on Gitlab group level, inherited by all repositories (projects) that are part of that group. Self-hosted and self-
managed.&lt;/li&gt;
&lt;li&gt;Project runners: Deployed on a single repository. Self-hosted and self-managed.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;types-of-runtime-environments&quot;&gt;Types of runtime environments&lt;/h2&gt;
&lt;p&gt;When registering a GitLab runner, you must select an &lt;strong&gt;executor&lt;/strong&gt;, which determines the environment in which your CI/CD jobs will run. Each executor offers different levels of isolation, scalability, and compatibility, making them suitable for various scenarios.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Executor&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;Typical Use Case&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Simple, local jobs&lt;/td&gt;
&lt;td&gt;Easy, minimal setup&lt;/td&gt;
&lt;td&gt;Low isolation, less secure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Reproducible, isolated builds&lt;/td&gt;
&lt;td&gt;Clean, scalable, supports services&lt;/td&gt;
&lt;td&gt;Needs Docker, some limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Autoscaler&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Scalable cloud builds&lt;/td&gt;
&lt;td&gt;Auto-scales, cloud support&lt;/td&gt;
&lt;td&gt;Complex setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instance&lt;/td&gt;
&lt;td&gt;Very High&lt;/td&gt;
&lt;td&gt;Full VM per job, high isolation&lt;/td&gt;
&lt;td&gt;Max isolation, flexibility&lt;/td&gt;
&lt;td&gt;Resource intensive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Cloud-native, Kubernetes environments&lt;/td&gt;
&lt;td&gt;Scalable, cloud integration&lt;/td&gt;
&lt;td&gt;Needs Kubernetes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;Remote, legacy, or custom environments&lt;/td&gt;
&lt;td&gt;Remote execution&lt;/td&gt;
&lt;td&gt;Limited support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VirtualBox/Parallels&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;VM-based isolation on local hardware&lt;/td&gt;
&lt;td&gt;Good isolation&lt;/td&gt;
&lt;td&gt;Slower, needs virtualization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;Anything not covered above&lt;/td&gt;
&lt;td&gt;Flexible&lt;/td&gt;
&lt;td&gt;Requires custom scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Choosing the right executor depends on your project&#39;s requirements for isolation, scalability, environment, and available infrastructure&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Our default platform of choice is Kubernetes, this article covers the Azure implementation of Kubernetes called &lt;strong&gt;Azure Kubernetes Service (AKS)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Creating infrastructure in Azure (or any environment for that matter) is done using Infrastructure as Code (IaC). The tool of choice is Terraform or Tofu, whatever your preference. The idea is to let a CI/CD pipeline handle the creating, updating and destruction of Azure resources, using Gitlab Runners on AKS. For this, we need several resources to make that happen.&lt;/p&gt;
&lt;p&gt;Here is a quick overview of what we are building:&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/avif&quot; srcset=&quot;https://ascode.nl/blog/gitlab-runners-aks/dXHmWkf9cF-4588.avif 4588w&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;https://ascode.nl/blog/gitlab-runners-aks/dXHmWkf9cF-4588.webp 4588w&quot;&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://ascode.nl/blog/gitlab-runners-aks/dXHmWkf9cF-4588.png&quot; alt=&quot;Gitlab runners AKS&quot; width=&quot;4588&quot; height=&quot;1484&quot;&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2 id=&quot;azure-configuration&quot;&gt;Azure configuration&lt;/h2&gt;
&lt;p&gt;The runner will use a Kubernetes Service account, which is &amp;quot;connected&amp;quot; to an Azure Managed Identity, that will be assigned roles with permissions to create resources in Azure. You will need an AKS cluster with OIDC issuer enabled. Read &lt;a href=&quot;https://learn.microsoft.com/en-us/azure/aks/use-oidc-issuer&quot;&gt;here&lt;/a&gt; how to enable if not configured yet.&lt;/p&gt;
&lt;p&gt;The Managed Identity is created as a separate resource outside of any Terraform Module. The main reason for this is, we use RBAC to assign permissions on Azure Resources. If you want to allow a Managed Identity access to Entra ID (to read groups, primarily), assigning permissions in Entra ID requires elevated privileges that we do not want to delegate to a Managed Identity. To prevent (re)creation of a Managed Identity as part of a module, we create it separately.&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;runners&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;tf&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;environment&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ss&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;environment_long&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;shared&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;runner_type&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;group_type&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;runner_tags&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;azure&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;platform&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;ss&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;repo_path&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ascodenl/infra&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;run_privileged&lt;/span&gt;   &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;true&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;purpose&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Runner for deploying Terraform resources in the Azure Landing Zone using Gitlab&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# view more in the complete command sequence&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see complete Terraform code&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;runners&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;tf&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;environment&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ss&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;environment_long&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;shared&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;runner_type&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;group_type&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;runner_tags&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;azure&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;platform&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;ss&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;repo_path&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ascodenl/infra&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;run_privileged&lt;/span&gt;   &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;true&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;purpose&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Runner for deploying Terraform resources in the Azure Landing Zone using Gitlab&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;packer&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;environment&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ss&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;environment_long&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;shared&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;runner_type&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;project_type&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;runner_tags&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;infra-packer-azure&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;ss&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;repo_path&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ascodenl/infra/base-images/azure&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;run_privileged&lt;/span&gt;   &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;true&quot;&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;purpose&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Gitlab runner for deploying Custom images to Shared Imaged Gallery using Packer in Azure&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_user_assigned_identity&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;for_each&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.runners
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;value&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;region_code&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;value&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;environment&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-msi-gitlab-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;key&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;resource_group_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.gitlab&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;each.value.region_code&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token property&quot;&gt;location&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.gitlab&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;each.value.region_code&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.location
  &lt;span class=&quot;token property&quot;&gt;tags&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; merge(local.tags, &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;purpose&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; each.value.purpose
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;)
  &lt;span class=&quot;token keyword&quot;&gt;lifecycle&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;ignore_changes&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;tags&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;CreatedOnDateTime&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;## Create the MSGraph application&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;data &lt;span class=&quot;token type variable&quot;&gt;&quot;azuread_application_published_app_ids&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;well_known&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azuread_service_principal&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;msgraph&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;client_id&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
  &lt;span class=&quot;token property&quot;&gt;use_existing&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;## Assign the Permissions in Entra ID&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azuread_app_role_assignment&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_api_permissions&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;for_each&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; toset(&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Group.Read.All&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;User.Read.All&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;GroupMember.Read.All&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;Directory.Read.All&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;)
  &lt;span class=&quot;token property&quot;&gt;app_role_id&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azuread_service_principal.msgraph.app_role_ids&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;each.key&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;principal_object_id&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_user_assigned_identity.gitlab_runner&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;tf&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.principal_id
  &lt;span class=&quot;token property&quot;&gt;resource_object_id&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azuread_service_principal.msgraph.object_id
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;## Create a federated credential&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_federated_identity_credential&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;for_each&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.runners
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ascode-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;value&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;environment_long&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-msi-gitlab-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;key&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-cred&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;resource_group_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_resource_group.gitlab.name
  &lt;span class=&quot;token property&quot;&gt;audience&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;api://AzureADTokenExchange&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;issuer&lt;/span&gt;              &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; module.aks.oidc_issuer_url
  &lt;span class=&quot;token property&quot;&gt;parent_id&lt;/span&gt;           &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_user_assigned_identity.gitlab_runner&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;each.key&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.id
  &lt;span class=&quot;token property&quot;&gt;subject&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;system:serviceaccount:&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;module&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;runners&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;key&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;k8s_sa_namespace&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;:&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;module&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;runners&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;key&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;k8s_sa_name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;## Assign permissions in Azure&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;##&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# MSI is owner on all subscriptions&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;azurerm_role_assignment&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner_platform_owner&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;for_each&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.subscription_id
  &lt;span class=&quot;token property&quot;&gt;scope&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;/subscriptions/&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;each&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;value&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;role_definition_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Owner&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;principal_id&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; azurerm_user_assigned_identity.gitlab_runner&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;tf&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.principal_id
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Note that we make the MSI specific to deploying Terraform resources (&lt;code&gt;azurerm_user_assigned_identity.gitlab_runner[&amp;quot;tf&amp;quot;].principal_id&lt;/code&gt;) owner of the subscriptions. Contributor is not going to be enough as we also want to use the pipeline to do role assignments and RBAC, therefor it needs owner permissions.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Warning:&lt;/strong&gt; This means that this MSI has very powerful privileges! Make sure you lock down your pipelines so that not just anyone can run them and make sure you do proper Merge Requests and code reviews!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;kubernetes-resources&quot;&gt;Kubernetes resources&lt;/h2&gt;
&lt;p&gt;Remember we are using Kubernetes as the Gitlab Executor. What we are deploying is what can be described as a &amp;quot;runner manager&amp;quot;, which will spin off containers (pods, actually) that will run the pipeline. Once the pipeline is finished, the pod is destroyed.&lt;/p&gt;
&lt;p&gt;The Gitlab Runner is deployed using Helm. Gitlab maintains a Helm chart that you can find on &lt;a href=&quot;https://gitlab.com/gitlab-org/charts/gitlab-runner/&quot;&gt;https://gitlab.com/gitlab-org/charts/gitlab-runner/&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Gitlab Runner configuration is done in a &lt;code&gt;config.toml&lt;/code&gt; file that we deploy using a template.&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;gitlabUrl&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;${gitlab_url}&quot;&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;unregisterRunners&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;3600&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;concurrent&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;checkInterval&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;30&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;serviceAccountName&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;rbac_service_account_name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# view more in the complete configuration&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see GitLab Runner Helm Values Configuration&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;gitlabUrl&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;${gitlab_url}&quot;&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;unregisterRunners&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;3600&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;concurrent&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;checkInterval&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;30&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;serviceAccountName&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;rbac_service_account_name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;rbac&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;serviceAccountAnnotations&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;azure.workload.identity/client-id&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;client_id&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;azure.workload.identity/tenant-id&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;tenant_id&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;metrics&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;enabled&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;runners&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;
    [[runners]]
      [runners.kubernetes]
        pod_labels_overwrite_allowed = &quot;.*&quot;
        privileged = ${run_privileged}
        service_account = &quot;${service_account_name}&quot;
        namespace = &quot;${runner_namespace}&quot;
      [runners.kubernetes.pod_labels]
        &quot;azure.workload.identity/use&quot; = &#39;&quot;true&quot;&#39;
      [[runners.kubernetes.volumes.empty_dir]]
        name = &quot;docker-certs&quot;
        mount_path = &quot;/certs/client&quot;
        medium = &quot;Memory&quot;&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;${gitlab_name}&quot;&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;${gitlab_docker_image}&quot;&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;privileged&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;run_privileged&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;serviceAccountName&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;service_account_name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;pull_policy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; always
  &lt;span class=&quot;token key atrule&quot;&gt;secret&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;${runner_secret}&quot;&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;securityContext&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;runAsUser&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;100&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;deploymentLabels&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;deployment_repository&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;deployment_repo_path&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;podLabels&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;azure.workload.identity/use&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;true&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Then we use Terraform to transform the template and deploy the Helm chart:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;gitlab_runner_vars&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;gitlab_url&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.gitlab_url
    &lt;span class=&quot;token property&quot;&gt;gitlab_docker_image&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.gitlab_docker_image
    &lt;span class=&quot;token property&quot;&gt;gitlab_name&lt;/span&gt;               &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-k8-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;runner_name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# view more in the complete chart&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see Terraform deploy Helm Chart&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;gitlab_runner_vars&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;gitlab_url&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.gitlab_url
    &lt;span class=&quot;token property&quot;&gt;gitlab_docker_image&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.gitlab_docker_image
    &lt;span class=&quot;token property&quot;&gt;gitlab_name&lt;/span&gt;               &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-k8-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;runner_name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;run_privileged&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.run_privileged
    &lt;span class=&quot;token property&quot;&gt;service_account_name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_service_account_v1.gitlab_runner.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;rbac_service_account_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_service_account_v1.gitlab_runner.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;runner_namespace&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;client_id&lt;/span&gt;                 &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.mi_client_id
    &lt;span class=&quot;token property&quot;&gt;tenant_id&lt;/span&gt;                 &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.tenant_id
    &lt;span class=&quot;token property&quot;&gt;runner_secret&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_secret_v1.gitlab_runner.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;deployment_repo_path&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.deployment_repo_path
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;helm_release&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;chart&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-runner&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;customer_tla&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;runner_name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token property&quot;&gt;repository&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;https://charts.gitlab.io&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.gitlab_runner_helm_release_version
  &lt;span class=&quot;token property&quot;&gt;create_namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    templatefile(&lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;module&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;/templates/runner-values.yml.tpl&quot;&lt;/span&gt;, local.gitlab_runner_vars)
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;To check for the latest version(s) of the chart:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;helm repo &lt;span class=&quot;token function&quot;&gt;add&lt;/span&gt; gitlab-runner https://charts.gitlab.io
helm repo update
helm search repo-l gitlab/gitlab-runner &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;head&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-5&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last part is to create the Kubernetes Service Account that &amp;quot;glues&amp;quot; the Managed Identity to the Gitlab Runner:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_service_account_v1&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-runner-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;random_id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;this&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;hex&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;annotations&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;azure.workload.identity/client-id&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.msi_client_id
      &lt;span class=&quot;token property&quot;&gt;&quot;azure.workload.identity/tenant-id&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.tenant_id
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;labels&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;azure.workload.identity/use&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;true&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally some required Kubernetes resources to make this all work (I use &lt;code&gt;rbac.create = false&lt;/code&gt; in the config.toml because I like to be in control of what is created. Setting this to true means you have to annotate the Service Account with the correct values as it gets auto-created).&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_namespace_v1&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;random_id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;this&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;hex&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# view more in the complete Kubernetes resources&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see Terraform Kubernetes resources&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_namespace_v1&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;random_id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;this&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;hex&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_role&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-runner-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;random_id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;this&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;hex&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;rule&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;api_groups&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;resources&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;configmaps&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;pods&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;pods/attach&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;secrets&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;services&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;namespaces&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;verbs&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;get&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;list&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;watch&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;create&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;patch&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;update&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;delete&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;rule&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;api_groups&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;resources&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;pods/exec&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;verbs&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;create&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;patch&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;delete&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;rule&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;api_groups&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;resources&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;serviceAccounts&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;verbs&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;get&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_role_binding_v1&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-runner-binding-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;random_id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;this&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;hex&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;role_ref&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;api_group&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;rbac.authorization.k8s.io&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;kind&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Role&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_role.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;subject&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;kind&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ServiceAccount&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_service_account_v1.gitlab_runner.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_cluster_role_binding_v1&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;runner_admin&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab-runner-admin-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;random_id&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;this&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;hex&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;role_ref&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;api_group&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;rbac.authorization.k8s.io&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;kind&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ClusterRole&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cluster-admin&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;subject&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;kind&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ServiceAccount&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_service_account_v1.gitlab_runner.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h2 id=&quot;runner-registration&quot;&gt;Runner registration&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Note:&lt;/strong&gt; This article describes the new way of registering runners. Please see https://docs.gitlab.com/ci/runners/new_creation_workflow/ how to migrate.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When deploying a runner, it needs to be registered against a group or repository. Each group or repository has its own unique token. You can do this from the &lt;strong&gt;CI/CD settings&lt;/strong&gt; of the repository or group, create a project or group runner, fill in the details and out comes a registration token. But who wants do do manual? Let&#39;s automate this.&lt;/p&gt;
&lt;p&gt;Terraform has a great provider for Gitlab, found on &lt;a href=&quot;https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs&quot;&gt;https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs&lt;/a&gt;. You can use it to fully automate your Gitlab environment, including repositories, groups, authorisations, integrations, etc. We&#39;ll focus on the &lt;a href=&quot;https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/user_runner&quot;&gt;gitlab_user_runner&lt;/a&gt; resource to get the registration token.&lt;/p&gt;
&lt;p&gt;Each group or project in Gitlab has a unique id, which is hard to find and even harder to remember. We use the path to find the id, which is a lot easier to remember. If you use Terraform to also create your groups and projects, you can even reference the Terraform resource!&lt;/p&gt;
&lt;p&gt;Terraform provider configuration is required for Gitlab and Kubernetes (the registration token is stored in a Kubernetes secret):&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;provider&lt;span class=&quot;token type variable&quot;&gt; &quot;kubernetes&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;config_path&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;~/.kube/config&quot;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# Need to create this file from the pipeline or run locally&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;provider&lt;span class=&quot;token type variable&quot;&gt; &quot;gitlab&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;base_url&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;https://gitlab.com/&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;token&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; data.azurerm_key_vault_secret.gitlab_token.value &lt;span class=&quot;token comment&quot;&gt;# this can be a Group token or a PAT token with the create_runner scope&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First, we need to determine if we are deploying a group runner or a project runner:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;data &lt;span class=&quot;token type variable&quot;&gt;&quot;gitlab_group&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;group&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;count&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;group_type&quot;&lt;/span&gt; ? &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; : &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;full_path&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.repo_path
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;data &lt;span class=&quot;token type variable&quot;&gt;&quot;gitlab_project&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;project&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;count&lt;/span&gt;               &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;project_type&quot;&lt;/span&gt; ? &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; : &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;path_with_namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.repo_path
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;repo_path&lt;/code&gt; is the path to your repo, for example &lt;code&gt;ascodenl/infra/tools&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then, dependent on what type of runner you want, create a token and store it in a Kubernetes secret. Note the reference to a Kubernetes Service Account, this will become clear later on.&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;gitlab_user_runner&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner_project&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;count&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;project_type&quot;&lt;/span&gt; ? &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; : &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;runner_type&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type
  &lt;span class=&quot;token property&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;kubernetes_service_account_v1&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;gitlab_runner&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;region_code&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-aks&quot;&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# view more code below&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;&lt;strong&gt;Click to see Terraform Runner Token Creation&lt;/strong&gt;&lt;/summary&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;gitlab_user_runner&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner_project&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;count&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;project_type&quot;&lt;/span&gt; ? &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; : &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;runner_type&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type
  &lt;span class=&quot;token property&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;kubernetes_service_account_v1&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;gitlab_runner&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;region_code&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-aks&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;project_id&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; data.gitlab_project.project&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.id
  &lt;span class=&quot;token property&quot;&gt;tag_list&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; flatten(&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;var.region_code&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;, &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;var.runner_name&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;, var.runner_tags&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;)
  &lt;span class=&quot;token property&quot;&gt;untagged&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.run_untagged
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;gitlab_user_runner&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner_group&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;count&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;group_type&quot;&lt;/span&gt; ? &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; : &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;runner_type&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type
  &lt;span class=&quot;token property&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;kubernetes_service_account_v1&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;gitlab_runner&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;var&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;region_code&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;-aks&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;group_id&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; data.gitlab_group.group&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.id
  &lt;span class=&quot;token property&quot;&gt;tag_list&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; flatten(&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;var.region_code&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;, &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;var.runner_name&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;, var.runner_tags&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;)
  &lt;span class=&quot;token property&quot;&gt;untagged&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.run_untagged
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;kubernetes_secret_v1&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab_runner&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;metadata&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_service_account_v1.gitlab_runner.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
    &lt;span class=&quot;token property&quot;&gt;namespace&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; kubernetes_namespace_v1.gitlab.metadata&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.name
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Opaque&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;runner-registration-token&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&quot;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# need to leave as an empty string for compatibility reasons&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;runner-token&lt;/span&gt;              &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; var.runner_type &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;project_type&quot;&lt;/span&gt; ? gitlab_user_runner.gitlab_runner_project&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.token : gitlab_user_runner.gitlab_runner_group&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;.token
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;h2 id=&quot;using-this-in-a-gitlab-pipeline&quot;&gt;Using this in a Gitlab pipeline&lt;/h2&gt;
&lt;p&gt;Now that the runner is deployed with the proper permissions, it is time to create a pipeline to implement this in CI/CD.&lt;/p&gt;
&lt;p&gt;Creating a full multi environment pipeline is enough for a separate blog post, so here is the most important part:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;before_script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;
    if ! [ -x &quot;$(command -v az)&quot; ]; then
      echo -e &quot;&#92;e[33mWarn: az is not installed.&#92;e[0m&quot;
      exit 1
    else
      echo &quot;Logging in to Azure using client_id $AZURE_CLIENT_ID...&quot;
      az login --service-principal -u $AZURE_CLIENT_ID --tenant $AZURE_TENANT_ID --federated-token $(cat $AZURE_FEDERATED_TOKEN_FILE)
      if [[ ! -z ${ARM_SUBSCRIPTION_NAME} ]]; then az account set -n ${ARM_SUBSCRIPTION_NAME}; fi
      export ARM_OIDC_TOKEN=$(cat $AZURE_FEDERATED_TOKEN_FILE)
      export ARM_CLIENT_ID=$AZURE_CLIENT_ID
      export ARM_TENANT_ID=$AZURE_TENANT_ID
    fi&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If OIDC is working correctly, the Azure token is stored in a file that is referenced in &lt;code&gt;$AZURE_FEDERATED_TOKEN_FILE&lt;/code&gt; which usually points to &lt;code&gt;/var/run/secrets/azure/tokens/azure-identity-token&lt;/code&gt;. Enabling OIDC on AKS deploys something called a &amp;quot;Mutating Admission Webhook&amp;quot; which takes care of getting a token that has a limited lifetime, and refreshes the token on expiration. If you are interested in how this works under the hood, look &lt;a href=&quot;https://azure.github.io/azure-workload-identity/docs/installation/self-managed-clusters/oidc-issuer.html&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I hope you enjoyed this, thanks for sticking around till the end. Until the next!&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>New Website, refactored</title>
    <link href="https://ascode.nl/blog/new-website-2025/" />
    <updated>2025-05-04T00:00:00Z</updated>
    <id>https://ascode.nl/blog/new-website-2025/</id>
    <content type="html">&lt;p&gt;Having neglected our web site for a long time, I read a post on X of someone that used &lt;a href=&quot;https://www.11ty.dev/&quot;&gt;Eleventy&lt;/a&gt; to create his web site. Being a guy that always likes to try out new technology, I thought I&#39;d give it a go. Our previous website was created with Hugo, which is also great but still required a lot of tweaking to get right. Since I am not a web developer by trade, reading up on Eleventy seemed like it could make the job easier.&lt;/p&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;/h2&gt;
&lt;p&gt;I used the blog template available on &lt;a href=&quot;https://github.com/11ty/eleventy-base-blog&quot;&gt;Github&lt;/a&gt; to get started, cloned it into my local git environment and started customising it to the Ascode look and feel. One if the nice features of Eleventy (same with Hugo) is that you can run a local web server using &lt;code&gt;eleventy --serve&lt;/code&gt; that starts a web server on http://localhost:8081 together with a file system watcher that once a change is made to a file, immediately processes the change and makes your changes visible through the local web server. This is great for immediate feedback on the result of the changes that were made.&lt;/p&gt;
&lt;p&gt;There are a bunch of &lt;a href=&quot;https://www.11ty.dev/docs/plugins/community/&quot;&gt;plugins&lt;/a&gt; available, we wanted to get up and running quickly so I kept the first version really simple.&lt;/p&gt;
&lt;h2 id=&quot;hosting-on-cloudflare-pages&quot;&gt;Hosting on Cloudflare Pages&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://ascode.nl/blog/new-startup-new-website/&quot;&gt;previous web site&lt;/a&gt; was a really nice project to test out the serverless features of AWS, but it involved deploying and maintaining quite a bit of resources so we decided to keep it really simple this time and host the web site with Cloudflare Pages.&lt;/p&gt;
&lt;p&gt;You can generate the static content for upload to Cloudflare by running the command &lt;code&gt;npx @11ty/eleventy&lt;/code&gt; which generates all required files and stores them by default in the &lt;code&gt;_site&lt;/code&gt; folder.
Then, upload the contents of the _site folder to Cloudflare (in the Compute (Workers)/Workers &amp;amp; Pages section). The name you give the project is the name that is used to generate the default Cloudflare Pages url, in this case the name is &lt;code&gt;ascode-web&lt;/code&gt; which automatically generates the url https://ascode-web.pages.dev/. That&#39;s it for basics, pretty simple - love it.&lt;/p&gt;
&lt;p&gt;The first go for me resulted in none of the pictures being visible, they contained broken links. After some troubleshooting I found the --serve option does not clean up after itself; after removing the local _site folder and re-running &lt;code&gt;eleventy --serve&lt;/code&gt; all was looking fine again.&lt;/p&gt;
&lt;p&gt;Here is the documentation for the image plugin that might help you out: https://www.11ty.dev/docs/plugins/image/&lt;/p&gt;
&lt;h3 id=&quot;setting-a-custom-domain&quot;&gt;Setting a custom domain&lt;/h3&gt;
&lt;p&gt;We are hosting the ascode.nl at Cloudflare which makes it super-easy to add the custom domain &lt;code&gt;ascode.nl&lt;/code&gt; to Cloudflare Pages. Just add the custom domain on the Workers &amp;amp; Pages page and Cloudflare will take care of creating or updating the required DNS record, and generate an SSL certificate with the same name. Keep in mind that &lt;code&gt;ascode.nl&lt;/code&gt; and &lt;code&gt;www.ascode.nl&lt;/code&gt; are 2 separate custom domains in the Pages sense and need to be generated separately.&lt;/p&gt;
&lt;h2 id=&quot;automating-deployments&quot;&gt;Automating deployments&lt;/h2&gt;
&lt;p&gt;When I find some more time...&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Shared VPC Subnet tagging</title>
    <link href="https://ascode.nl/blog/shared-vpc-subnet-tagging/" />
    <updated>2023-11-06T00:00:00Z</updated>
    <id>https://ascode.nl/blog/shared-vpc-subnet-tagging/</id>
    <content type="html">&lt;p&gt;&lt;em&gt;This post was previously published on lionsville.nl. That site is gone, so it is re-published here.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;One of the services we provide for our customers is the deployment and management of an AWS Landing Zone. Part of the Landing Zone is a centralised networking environment that is used as the centralised VPC for ingress and egress network traffic. VPC sharing is used, implementation varies, for this article we implement a shared VPC per AWS account with 3 subnets per VPC.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ascode.nl/img/shared_subnets-1.png&quot; alt=&quot;&amp;quot;High level overview&amp;quot;&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;vpc-sharing-overview&quot;&gt;VPC Sharing overview&lt;/h2&gt;
&lt;p&gt;Each AWS Account gets its own VPC with 3 type of subnets:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Isolated&lt;/li&gt;
&lt;li&gt;Private&lt;/li&gt;
&lt;li&gt;TransitGateway&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With 1 subnet per Availability Zone, a total of 9 subnets are created. The subnets are then shared with Resource Access Manager (RAM) with the Team account.&lt;/p&gt;
&lt;p&gt;For the sake of simplicity, this article will only focus on the subnet sharing part of this implementation. One of the big downsides of RAM is that the shared resource in the Team account does not inherit tags. Especially for implementations like EKS or other AWS services that rely on tagging, this is a problem. As part of the VPC deployment in the Shared Network account, several tags are created of which a few of them we would like to make available in the Team account. The tags that are discussed in this post are Name, AccountID, subnetType and aws-cdk:subnet-type.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ascode.nl/img/shared_subnets-2.png&quot; alt=&quot;&amp;quot;VPC Sharing overview&amp;quot;&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;process&quot;&gt;Process&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;EventBridge detects an &lt;strong&gt;AssociateResourceShare&lt;/strong&gt; CloudWatch Event.&lt;/li&gt;
&lt;li&gt;EventBridge triggers the Lambda function with the CloudWatch event json as input.&lt;/li&gt;
&lt;li&gt;The Lambda function looks up the subnet-id that is in the body of the CloudWatch event and grabs the tags that are configured on that subnet.&lt;/li&gt;
&lt;li&gt;The Lambda function assumes a role in the team account.&lt;/li&gt;
&lt;li&gt;The Lambda function updates the tags on the subnets in the team account.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The process in the will be discussed in more detail, using code examples (Terraform and Lambda). The code can be found &lt;a href=&quot;https://gitlab.com/ascodenl/vpc-subnet-tagger&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you want EventBridge to be able to record events with a &lt;code&gt;detail-type&lt;/code&gt; value of &lt;code&gt;AWS API Call via CloudTrail&lt;/code&gt;, a CloudTrail trail with logging enabled is required.
The event that EventBridge is configured to look for is the &lt;strong&gt;AssociateResourceShare&lt;/strong&gt; event. Check the &lt;a href=&quot;https://docs.aws.amazon.com/ram/latest/APIReference/API_AssociateResourceShare.html&quot;&gt;API Documentation&lt;/a&gt; for the different events that are available for RAM.
The target for the event is a Lambda function that was created by &lt;a href=&quot;https://www.linkedin.com/in/markusmuhr&quot;&gt;Markus Muhr&lt;/a&gt;. Two resources are created, an event rule and an event target.&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;aws_cloudwatch_event_rule&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ram&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;capture-ram-creation&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Capture creation of RAM shares&quot;&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;event_pattern&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; jsonencode(&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;&quot;source&quot;&lt;/span&gt; : &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;aws.ram&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;,
    &lt;span class=&quot;token property&quot;&gt;&quot;detail-type&quot;&lt;/span&gt; : &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;AWS API Call via CloudTrail&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;,
    &lt;span class=&quot;token property&quot;&gt;&quot;detail&quot;&lt;/span&gt; : &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;eventSource&quot;&lt;/span&gt; : &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;ram.amazonaws.com&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;,
      &lt;span class=&quot;token property&quot;&gt;&quot;eventName&quot;&lt;/span&gt; : &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;AssociateResourceShare&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;)
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;aws_cloudwatch_event_target&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;lambda&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;target_id&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;lambda-function-target&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;rule&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; aws_cloudwatch_event_rule.ram.name
  &lt;span class=&quot;token property&quot;&gt;arn&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; aws_lambda_alias.tagger_alias.arn
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;lambda-function&quot;&gt;Lambda function&lt;/h3&gt;
&lt;p&gt;The Lambda function contains several functions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;lookup the shared subnet&lt;/li&gt;
&lt;li&gt;assume the role&lt;/li&gt;
&lt;li&gt;tag the team subnet&lt;/li&gt;
&lt;li&gt;the main Lambda event handler&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;https://gitlab.com/ascodenl/vpc-subnet-tagger/-/blob/main/lambda/subnet-tagger.py?ref_type=heads&quot;&gt;example&lt;/a&gt; shows a simplified version of the actual function that was implemented, it should however be easy to extend upon this code.&lt;/p&gt;
&lt;h3 id=&quot;iam-role-in-team-account-s&quot;&gt;IAM role in Team account(s)&lt;/h3&gt;
&lt;p&gt;The team account requires an IAM role to be created that can be assumed by the Lambda function in order to create the tags. This is a very simple role, which is created using &lt;a href=&quot;https://github.com/org-formation/org-formation-cli&quot;&gt;org-formation&lt;/a&gt; In short, in order to use Terraform several resources like an S3 bucket for state backend and IAM roles are required. Org-formation uses CloudFormation under the hood for creating the resources that are required for Terraform and resources that need to be created in all accounts. When AWS Control Tower is not an option, we leverage org-formation as our account vending machine in AWS.
Note the &lt;code&gt;OrganizationBinding&lt;/code&gt; parameter, this is specific to org-formation to target or filter only specific accounts or OU&#39;s. If you don&#39;t use org-formation, omit this parameter.&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;LambdaTaggerPolicy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;AWS::IAM::ManagedPolicy&quot;&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;OrganizationBinding&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token tag&quot;&gt;!Ref&lt;/span&gt; TeamAccountBinding
  &lt;span class=&quot;token key atrule&quot;&gt;Properties&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;ManagedPolicyName&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; LambdaTaggerPolicy
    &lt;span class=&quot;token key atrule&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Policy to allow access from lz&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;net Lambda function
    &lt;span class=&quot;token key atrule&quot;&gt;PolicyDocument&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;Version&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token datetime number&quot;&gt;2012-10-17&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;Statement&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;Effect&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Allow
          &lt;span class=&quot;token key atrule&quot;&gt;Action&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ec2:CreateTags&quot;&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;Resource&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;arn:aws:ec2:*:*:subnet/*&quot;&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;arn:aws:ec2:*:*:vpc/*&quot;&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;Roles&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token tag&quot;&gt;!Ref&lt;/span&gt; LambdaTaggerRole

&lt;span class=&quot;token key atrule&quot;&gt;LambdaTaggerRole&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; AWS&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;IAM&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;Role
  &lt;span class=&quot;token key atrule&quot;&gt;OrganizationBinding&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token tag&quot;&gt;!Ref&lt;/span&gt; TeamAccountBinding
  &lt;span class=&quot;token key atrule&quot;&gt;Properties&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;RoleName&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; LambdaTaggerRole
    &lt;span class=&quot;token key atrule&quot;&gt;AssumeRolePolicyDocument&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;Version&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;2012-10-17&quot;&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;Statement&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;Effect&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Allow
          &lt;span class=&quot;token key atrule&quot;&gt;Principal&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;Service&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; lambda.amazonaws.com
          &lt;span class=&quot;token key atrule&quot;&gt;Action&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;sts:AssumeRole&quot;&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;Effect&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Allow
          &lt;span class=&quot;token key atrule&quot;&gt;Principal&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;AWS&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&amp;lt;central network account ID&gt;&quot;&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;Action&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;sts:AssumeRole&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s it! Even though the current Lambda is “only” used to tag resources, you can imagine this concept can be used for all sorts of implementations. Go nuts!&lt;/p&gt;
</content>
  </entry>
</feed>