<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.glen-thomas.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.glen-thomas.com/" rel="alternate" type="text/html" /><updated>2026-07-12T22:36:46+01:00</updated><id>https://blog.glen-thomas.com/feed.xml</id><title type="html">Glen Thomas</title><subtitle>Personal tech blog for Glen Thomas</subtitle><author><name>Glen Thomas</name></author><entry><title type="html">A Developer’s Guide to GitHub Copilot CLI: Agentic Coding in Your Terminal</title><link href="https://blog.glen-thomas.com/software%20engineering/2026/07/11/a-developers-guide-to-github-copilot-cli.html" rel="alternate" type="text/html" title="A Developer’s Guide to GitHub Copilot CLI: Agentic Coding in Your Terminal" /><published>2026-07-11T21:59:00+01:00</published><updated>2026-07-11T21:59:00+01:00</updated><id>https://blog.glen-thomas.com/software%20engineering/2026/07/11/a-developers-guide-to-github-copilot-cli</id><content type="html" xml:base="https://blog.glen-thomas.com/software%20engineering/2026/07/11/a-developers-guide-to-github-copilot-cli.html"><![CDATA[<p>Most of us live in the terminal. We build, test, commit, grep, tail logs, and wire together pipelines without ever reaching for the mouse. So it makes sense that GitHub brought the full power of its Copilot coding agent to exactly where developers already spend their time — the command line.</p>

<p>GitHub Copilot CLI is not “autocomplete in a shell”. It’s an agentic assistant that can read your code, plan multi-step work, run commands (with your approval), edit files, drive Git and <code class="language-plaintext highlighter-rouge">gh</code>, talk to GitHub.com, and call out to MCP servers and custom agents. It uses the same agentic harness as the Copilot coding agent, so what you get in the terminal is a genuine collaborator rather than a fancy prompt box.</p>

<p>This post is a practical, hands-on guide. We’ll cover installation, the core interactive workflow, plan mode, permissions, MCP servers, custom agents, and then finish with a set of tips and tricks for advanced usage.</p>

<h2 id="what-is-copilot-cli-and-what-makes-it-different">What is Copilot CLI (and what makes it different)?</h2>

<p>At a high level, Copilot CLI gives you:</p>

<ul>
  <li><strong>Terminal-native agentic development</strong> — build, edit, debug, and refactor without leaving your shell.</li>
  <li><strong>GitHub integration out of the box</strong> — the GitHub MCP server is pre-configured, so it can work with repos, issues, and pull requests using natural language, authenticated with your existing GitHub account.</li>
  <li><strong>Two ways to drive it</strong> — an interactive session for conversational, iterative work, and a programmatic (<code class="language-plaintext highlighter-rouge">-p</code>) mode for scripting and automation.</li>
  <li><strong>Full control by default</strong> — every action that could modify or execute files is previewed and requires your approval, unless you explicitly opt in to broader permissions.</li>
</ul>

<p>It’s available on <strong>all Copilot plans</strong> (Free, Pro, Business, Enterprise). If you get Copilot through an organisation, an admin needs to have the Copilot CLI policy enabled. It runs on <strong>Linux, macOS, and Windows</strong> (via PowerShell 6+ or WSL).</p>

<blockquote>
  <p>A note on cost: each prompt you submit consumes from your premium request / AI credit allowance, and the amount varies by model. Keep an eye on this with <code class="language-plaintext highlighter-rouge">/usage</code>, which we’ll cover later.</p>
</blockquote>

<h2 id="installing-copilot-cli">Installing Copilot CLI</h2>

<p>There are several installation paths — pick whichever fits your platform and tooling.</p>

<p><strong>Install script (macOS and Linux):</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsSL</span> https://gh.io/copilot-install | bash
</code></pre></div></div>

<p><strong>Homebrew (macOS and Linux):</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>copilot-cli
<span class="c"># or the prerelease channel for the newest features</span>
brew <span class="nb">install </span>copilot-cli@prerelease
</code></pre></div></div>

<p><strong>WinGet (Windows):</strong></p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">GitHub.Copilot</span><span class="w">
</span></code></pre></div></div>

<p><strong>npm (all platforms):</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @github/copilot
</code></pre></div></div>

<p>Because GitHub is iterating on this tool very quickly (there have been hundreds of releases), it’s worth keeping your client up to date to get the latest features and fixes.</p>

<h3 id="first-launch-and-authentication">First launch and authentication</h3>

<p>Launch the CLI from a folder containing code you want to work with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>copilot
</code></pre></div></div>

<p>On first launch you’ll be asked to confirm you trust the files in (and below) the current directory — more on why that matters in the security section. If you’re not logged in, run:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/login
</code></pre></div></div>

<p>and follow the on-screen instructions. You can also authenticate headlessly using a fine-grained Personal Access Token with the <strong>“Copilot Requests”</strong> permission, exposed via the <code class="language-plaintext highlighter-rouge">GH_TOKEN</code> or <code class="language-plaintext highlighter-rouge">GITHUB_TOKEN</code> environment variable — handy for CI and containers.</p>

<p>By default Copilot CLI uses Claude Sonnet 4.5. You can switch models (for example to GPT-5) at any time with the <code class="language-plaintext highlighter-rouge">/model</code> slash command.</p>

<h2 id="the-core-workflow-interactive-sessions">The core workflow: interactive sessions</h2>

<p>The interactive interface is where you’ll spend most of your time. Start a session with <code class="language-plaintext highlighter-rouge">copilot</code>, then just talk to it. A prompt can be a simple question or a request to do real work.</p>

<h3 id="example-1-ask-a-question-about-your-codebase">Example 1: Ask a question about your codebase</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; Where is rate limiting implemented in this service, and what limits are applied?
</code></pre></div></div>

<p>Copilot will search the code, read the relevant files, and answer with references to the exact locations — without you having to grep through anything.</p>

<h3 id="example-2-make-a-change">Example 2: Make a change</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; Change the background-color of H1 headings to dark blue
</code></pre></div></div>

<p>Copilot finds the relevant CSS file, proposes the edit, and asks for your approval before writing it. This preview-then-approve loop is the heart of how the CLI keeps you in control.</p>

<h3 id="example-3-investigate-history">Example 3: Investigate history</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; Show me the last 5 changes made to CHANGELOG.md. Who changed it, when, and give a brief summary of each change.
</code></pre></div></div>

<p>Here it drives Git on your behalf and summarises the results in plain language.</p>

<h3 id="example-4-build-something-from-scratch">Example 4: Build something from scratch</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; Use create-next-app and Tailwind to scaffold a Next.js dashboard that pulls
  build metrics from the GitHub API — build success rate, average duration,
  failed builds, and test pass rate. When it's done, give me clear instructions
  to build, run, and view it.
</code></pre></div></div>

<p>This is where the agentic nature shines: Copilot plans the work, runs the scaffolding commands (with approval), writes the code across multiple files, and then hands you run instructions.</p>

<h3 id="referencing-files-with-">Referencing files with <code class="language-plaintext highlighter-rouge">@</code></h3>

<p>To pull a specific file into your prompt as context, use <code class="language-plaintext highlighter-rouge">@</code> followed by the path:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; Explain @config/ci/ci-required-checks.yml
&gt; Fix the bug in @src/app.js
</code></pre></div></div>

<p>As you type a path, matching files appear below the prompt box; use the arrow keys and <kbd>Tab</kbd> to complete it. You can also <strong>drag and drop</strong> files, or <strong>paste images</strong> (and PDFs) directly — useful for handing Copilot a screenshot of a stack trace or a design mock, on models that support image input.</p>

<h3 id="running-shell-commands-directly">Running shell commands directly</h3>

<p>Prefix input with <code class="language-plaintext highlighter-rouge">!</code> to run a shell command yourself without involving the model:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!git status
!npm test
</code></pre></div></div>

<p>This is great for quickly checking state mid-conversation without spending a request.</p>

<h2 id="plan-mode-think-before-you-build">Plan mode: think before you build</h2>

<p>For anything non-trivial, <strong>plan mode</strong> is your friend. Press <kbd>Shift</kbd>+<kbd>Tab</kbd> to cycle into it. In plan mode, Copilot analyses your request, asks clarifying questions to nail down scope and requirements, and produces a structured implementation plan <em>before</em> writing a single line of code.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; [plan mode] Add OAuth2 login with GitHub as a provider to this Express app,
  including session handling and a protected /profile route.
</code></pre></div></div>

<p>Copilot will come back with questions (“Which session store do you want — in-memory, Redis, or database?”) and then a step-by-step plan. You catch misunderstandings early, stay in control of complex multi-step tasks, and only proceed to implementation once the plan looks right. Press <kbd>Shift</kbd>+<kbd>Tab</kbd> again to cycle back out and execute.</p>

<h2 id="rolling-back-when-things-go-wrong">Rolling back when things go wrong</h2>

<p>Copilot doesn’t always do exactly what you had in mind — it might edit the wrong file, take a task in an unexpected direction, or run a command whose results you’d rather undo. When that happens, you can <strong>rewind</strong> the session to an earlier prompt. Press <kbd>Esc</kbd> twice in quick succession (with an empty input box), or use the <code class="language-plaintext highlighter-rouge">/undo</code> slash command — or its alias <code class="language-plaintext highlighter-rouge">/rewind</code> — to open the rewind picker.</p>

<p>The picker lists the rewind points for your session, most recent first, showing the start of each prompt and how long ago you submitted it. Choosing one takes you back to the state <em>before</em> Copilot started working on that prompt, and the selected prompt is dropped back into the input box so you can tweak and resubmit it.</p>

<p>There are two rewind behaviours, and Copilot picks the best one for your environment automatically:</p>

<ul>
  <li><strong>Git-based rewind</strong> rolls the whole workspace back to a snapshot taken at the start of the prompt. This is thorough but blunt: it reverts <em>everything</em> after that point — Copilot’s changes, your own manual edits, and the effects of shell commands — and deletes any files created since the snapshot. It requires a Git repository with at least one commit.</li>
  <li><strong>Tools-based rewind</strong> is more surgical, letting you choose <strong>“Conversation only”</strong> (rewind the chat history but leave files untouched) or <strong>“Conversation + files”</strong> (also restore files Copilot changed). It’s currently experimental, so enable it first with <code class="language-plaintext highlighter-rouge">/experimental on</code> or the <code class="language-plaintext highlighter-rouge">--experimental</code> flag.</li>
</ul>

<p>A word of caution: <strong>rewinding can’t be undone</strong> — once you roll back, the later session history is gone for good. Afterwards, it’s worth confirming the result with a quick shell command (remember the <code class="language-plaintext highlighter-rouge">!</code> prefix), for example <code class="language-plaintext highlighter-rouge">!git status</code>, <code class="language-plaintext highlighter-rouge">!git log --oneline -1</code>, or <code class="language-plaintext highlighter-rouge">!git diff</code>, without leaving the CLI.</p>

<h2 id="understanding-permissions-and-staying-safe">Understanding permissions (and staying safe)</h2>

<p>By default, the first time Copilot wants to use a tool that could modify or execute files — <code class="language-plaintext highlighter-rouge">touch</code>, <code class="language-plaintext highlighter-rouge">chmod</code>, <code class="language-plaintext highlighter-rouge">node</code>, <code class="language-plaintext highlighter-rouge">sed</code>, and so on — it asks for approval. You’ll typically see three options:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Yes
2. Yes, and approve TOOL for the rest of the running session
3. No, and tell Copilot what to do differently (Esc)
</code></pre></div></div>

<ul>
  <li><strong>Option 1</strong> allows this one command, this once.</li>
  <li><strong>Option 2</strong> allows that tool for the rest of the session — convenient for something like <code class="language-plaintext highlighter-rouge">chmod</code> you’ll use repeatedly, but be careful: approving <code class="language-plaintext highlighter-rouge">rm</code> this way lets Copilot run <em>any</em> <code class="language-plaintext highlighter-rouge">rm</code> (including <code class="language-plaintext highlighter-rouge">rm -rf ./*</code>) without asking again.</li>
  <li><strong>Option 3</strong> cancels and lets you steer it in a different direction. You can give inline feedback on the rejection so Copilot adapts without stopping entirely.</li>
</ul>

<h3 id="fine-grained-control-from-the-command-line">Fine-grained control from the command line</h3>

<p>You can pre-authorise or block specific tools using flags that work in both interactive and programmatic modes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Allow a specific tool without prompting</span>
copilot <span class="nt">--allow-tool</span><span class="o">=</span><span class="s1">'shell(git)'</span>

<span class="c"># Allow all shell commands, but explicitly block the dangerous ones</span>
copilot <span class="nt">--allow-all-tools</span> <span class="nt">--deny-tool</span><span class="o">=</span><span class="s1">'shell(rm)'</span> <span class="nt">--deny-tool</span><span class="o">=</span><span class="s1">'shell(git push)'</span>

<span class="c"># Allow all tools from an MCP server except one specific tool</span>
copilot <span class="nt">--allow-tool</span><span class="o">=</span><span class="s1">'My-MCP-Server'</span> <span class="nt">--deny-tool</span><span class="o">=</span><span class="s1">'My-MCP-Server(tool_name)'</span>
</code></pre></div></div>

<p>A few things worth knowing:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--deny-tool</code> always takes precedence over <code class="language-plaintext highlighter-rouge">--allow-tool</code> and <code class="language-plaintext highlighter-rouge">--allow-all-tools</code>.</li>
  <li>For <code class="language-plaintext highlighter-rouge">git</code> and <code class="language-plaintext highlighter-rouge">gh</code>, you can scope to a subcommand, e.g. <code class="language-plaintext highlighter-rouge">shell(git push)</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">--allow-tool='write'</code> lets Copilot edit files without per-file approval.</li>
  <li>Inside a session, <code class="language-plaintext highlighter-rouge">/allow-all</code> and <code class="language-plaintext highlighter-rouge">/yolo</code> (or launching with <code class="language-plaintext highlighter-rouge">--allow-all</code> / <code class="language-plaintext highlighter-rouge">--yolo</code>) enable everything at once.</li>
</ul>

<p><strong>Use the broad options with care.</strong> With <code class="language-plaintext highlighter-rouge">--allow-all-tools</code>, Copilot has the same access to your machine that you do and can run any command without prior approval.</p>

<h3 id="trusted-directories-and-sandboxing">Trusted directories and sandboxing</h3>

<p>Copilot CLI only operates within directories you’ve marked as trusted, which is why it prompts you on launch. Don’t launch it from your home directory or from folders full of untrusted executables or sensitive data. You can add directories mid-session with <code class="language-plaintext highlighter-rouge">/add-dir /path/to/dir</code>, and switch the working directory with <code class="language-plaintext highlighter-rouge">/cwd</code> or <code class="language-plaintext highlighter-rouge">/cd</code>.</p>

<p>For extra safety when using automatic approvals, run inside a <strong>sandbox</strong>:</p>

<ul>
  <li><strong>Local sandboxing</strong> — run <code class="language-plaintext highlighter-rouge">/sandbox enable</code> inside a session to restrict Copilot’s access to your filesystem, network, and system capabilities.</li>
  <li><strong>Cloud sandboxing</strong> — start an isolated, cloud-hosted session with <code class="language-plaintext highlighter-rouge">copilot --cloud</code>. This keeps session state between uses, lets you continue from a different machine, and run tasks in parallel — without touching your local environment. (Sandboxes are in public preview.)</li>
</ul>

<h2 id="working-with-githubcom">Working with GitHub.com</h2>

<p>Because the GitHub MCP server is pre-configured, Copilot CLI is fluent in your GitHub workflow. Some things you can just ask for:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; List my open PRs
&gt; List all open issues assigned to me in OWNER/REPO
&gt; I've been assigned https://github.com/octo-org/octo-repo/issues/1234 —
  start working on it for me in a suitably named branch.
&gt; Create a PR that changes the "How to run" heading to "Example usage" in the
  README of https://github.com/octo-org/octo-repo
&gt; Check the changes in PR https://github.com/octo-org/octo-repo/pull/57575 and
  report any serious errors.
&gt; Merge all of the open PRs I've created in octo-org/octo-repo
</code></pre></div></div>

<p>When Copilot opens a PR or issue, it does so on your behalf, and you’re recorded as the author. This turns the CLI into a genuinely useful “assign it and walk away” tool for small, well-scoped changes.</p>

<h2 id="programmatic-mode-copilot-in-scripts-and-pipelines">Programmatic mode: Copilot in scripts and pipelines</h2>

<p>Pass a single prompt with <code class="language-plaintext highlighter-rouge">-p</code> (or <code class="language-plaintext highlighter-rouge">--prompt</code>) and the CLI completes the task and exits — perfect for automation. Pair it with approval flags so it can run unattended:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>copilot <span class="nt">-p</span> <span class="s2">"Show me this week's commits and summarise them"</span> <span class="nt">--allow-tool</span><span class="o">=</span><span class="s1">'shell(git)'</span>

copilot <span class="nt">-p</span> <span class="s2">"Revert the last commit, leaving the changes unstaged"</span> <span class="nt">--allow-all-tools</span>
</code></pre></div></div>

<p>You can also generate flags dynamically and pipe them in:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./script-outputting-options.sh | copilot
</code></pre></div></div>

<p>This unlocks patterns like nightly summarisation jobs, scripted refactors across many repos, or a CI step that asks Copilot to review a diff. Just remember: with automatic approval, there’s no human in the loop, so sandboxing and tightly-scoped <code class="language-plaintext highlighter-rouge">--deny-tool</code> rules become important.</p>

<h2 id="working-autonomously-autopilot-and-delegate">Working autonomously: autopilot and <code class="language-plaintext highlighter-rouge">/delegate</code></h2>

<p>Sometimes you don’t want to babysit each step — you just want to hand off a task. Copilot CLI gives you two ways to do this, and the difference comes down to <em>where</em> the work happens.</p>

<p><strong>Autopilot mode runs locally.</strong> You grant full permissions and Copilot works through the task without stopping to prompt you, while you watch progress in real time on your own machine. Enable it interactively by pressing <kbd>Shift</kbd>+<kbd>Tab</kbd> until you see “autopilot” in the status bar, then enter your prompt. Or run it programmatically:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>copilot <span class="nt">--autopilot</span> <span class="nt">--yolo</span> <span class="nt">--max-autopilot-continues</span> 10 <span class="nt">-p</span> <span class="s2">"Migrate the test suite from Jest to Vitest and make it pass"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--max-autopilot-continues</code> flag is a useful safety valve — it caps how many times autopilot will keep going before handing back control.</p>

<p><strong><code class="language-plaintext highlighter-rouge">/delegate</code> pushes the work to the cloud.</strong> Instead of running locally, it hands the task to the Copilot cloud agent on GitHub: Copilot creates a branch, opens a draft pull request, and works in the background — so the task keeps running even if you close your laptop.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/delegate complete the API integration tests and fix any failing edge cases
</code></pre></div></div>

<p>You can also prefix a prompt with <code class="language-plaintext highlighter-rouge">&amp;</code> as shorthand for the same thing:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&amp; complete the API integration tests and fix any failing edge cases
</code></pre></div></div>

<p>Copilot will offer to commit any unstaged changes as a checkpoint on the new branch, then give you a link to the pull request and cloud agent session so you can follow along and review when it’s done. Reach for autopilot when you want hands-free <em>local</em> execution, and <code class="language-plaintext highlighter-rouge">/delegate</code> when you want to offload a task entirely and get on with something else.</p>

<h3 id="running-work-in-parallel-with-fleet">Running work in parallel with <code class="language-plaintext highlighter-rouge">/fleet</code></h3>

<p>When a task breaks down into several independent pieces, the <code class="language-plaintext highlighter-rouge">/fleet</code> slash command speeds things up by farming the work out to multiple subagents that run in parallel. It pairs naturally with plan mode: build a plan first, then let a fleet execute it.</p>

<p>The typical flow is:</p>

<ol>
  <li>Press <kbd>Shift</kbd>+<kbd>Tab</kbd> to switch into <strong>plan mode</strong> and describe the feature or change you want.</li>
  <li>Work with Copilot to refine the implementation plan.</li>
  <li>Once the plan looks right, either choose <strong>“Accept plan and build on autopilot + /fleet”</strong> to have it work autonomously with subagents, or exit plan mode and prompt it yourself:</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/fleet implement the plan
</code></pre></div></div>

<p>Copilot then splits the work across subagents, running independent parts concurrently. Monitor them with the <code class="language-plaintext highlighter-rouge">/tasks</code> slash command, which lists the background tasks for the session — including each subagent’s subtask. Use the arrow keys to navigate; press <kbd>Enter</kbd> to view details (or a summary once complete), <kbd>k</kbd> to kill a process, and <kbd>r</kbd> to clear finished ones. Press <kbd>Esc</kbd> to return to the prompt.</p>

<h2 id="extending-copilot-cli">Extending Copilot CLI</h2>

<p>This is where the CLI goes from “useful” to “tailored to how your team works”.</p>

<h3 id="mcp-servers">MCP servers</h3>

<p>Model Context Protocol (MCP) servers give Copilot access to additional data sources and tools. The GitHub server ships by default; to add more:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/mcp add
</code></pre></div></div>

<p>Fill in the details (use <kbd>Tab</kbd> to move between fields) and press <kbd>Ctrl</kbd>+<kbd>S</kbd> to save. Configurations live in <code class="language-plaintext highlighter-rouge">~/.copilot/mcp-config.json</code> (override the location with <code class="language-plaintext highlighter-rouge">COPILOT_HOME</code>). You can list configured servers and their tool names with <code class="language-plaintext highlighter-rouge">/mcp</code> — useful when you want to reference a server precisely in an <code class="language-plaintext highlighter-rouge">--allow-tool</code> / <code class="language-plaintext highlighter-rouge">--deny-tool</code> rule.</p>

<blockquote>
  <p>Tip: if you know a specific MCP server can do the job, name it in your prompt — e.g. <em>“Use the GitHub MCP server to find good first issues in octo-org/octo-repo”</em> — to steer Copilot toward the result you want.</p>
</blockquote>

<h3 id="custom-instructions">Custom instructions</h3>

<p>Copilot CLI automatically reads custom instructions from your repository, so it understands your conventions and how to build, test, and validate changes. It supports:</p>

<ul>
  <li>Repository-wide: <code class="language-plaintext highlighter-rouge">.github/copilot-instructions.md</code></li>
  <li>Path-specific: <code class="language-plaintext highlighter-rouge">.github/instructions/**/*.instructions.md</code></li>
  <li>Agent files: <code class="language-plaintext highlighter-rouge">AGENTS.md</code></li>
</ul>

<p>All applicable instruction files now <strong>combine</strong> rather than falling back by priority, so you can layer general and path-specific guidance.</p>

<blockquote>
  <p>Tip: keep your instructions files minimal for efficient token usage. They’re injected into every prompt, so every line costs context. Include only the essential guidance for working in the repository — build and test commands, key conventions, and anything non-obvious — and leave out anything Copilot can readily infer from the code itself.</p>
</blockquote>

<h3 id="custom-agents">Custom agents</h3>

<p>Custom agents are specialised versions of Copilot for particular workflows. The CLI ships with a set of built-in ones it can delegate to automatically:</p>

<table>
  <thead>
    <tr>
      <th>Agent</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Explore</strong></td>
      <td>Fast, read-only codebase analysis without polluting your main context</td>
    </tr>
    <tr>
      <td><strong>Task</strong></td>
      <td>Runs commands like tests and builds; brief summary on success, full output on failure</td>
    </tr>
    <tr>
      <td><strong>General purpose</strong></td>
      <td>Complex multi-step work using the full toolset, in a separate context</td>
    </tr>
    <tr>
      <td><strong>Code review</strong></td>
      <td>Reviews changes, focusing on genuine issues and minimising noise</td>
    </tr>
    <tr>
      <td><strong>Research</strong></td>
      <td>Deep research across your code, related repos, and the web, with citations</td>
    </tr>
    <tr>
      <td><strong>Rubber duck</strong></td>
      <td>A constructive critic; consulted automatically by Copilot</td>
    </tr>
  </tbody>
</table>

<p>You can invoke agents three ways:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Pick from a list interactively
/agent

# Ask for one in natural language
&gt; Use the code review agent to check my staged changes

# Select one at launch
copilot --agent=refactor-agent --prompt "Refactor this code block"
</code></pre></div></div>

<p>Define your own agents as Markdown “agent profiles” specifying their expertise, allowed tools, and instructions — at user level (<code class="language-plaintext highlighter-rouge">~/.copilot/agents</code>), repository level (<code class="language-plaintext highlighter-rouge">.github/agents</code>), or organisation/enterprise level (<code class="language-plaintext highlighter-rouge">.github-private/agents</code>). On naming conflicts, user overrides repository overrides organisation.</p>

<h3 id="skills-and-hooks">Skills and hooks</h3>

<p>Two more extension points let you package know-how and enforce guardrails.</p>

<p><strong>Skills</strong> enhance Copilot’s ability to perform specialised tasks with bundled instructions, scripts, and resources. A skill is a folder containing a <code class="language-plaintext highlighter-rouge">SKILL.md</code> file (with a name and description) plus any helper scripts or reference material. Copilot reads the description to decide when the skill is relevant, then follows its instructions.</p>

<p>For example, a skill that standardises how release notes are generated might live at <code class="language-plaintext highlighter-rouge">.github/skills/release-notes/SKILL.md</code>:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">release-notes</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Generate release notes from merged PRs since the last tag, grouped</span>
  <span class="s">by Added / Changed / Fixed, following our CHANGELOG format.</span>
<span class="nn">---</span>

<span class="gh"># Release notes</span>
<span class="p">
1.</span> Find the most recent git tag.
<span class="p">2.</span> Collect merged PRs since that tag using <span class="sb">`gh pr list --state merged`</span>.
<span class="p">3.</span> Group them by Conventional Commit type into Added / Changed / Fixed.
<span class="p">4.</span> Write the result to the top of <span class="sb">`CHANGELOG.md`</span>, keeping the existing entries.
</code></pre></div></div>

<p>Now a prompt like <em>“Draft the release notes for the next version”</em> will pick up the skill and produce output that matches your conventions every time.</p>

<p><strong>Hooks</strong> let you run custom shell commands automatically at key points in a session — great for validation, logging, security scanning, or workflow automation. Because a hook fires deterministically around agent events, it’s a good place to enforce guardrails rather than relying on the model to remember a rule.</p>

<p>Hooks are configured in JSON. Create a <code class="language-plaintext highlighter-rouge">NAME.json</code> file in <code class="language-plaintext highlighter-rouge">.github/hooks/</code> (repository-level) or <code class="language-plaintext highlighter-rouge">~/.copilot/hooks/</code> (user-level), with a <code class="language-plaintext highlighter-rouge">version</code> and a <code class="language-plaintext highlighter-rouge">hooks</code> object keyed by event — the available triggers include <code class="language-plaintext highlighter-rouge">sessionStart</code>, <code class="language-plaintext highlighter-rouge">sessionEnd</code>, <code class="language-plaintext highlighter-rouge">userPromptSubmitted</code>, <code class="language-plaintext highlighter-rouge">preToolUse</code>, <code class="language-plaintext highlighter-rouge">postToolUse</code>, and <code class="language-plaintext highlighter-rouge">errorOccurred</code>.</p>

<p>Each entry provides both a <code class="language-plaintext highlighter-rouge">bash</code> command (for Linux/macOS) and a <code class="language-plaintext highlighter-rouge">powershell</code> command (for Windows), so the hook runs everywhere. For example, a <code class="language-plaintext highlighter-rouge">preToolUse</code> hook that logs every tool the agent is about to run — handy for auditing — would live in <code class="language-plaintext highlighter-rouge">.github/hooks/audit.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"preToolUse"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"bash"</span><span class="p">:</span><span class="w"> </span><span class="s2">"echo </span><span class="se">\"</span><span class="s2">$(date): about to run tool</span><span class="se">\"</span><span class="s2"> &gt;&gt; logs/agent-audit.log"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"powershell"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Add-Content -Path logs/agent-audit.log -Value </span><span class="se">\"</span><span class="s2">$(Get-Date): about to run tool</span><span class="se">\"</span><span class="s2">"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"cwd"</span><span class="p">:</span><span class="w"> </span><span class="s2">"."</span><span class="p">,</span><span class="w">
        </span><span class="nl">"timeoutSec"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>You can also point a hook at an external script instead of an inline command — useful for anything non-trivial, like scanning a proposed change for secrets before it’s written:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"preToolUse"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"bash"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./scripts/scan-secrets.sh"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"powershell"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./scripts/scan-secrets.ps1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"cwd"</span><span class="p">:</span><span class="w"> </span><span class="s2">"scripts"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"timeoutSec"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The hook receives details of the event as JSON on stdin (timestamp, working directory, tool name, and arguments), so your script can inspect what’s happening and act accordingly. A couple of things to remember: hook config is loaded when the CLI starts, so restart after editing it; any referenced script needs to be executable (<code class="language-plaintext highlighter-rouge">chmod +x</code>) with a proper shebang; and the default timeout is 30 seconds, tunable with <code class="language-plaintext highlighter-rouge">timeoutSec</code>.</p>

<h3 id="copilot-memory">Copilot Memory</h3>

<p>Normally, anything you teach Copilot in a session is forgotten when that session ends — so the next time you start up, you’re explaining your conventions all over again. Copilot Memory fixes that by letting the agent keep notes about your repository between sessions.</p>

<p>As it works, Copilot notices recurring facts, conventions, and preferences and saves them as short “memories”. For instance, if you correct it once — <em>“we use Vitest here, not Jest”</em>, or <em>“always put new API routes under <code class="language-plaintext highlighter-rouge">src/routes/</code> and export them from the barrel file”</em> — it can store that and apply it automatically next time, without you having to repeat yourself.</p>

<p>The practical payoff is that later sessions get progressively more useful: Copilot already knows how your project is laid out, which tools you use, and how you like things done, so you spend less time on context and more on the actual task. Think of it as the agent gradually onboarding itself to your codebase.</p>

<h2 id="tips-and-tricks-for-advanced-usage">Tips and tricks for advanced usage</h2>

<p>Here’s the stuff that makes daily use noticeably better.</p>

<p><strong>Review before you commit.</strong> Run <code class="language-plaintext highlighter-rouge">/review</code> to have Copilot analyse your uncommitted changes and give you feedback right in the terminal — no PR required. You can narrow the scope by adding a prompt, path, or file pattern after the command (e.g. <code class="language-plaintext highlighter-rouge">/review focus on error handling in src/api</code>). If Copilot needs to run a command to inspect the diff, it’ll ask first; then it reports back any issues so you can fix them before they ever reach a pull request.</p>

<p><strong>Steer while it’s thinking.</strong> You don’t have to wait for Copilot to finish. Enqueue follow-up messages to redirect it or queue up the next instruction, and it’ll feel far more like a real conversation.</p>

<p><strong>Connect it to VS Code for the best of both worlds.</strong> If you like the speed of the terminal but miss the visual tools of an editor, connect the CLI to VS Code. Start Copilot from a directory that matches an open (trusted) VS Code workspace and it connects automatically — you’ll see “Visual Studio Code connected” in the startup message — or use the <code class="language-plaintext highlighter-rouge">/ide</code> slash command to connect, switch, or disconnect at any time. This works whether you’re in VS Code’s integrated terminal or an external one. Once connected you get: your <strong>editor selection as context</strong> (highlight code and just say <em>“Debug this”</em> — no file paths needed), proposed edits shown as <strong>side-by-side diffs</strong> you accept or reject visually, access to VS Code’s <strong>live diagnostics</strong> so Copilot can fix errors your editor already spotted, and the ability to <strong>view CLI session transcripts</strong> in the Copilot Chat Sessions view and <em>Resume in Terminal</em> to pick up where you left off.</p>

<p><strong>Stop instantly.</strong> Pressed enter and immediately regretted the prompt? Hit <kbd>Esc</kbd> while it’s “Thinking” to stop.</p>

<p><strong>Resume where you left off.</strong> Sessions are saved with their context:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>copilot <span class="nt">--continue</span>          <span class="c"># resume the most recently closed session</span>
copilot <span class="nt">--resume</span>            <span class="c"># pick a session to resume</span>
</code></pre></div></div>

<p>You can even kick off a Copilot cloud agent session on GitHub and pull it down into your local CLI to keep going.</p>

<p><strong>Manage your context window.</strong> Long sessions are handled automatically — Copilot auto-compacts your history in the background at ~95% of the token limit, enabling effectively endless sessions. To take manual control:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/context</code> — a visual breakdown of current token usage</li>
  <li><code class="language-plaintext highlighter-rouge">/compact</code> — compress history now (press <kbd>Esc</kbd> to cancel)</li>
  <li><code class="language-plaintext highlighter-rouge">/usage</code> — AI credits used, session duration, lines edited, and per-model token breakdown</li>
</ul>

<p><strong>Schedule prompts.</strong> Automate recurring or delayed work right from a session:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/every 1h Run frontend tests and report any failures
/after 30m Summarise today's merged PRs
</code></pre></div></div>

<p><em>At time of writing, scheduled prompts are currently an experimental feature, so you’ll need to enable experimental mode first — either with the <code class="language-plaintext highlighter-rouge">/experimental on</code> slash command inside a session, or by launching with the <code class="language-plaintext highlighter-rouge">--experimental</code> command-line option.</em></p>

<p><strong>Toggle reasoning visibility.</strong> Press <kbd>Ctrl</kbd>+<kbd>T</kbd> to show or hide the model’s reasoning as it works. The setting persists across sessions — handy when you want to understand <em>how</em> it reached a conclusion.</p>

<p><strong>Tune settings without leaving the CLI.</strong> Use <code class="language-plaintext highlighter-rouge">/settings</code> to open an interactive editor, <code class="language-plaintext highlighter-rouge">/settings KEY VALUE</code> to set something directly (works in scripts too), and <code class="language-plaintext highlighter-rouge">/settings show KEY</code> to print a value. <kbd>Ctrl</kbd>+<kbd>R</kbd> restores a setting to its default.</p>

<p><strong>Pick the right model for the job.</strong> Newer models support a <strong>1M-token context window</strong> and configurable reasoning levels. Larger context and higher reasoning consume more credits, so use the regular settings by default and reach for the big context / deep reasoning only on genuinely complex, multi-file tasks.</p>

<p><strong>Bring your own model.</strong> You can point Copilot CLI at an OpenAI-compatible endpoint, Azure OpenAI, Anthropic, or a local model like Ollama, using environment variables (<code class="language-plaintext highlighter-rouge">COPILOT_PROVIDER_BASE_URL</code>, <code class="language-plaintext highlighter-rouge">COPILOT_PROVIDER_TYPE</code>, <code class="language-plaintext highlighter-rouge">COPILOT_PROVIDER_API_KEY</code>, <code class="language-plaintext highlighter-rouge">COPILOT_MODEL</code>). The model must support tool calling and streaming; run <code class="language-plaintext highlighter-rouge">copilot help providers</code> for details.</p>

<p><strong>Wire it into other tools via ACP.</strong> Copilot CLI can act as an agent over the Agent Client Protocol (ACP), so IDEs and automation systems that speak ACP can use it directly. See <a href="https://docs.github.com/en/copilot/reference/copilot-cli-reference/acp-server">Copilot CLI ACP server</a> for more info.</p>

<p><strong>Voice input.</strong> On supported setups you can speak your prompt instead of typing — handy when your hands are busy or you’re describing something long. First, run <code class="language-plaintext highlighter-rouge">/voice</code> in a session to download the local speech runtime and a voice model (transcription runs entirely on your machine — no audio leaves it; English and Spanish are supported). Then dictate one of two ways: <strong>hold the space bar</strong> while you talk and release when done for short prompts, or press <kbd>Ctrl</kbd>+<kbd>X</kbd> then <kbd>V</kbd> to toggle recording on for longer ones (press any key to stop). The transcribed text lands at your cursor so you can tweak it before submitting.</p>

<p><strong>Discover everything fast.</strong> Type <code class="language-plaintext highlighter-rouge">?</code> in a session, or run <code class="language-plaintext highlighter-rouge">copilot help</code> in your terminal. For deeper reference, use the subcommands <code class="language-plaintext highlighter-rouge">copilot help config</code>, <code class="language-plaintext highlighter-rouge">copilot help environment</code>, <code class="language-plaintext highlighter-rouge">copilot help logging</code>, and <code class="language-plaintext highlighter-rouge">copilot help permissions</code>.</p>

<h2 id="a-pragmatic-workflow-to-adopt">A pragmatic workflow to adopt</h2>

<p>If you want a starting point, this is a solid loop:</p>

<ol>
  <li>Launch <code class="language-plaintext highlighter-rouge">copilot</code> from your project root and trust the directory.</li>
  <li>For anything beyond a one-liner, drop into <strong>plan mode</strong> (<kbd>Shift</kbd>+<kbd>Tab</kbd>) and refine the plan before any code is written.</li>
  <li>Let it work, approving tools individually until you trust the pattern, then approve for the session.</li>
  <li>Use <code class="language-plaintext highlighter-rouge">!</code> for quick checks (<code class="language-plaintext highlighter-rouge">!git diff</code>, <code class="language-plaintext highlighter-rouge">!npm test</code>) without spending requests.</li>
  <li>Run <code class="language-plaintext highlighter-rouge">/review</code> (or ask the <strong>code review agent</strong>) to sanity-check the diff before you commit.</li>
  <li>Have Copilot commit, push, and open a PR — then keep an eye on <code class="language-plaintext highlighter-rouge">/usage</code>.</li>
</ol>

<p>For unattended automation, script it with <code class="language-plaintext highlighter-rouge">-p</code>, scope permissions tightly with <code class="language-plaintext highlighter-rouge">--allow-tool</code> / <code class="language-plaintext highlighter-rouge">--deny-tool</code>, and run inside a sandbox.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>GitHub Copilot CLI brings a real agentic collaborator to the place developers already work. The core loop — converse, plan, approve, execute — keeps you firmly in control while offloading the mechanical parts of building, debugging, and shipping. Layer on MCP servers, custom agents, custom instructions, and memory, and it starts to feel like a teammate that already knows your codebase and conventions.</p>

<p>The tool is evolving quickly, so keep your client updated, start small with tightly-scoped tasks, and lean on plan mode and sandboxes as you build trust. Once it’s part of your muscle memory, you’ll wonder how you shipped without it.</p>]]></content><author><name>Glen Thomas</name></author><category term="Software Engineering" /><category term="GitHub Copilot" /><category term="CLI" /><category term="AI" /><category term="Developer Tools" /><category term="MCP" /><category term="Agents" /><category term="Productivity" /><category term="Automation" /><summary type="html"><![CDATA[Most of us live in the terminal. We build, test, commit, grep, tail logs, and wire together pipelines without ever reaching for the mouse. So it makes sense that GitHub brought the full power of its Copilot coding agent to exactly where developers already spend their time — the command line.]]></summary></entry><entry><title type="html">Book Review: Crafting Engineering Strategy by Will Larson</title><link href="https://blog.glen-thomas.com/leadership/2026/07/05/crafting-engineering-strategy-will-larson-book-review.html" rel="alternate" type="text/html" title="Book Review: Crafting Engineering Strategy by Will Larson" /><published>2026-07-05T10:00:00+01:00</published><updated>2026-07-05T10:00:00+01:00</updated><id>https://blog.glen-thomas.com/leadership/2026/07/05/crafting-engineering-strategy-will-larson-book-review</id><content type="html" xml:base="https://blog.glen-thomas.com/leadership/2026/07/05/crafting-engineering-strategy-will-larson-book-review.html"><![CDATA[<p>Ask a room full of engineers whether their company has an engineering strategy and you’ll almost always get the same weary answer: no, not really. It’s one of those complaints that has become a kind of folk wisdom. The economy used to be better, children used to respect their parents, and engineering organisations used to have a strategy.</p>

<p>Will Larson’s new book, <a href="https://amzn.eu/d/06jMoTL1">Crafting Engineering Strategy: How Thoughtful Decisions Solve Complex Problems</a>, sets out to dismantle that complaint. Larson is the author of <em>An Elegant Puzzle</em>, <em>Staff Engineer</em> and <em>The Engineering Executive’s Primer</em>, and here he turns his attention to the one topic engineers most love to grumble about and least understand how to influence. His central claim is refreshingly unglamorous. Strategy, he writes, is “the art of reproducibly making good decisions.” Not visionary pronouncements from on high, not a glossy deck presented at the all-hands, but the boring, repeatable practice of deciding well and writing it down.</p>

<p>I found this to be one of the most immediately useful engineering books I’ve read in years, precisely because it strips away the mystique. This post walks through the ideas that stuck with me, and then looks at how the book can be applied by different roles across an engineering organisation, because one of Larson’s most encouraging arguments is that strategy is not the exclusive property of executives.</p>

<h2 id="there-is-always-a-strategy">There is always a strategy</h2>

<p>The book opens by refusing to accept the premise that a strategy is missing. Larson has “never worked somewhere where people didn’t claim there was no strategy,” and yet he’s also never found a company that genuinely lacked one. His point is that a strategy is embedded in the decisions an organisation actually makes, whether or not anyone has bothered to write it down. Borrowing William Gibson’s famous line about the future being unevenly distributed, he suggests the strategy is already there; it’s just visible only to a small group, and often quickly forgotten.</p>

<p>This reframing matters because it changes the question. Instead of asking “why don’t we have a strategy?”, Larson wants you to ask where the strategy lives if you can’t find it. When you go looking, you’ll usually discover an implicit set of rules that nobody has examined and nobody can therefore improve. His prescription is disarmingly simple, and it’s the thread running through the entire book:</p>

<blockquote>
  <p>The single biggest act you can take to further strategy in your organization is to write down strategy so it can be debated, agreed upon, and explicitly evolved.</p>
</blockquote>

<p>Writing it down is what makes it possible to <em>precisely</em> disagree. He describes his time at Carta, where a written strategy meant new joiners could argue with decisions “from an informed place rather than coming across as attached to their prior company’s practices.” Oral history gives you a version of events that depends entirely on who you happened to ask. Written history lets an organisation agree at scale, which he argues is the prerequisite for growing at scale rather than trapping all the context inside a handful of senior heads.</p>

<h2 id="the-five-steps">The five steps</h2>

<p>The backbone of the book is a repeatable, five-step structure for producing a strategy: explore, diagnose, refine, set policy, and operate. Larson is candid that this isn’t a magic formula, and that “strategies fail more often due to avoidable errors than from fundamentally unsound thinking. Busy people skip steps. Especially steps they dislike or have failed at before.” The structure exists to stop you skipping the awkward parts.</p>

<p><strong>Exploration</strong> comes first, and it’s deliberately judgement-free. Before committing to an approach, you look at how other teams and companies have handled the same problem. His example is Uber’s 2014 service migration, where the team grounded themselves by reading the Google Borg and Berkeley Mesos papers before deciding anything. The discipline here is resisting the urge to reach for whatever worked at your last job.</p>

<p><strong>Diagnosis</strong> is where the book earns its keep, and Larson calls it strategy’s foundation. This is the deliberate act of understanding your problem before reaching for a solution. He is blunt about the stakes:</p>

<blockquote>
  <p>Every strategy that I’ve seen fail, did so due to a lazy or inaccurate diagnoses. It’s very challenging to fail with a proper diagnosis, and almost impossible to succeed without one.</p>
</blockquote>

<p>A good diagnosis, he argues, isn’t a statement of opinion you can easily dispute; it’s “a web of interconnected observations, facts, and data” that is hard to argue against even for people who dislike the conclusions. Two ideas from this chapter are especially worth highlighting. The first is that you should actively seek out the people most likely to disagree with you and represent their view faithfully, without letting your own bias show through. The second is his instruction to reframe blockers as part of the diagnosis rather than as reasons to give up. If your executives keep changing their minds, that isn’t proof that strategy is impossible; it’s a condition your strategy has to account for. As he puts it, “there are never reasons why strategy simply cannot succeed, only diagnoses you’ve failed to recognize.”</p>

<p>He’s also honest about organisational politics. Sometimes the true diagnosis is that a particular leader is the problem, and you cannot write that down. His advice is to “whisper the controversial parts”, finding a professional, factual framing. His example from a private equity transition simply notes that new ownership “will expect us to reduce R&amp;D headcount costs” without editorialising about how anyone feels about it.</p>

<p><strong>Refinement</strong> is the step most people skip. This is where you pressure test a raw diagnosis using one of three techniques: strategy testing, systems modelling, and Wardley mapping. Strategy testing is his antidote to what he calls the waterfall strategy trap: rather than perfecting a document in a vacuum, you run your proto-strategy against reality and iterate. His deeper point is that the biggest risk isn’t modelling too early or mapping too late, it’s doing neither, so he keeps the barrier to entry low.</p>

<p><strong>Policy</strong> is where decisions finally get made. “Policy is interpreting your diagnosis into a concrete plan,” and he divides policies into four recurring kinds: approvals, allocations, direction, and guidance. <em>Direction</em> is unambiguous instruction, as in Calm’s blunt “all new code must be written in the monolith”, which removed a debate that individual judgement kept reopening. <em>Guidance</em> is for when you know the destination but can’t mandate the route. <em>Allocations</em> are the most concrete statement of priority an organisation makes; Uber’s decision to fix “one full time engineer on manual service provisioning tasks” and pour everyone else into automation is a policy that says more about priorities than any mission statement could. He offers two criteria for judging a policy: it must be <em>applicable</em> to real, messy situations, and it must be <em>enforced</em>. A policy such as “we only hire world-class engineers” fails both, because nobody can define or consistently apply it.</p>

<p><strong>Operations</strong> is the step that separates strategies that change behaviour from strategies that “fade quietly into your organization’s history.” Policies don’t implement themselves. Larson’s memorable framing is that “effectively operating a policy is two thirds avoiding common practices that simply don’t work” such as top-down pronouncements, one-off training, and the vague hope that you’ll “just change the culture.” The remaining third is choosing concrete mechanisms: approval forums, inspection, automation, and above all nudges. He’s a strong advocate for the humble nudge, recalling how Stripe told teams when their cloud spend accelerated rather than forbidding all new spend outright, and concluding that “done well, I continue to believe they are the most effective operational mechanism.”</p>

<h2 id="putting-it-together-a-worked-example">Putting it together: a worked example</h2>

<p>Laid out one after another, the five steps can still feel abstract, so it’s worth walking a single problem all the way through them. The book leans on real case studies from Uber, Stripe and Calm to do this; what follows is my own illustrative scenario rather than one of Larson’s, but it’s the kind of situation many scale-ups will recognise.</p>

<p>Imagine a company that has grown from thirty engineers to a hundred and fifty in two years. New services are appearing faster than anyone can count, on-call rotas are miserable, and the small platform team is drowning in provisioning requests. Everyone agrees “something should be done”, but every attempt to fix it dissolves into an argument about microservices versus monoliths.</p>

<p><strong>Exploration</strong> comes first, and it stays deliberately opinion-free. You read how comparable companies handled service sprawl, you dig out the internal design docs behind the three services that caused the most pain last year, and you note which patterns keep recurring. You resist the urge to announce a conclusion. The goal is simply to understand the shape of the problem space before committing to anything.</p>

<p><strong>Diagnosis</strong> turns that reading into an honest, evidence-backed picture of your specific situation. Perhaps the data shows that the platform team of four hasn’t grown despite the engineering organisation tripling, that ninety per cent of new services were created to dodge a slow provisioning queue rather than for any architectural reason, and that most services have fewer than two committers. Following Larson’s advice, you deliberately sit down with the staff engineer who is convinced microservices are the future, and you capture their view faithfully. You also reframe the obvious blocker, “the platform team is understaffed and that won’t change this year”, not as a reason to give up but as a constraint the strategy must be built around. That single move, treating the immovable object as part of the diagnosis, is what stops the whole effort stalling.</p>

<p><strong>Refinement</strong> pressure tests that picture before you commit. A quick systems model might reveal that adding services actually slows the organisation down once the platform team saturates, which is a far more persuasive argument than any assertion. Rather than perfecting a grand document, you run a small strategy test: for one month, route all new service requests through a lightweight review and see what actually happens. You learn cheaply, and you gather real evidence instead of speculation.</p>

<p><strong>Policy</strong> is where the decisions finally land, and the diagnosis makes them feel almost inevitable. You might set a piece of <em>direction</em> (“new functionality is built in the existing service that owns that domain unless an exception is granted”), an <em>allocation</em> that fixes one platform engineer on manual provisioning while the rest build self-service tooling, and some <em>guidance</em> encouraging teams to fold thinly-staffed services back into their owning domain where it makes sense. Each policy maps to a specific line of the diagnosis, and each one is both applicable and enforceable, which is Larson’s bar for a policy worth having.</p>

<p><strong>Operations</strong> is what makes any of that real. Instead of a launch email and a hopeful shrug, you add a nudge that pings a team the moment they try to create a new service, pointing them at the guidance and the exception process. You stand up a fortnightly inspection of new services and provisioning lead times on a shared dashboard that “cannot silently fail”, and you route exception requests through a single lightweight approval channel. None of this depends on a heroic mandate; it depends on mechanisms that keep working after the initial announcement has been forgotten.</p>

<p>Run end to end, the example shows why Larson insists the awkward early steps matter most. By the time you reach policy, the hard thinking is done and the decisions write themselves. Skip exploration and diagnosis, and you’d have jumped straight to “stop making so many microservices”, a decree that sounds decisive and changes nothing.</p>

<h2 id="how-different-roles-can-apply-the-book">How different roles can apply the book</h2>

<p>The reason I’d recommend this book widely is that it explicitly rejects the idea that you need a title before you’re allowed to think strategically. “You can work on strategy from anywhere in an organization,” Larson writes. “It just requires different tactics to do so.” Here’s how that plays out across the roles you’ll find in most engineering organisations.</p>

<h3 id="engineers">Engineers</h3>

<p>If you’re an individual contributor without formal authority, the book’s most valuable gift is a pair of low authority techniques. The first, borrowed from <em>Staff Engineer</em>, is “take five, then synthesize”: document how five recent, related decisions were actually made, then synthesise them into a diagnosis and policy. You’re not inventing a strategy so much as naming the one that already exists, which makes it very hard for anyone to argue you’re overstepping. The second, from <em>An Elegant Puzzle</em>, is “model, document, and share”: adopt the approach yourself, write down your reasoning, and let people copy your success. Neither requires anyone’s permission. For an engineer who has ever felt powerless watching a bad decision unfold on a team they don’t own, this is a practical route to influence.</p>

<h3 id="staff-and-principal-engineers">Staff and principal engineers</h3>

<p>Senior individual contributors sit at exactly the altitude the book is aimed at. Larson introduces the concept of <em>strategy altitude</em>, being deliberate about the level at which a policy should be set, and Staff-plus engineers are often the people who can see when a decision is being made at the wrong one. They’re also central to his idea of <em>informational herd immunity</em>. You don’t need every engineer to have read the strategy; you just need “enough people who know something that confusion doesn’t propagate too far.” In practice, that means every Staff-plus engineer and manager understanding the strategy well enough to keep their team on track. The refinement toolkit, particularly systems modelling and Wardley mapping, is squarely in this group’s wheelhouse and gives them a rigorous way to challenge or support a proposed direction with evidence rather than opinion.</p>

<h3 id="engineering-managers">Engineering managers</h3>

<p>Managers are the connective tissue that keeps a written strategy alive, and the book’s chapter on operations reads almost like a manual for them. Larson is scathing about the mechanisms managers most often reach for by default, warning that “a couple of trainings will never change organizational behavior.” The more effective path is the unglamorous one: inspection mechanisms that “cannot silently fail”, nudges that bring context to people at the moment they need it, and a genuine curiosity about why some teams aren’t adopting a change rather than assuming they’re being difficult. His observation that people usually “want to do things the new way, but rarely take time to learn how to do it” is a healthier default assumption than most performance conversations start from.</p>

<h3 id="engineering-executives">Engineering executives</h3>

<p>Executives get the book’s most bracing warning. Yes, they can mandate adherence in a way engineers can’t, but “mandates only matter if there are consequences”, and an executive unwilling to enforce them is issuing empty instructions. More pointedly, Larson notes that access and mandates “often create the appearance of progress” without improving the underlying diagnosis, which is “why executive strategies can fail so spectacularly and endure so long despite failure.” His conclusion is one every senior leader should sit with: executives “have an easier time doing strategy, but a much harder time learning how to do strategy well.” That’s the argument for practising the craft long before you reach the top, because “waiting to do strategy until you are an executive is a recipe for disaster.”</p>

<h3 id="product-managers-and-other-partners">Product managers and other partners</h3>

<p>Even outside engineering, the book insists you can contribute, though it will demand an even more influence driven approach. The examples where engineers themselves are on uncertain ground, such as the early adoption of large language models, are exactly the moments when an outside perspective is most valuable. The through-line is the same for everyone: diagnose honestly, write it down, and let the artefact do the persuading over time.</p>

<h2 id="the-good-and-the-caveats">The good, and the caveats</h2>

<p>What I appreciated most is how grounded the book is in real, named examples. The case studies aren’t sanitised. Larson revisits Digg’s catastrophic V4 rewrite, which he calls “the worst considered strategy I’ve personally participated in” and which “ensured we died fast rather than having an opportunity to dig our way out.” He’s careful, though, to relabel such strategies “inappropriate” rather than “bad”, because “the same strategy might well be very effective in a different set of circumstances.” That intellectual honesty runs throughout, and it’s a welcome corrective to the confident, context-free advice that dominates so much engineering leadership writing.</p>

<p>The book is not a light read. The five step scaffolding, the refinement techniques and the taxonomy of policies reward careful study rather than a quick skim, and readers hoping for a checklist to copy will be gently disappointed. That’s by design; Larson repeatedly reminds you that “the structure is not sacrosanct, it’s the thinking behind the sections that really matter.” The chapters on systems modelling and Wardley mapping in particular are enough to get you started but will send the curious off to other sources. And if you’ve read his earlier work, you’ll recognise a fair amount of the philosophy, albeit sharpened and far better organised here.</p>

<p><em>Crafting Engineering Strategy</em> delivers on its subtitle by treating strategy as a learnable, teachable craft rather than an innate gift, and it does so with enough concrete examples that you can start applying it the same day. If you’ve ever complained that your organisation lacks a strategy, Larson’s response is both a challenge and an invitation: it’s already there, so go and write it down. As he reminds us, strategy is useful, and doing strategy can be simple, too.</p>

<p>You can find the book <a href="https://amzn.eu/d/06jMoTL1">on Amazon</a> or <a href="https://www.oreilly.com/library/view/crafting-engineering-strategy/9798341645516/">via O’Reilly</a>, and much of the material is available to read on Larson’s <a href="https://craftingengstrategy.com/">companion site</a>.</p>]]></content><author><name>Glen Thomas</name></author><category term="Leadership" /><category term="Leadership" /><category term="Software Engineering" /><category term="Strategy" /><category term="Book Review" /><summary type="html"><![CDATA[Will Larson's Crafting Engineering Strategy argues that strategy is simply the art of reproducibly making good decisions, and that anyone can do it. Here's what the book covers, and how engineers, managers and executives can put it to work.]]></summary></entry><entry><title type="html">When AAD Pod Identity Breaks Flux: A Deep Dive into a Hidden AKS Failure Mode</title><link href="https://blog.glen-thomas.com/platform%20engineering/2026/05/14/when-aad-pod-identity-breaks-flux.html" rel="alternate" type="text/html" title="When AAD Pod Identity Breaks Flux: A Deep Dive into a Hidden AKS Failure Mode" /><published>2026-05-14T09:18:00+01:00</published><updated>2026-05-14T09:18:00+01:00</updated><id>https://blog.glen-thomas.com/platform%20engineering/2026/05/14/when-aad-pod-identity-breaks-flux</id><content type="html" xml:base="https://blog.glen-thomas.com/platform%20engineering/2026/05/14/when-aad-pod-identity-breaks-flux.html"><![CDATA[<p>If you run AKS long enough, you eventually meet the ghosts of deprecated features past.<br />
This is the story of how <strong>AAD Pod Identity</strong>, long deprecated but still lurking in many clusters, silently broke <strong>Flux image automation</strong>, and how to diagnose and fix it when it happens to you.</p>

<p>This post documents the symptoms, the root cause, and the exact steps required to recover a cluster stuck in this half‑installed, half‑removed state.</p>

<hr />

<h2 id="the-setup">The Setup</h2>

<p>This cluster was:</p>

<ul>
  <li>Running <strong>AKS</strong> with the old <strong>AAD Pod Identity</strong> Helm chart still installed</li>
  <li>Missing <strong>MIC</strong> (Managed Identity Controller)</li>
  <li>Still running <strong>NMI</strong> (Node Managed Identity) on every node</li>
  <li>Running <strong>Flux v2</strong> with image automation enabled</li>
  <li>Using <strong>ACR</strong> with a <strong>node‑assigned managed identity</strong></li>
</ul>

<p>This combination is more common than you’d think—especially in clusters upgraded over several years.</p>

<hr />

<h2 id="the-symptoms">The Symptoms</h2>

<p>Flux’s <code class="language-plaintext highlighter-rouge">image-reflector-controller</code> began failing with IMDS authentication errors:</p>

<blockquote>
  <p>failed to configure authentication options: failed to create provider access token for the controller: ManagedIdentityCredential: context deadline exceeded</p>
</blockquote>

<p>NMI logs showed:</p>

<blockquote>
  <p>failed to get matching identities for pod: flux-system/image-reflector-controller…</p>
</blockquote>

<p>Flux could not:</p>

<ul>
  <li>Query ACR</li>
  <li>Populate its tag database</li>
  <li>Resolve image policies</li>
</ul>

<p>Image automation was effectively dead.</p>

<hr />

<h2 id="diagnosis-the-smoking-gun">Diagnosis: The Smoking Gun</h2>

<p>Running <code class="language-plaintext highlighter-rouge">kubectl get pods -n kube-system | grep nmi</code> revealed <strong>dozens of NMI pods</strong>, all running for hundreds of days.</p>

<p><code class="language-plaintext highlighter-rouge">kubectl get crd | grep aadpodidentity</code> revealed <strong>AAD Pod Identity CRDs still installed</strong>.</p>

<p>But running <code class="language-plaintext highlighter-rouge">kubectl get pods -n kube-system | grep mic</code> showed <strong>no MIC pods at all</strong>.</p>

<p>This is the broken state:</p>

<ul>
  <li><strong>NMI intercepts IMDS calls</strong></li>
  <li><strong>MIC is missing</strong>, so identities are never assigned</li>
  <li>Every IMDS call from a pod fails</li>
  <li>Flux cannot authenticate to ACR</li>
</ul>

<p>This is where the cluster was stuck.</p>

<h2 id="why-this-breaks-flux">Why This Breaks Flux</h2>

<p>Flux’s controllers authenticate to ACR using the <strong>node’s managed identity</strong> via IMDS.</p>

<p>But NMI intercepts IMDS calls and tries to look up a pod’s assigned identity.</p>

<p>With MIC missing, NMI always returns:</p>

<blockquote>
  <p>no AzureAssignedIdentity found</p>
</blockquote>

<p>Flux never reaches IMDS → never reaches ACR → never sees image tags.</p>

<hr />

<h2 id="the-fix-azurepodidentityexception">The Fix: AzurePodIdentityException</h2>

<p>The solution is to tell NMI:</p>

<blockquote>
  <p>“Do not intercept IMDS calls for Flux pods.”</p>
</blockquote>

<p>This is done using an <code class="language-plaintext highlighter-rouge">AzurePodIdentityException</code>.</p>

<p>But here’s the twist:<br />
<strong>AAD Pod Identity has multiple CRD schemas depending on the version</strong>, and this cluster was running a very old one.</p>

<p>Attempts using modern fields like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">podSelector</span><span class="pi">:</span>
</code></pre></div></div>

<p>or</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">PodLabels</span><span class="pi">:</span>
</code></pre></div></div>

<p>were rejected.</p>

<p>The correct schema, discovered by inspecting existing exceptions (<code class="language-plaintext highlighter-rouge">kubectl get azurepodidentityexception -n flux-system -o yaml</code>), was:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">podLabels</span><span class="pi">:</span>
    <span class="na">&lt;label&gt;</span><span class="pi">:</span> <span class="s">&lt;value&gt;</span>
</code></pre></div></div>

<p>I discovered a couple of existing exception for Flux that didn’t cover the label used by the image-reflector-controller pod:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>spec:
  podLabels:
    app.kubernetes.io/name: flux-extension
</code></pre></div></div>

<h3 id="creating-the-correct-exception">Creating the Correct Exception</h3>

<p>I inspected the labels of the pods in the flux-system namespace:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl get pods -n flux-system --show-labels
</code></pre></div></div>

<p>Flux controllers all share this label:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app.kubernetes.io/name=microsoft.flux
</code></pre></div></div>

<p>So the correct exception is:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">aadpodidentity.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">AzurePodIdentityException</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">flux-system-exception</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">podLabels</span><span class="pi">:</span>
    <span class="na">app.kubernetes.io/name</span><span class="pi">:</span> <span class="s">microsoft.flux</span>
</code></pre></div></div>

<p>Apply it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl apply -f flux-system-exception.yaml
</code></pre></div></div>

<p>Then restart Flux:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl rollout restart deploy -n flux-system
</code></pre></div></div>

<h2 id="verifying-the-fix">Verifying the Fix</h2>

<p>Check NMI logs:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl logs -n kube-system -l app.kubernetes.io/component=nmi --tail=200
</code></pre></div></div>

<p>Before the fix:</p>

<blockquote>
  <p>failed to get matching identities for pod flux-system/image-reflector-controller…</p>
</blockquote>

<p>After the fix no errors are shown.</p>

<p>Flux logs then showed healthy behaviour:</p>

<blockquote>
  <p>successful scan: found 1 tags</p>

  <p>Latest image tag … resolved to prod-8552309f0-20260512T090128</p>
</blockquote>

<p>Image automation was back.</p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>This issue is obscure, poorly documented, and easy to miss.
If you’re running Flux on AKS and see IMDS or ACR authentication failures, check for:</p>

<ul>
  <li>NMI pods still running</li>
  <li>MIC missing</li>
  <li>Old AAD Pod Identity CRDs</li>
  <li>Missing or incorrect <code class="language-plaintext highlighter-rouge">AzurePodIdentityException</code> objects</li>
</ul>

<p>Hopefully this post saves someone else the hours of digging it took to unravel this.</p>]]></content><author><name>Glen Thomas</name></author><category term="Platform Engineering" /><category term="Kubernetes" /><category term="AKS" /><summary type="html"><![CDATA[If you run AKS long enough, you eventually meet the ghosts of deprecated features past. This is the story of how AAD Pod Identity, long deprecated but still lurking in many clusters, silently broke Flux image automation, and how to diagnose and fix it when it happens to you.]]></summary></entry><entry><title type="html">Signing Container Images with Cosign (and Notation) + Storing Signatures, SBOMs, and Attestations in Azure Container Registry</title><link href="https://blog.glen-thomas.com/software%20engineering/security/2026/01/20/signing-container-images-with-cosign.html" rel="alternate" type="text/html" title="Signing Container Images with Cosign (and Notation) + Storing Signatures, SBOMs, and Attestations in Azure Container Registry" /><published>2026-01-20T09:10:00+00:00</published><updated>2026-01-20T09:10:00+00:00</updated><id>https://blog.glen-thomas.com/software%20engineering/security/2026/01/20/signing-container-images-with-cosign</id><content type="html" xml:base="https://blog.glen-thomas.com/software%20engineering/security/2026/01/20/signing-container-images-with-cosign.html"><![CDATA[<p>Signing container images is one of the highest-leverage things you can do to improve your software supply chain. A signature gives you a cryptographic way to answer “who produced this image?” and “has it been tampered with?”. Attestations (for example: provenance and SBOMs) help you answer deeper questions like “how was it built?” and “what’s inside it?”.</p>

<p>This post is a practical reference for:</p>

<ul>
  <li>Signing OCI container images with <code class="language-plaintext highlighter-rouge">cosign</code> (Sigstore)</li>
  <li>Generating and attaching attestations (including SBOM-related examples)</li>
  <li>Storing signatures/attestations alongside images in Azure Container Registry (ACR)</li>
  <li>Understanding how the OCI Referrers API organises “artifacts about artifacts”</li>
  <li>Briefly comparing Cosign to Notation (Notary Project)</li>
</ul>

<h2 id="key-concepts-what-are-we-actually-storing">Key concepts (what are we actually storing?)</h2>

<p>When people say “sign the image” they often mean a few related, but distinct things.</p>

<h3 id="signatures">Signatures</h3>

<p>A signature is a statement like:</p>

<blockquote>
  <p>“I (an identity) signed this immutable image digest.”</p>
</blockquote>

<p>The important part is that you sign the image <em>digest</em> (<code class="language-plaintext highlighter-rouge">@sha256:...</code>), not the <em>tag</em> (<code class="language-plaintext highlighter-rouge">:latest</code>), because tags are mutable.</p>

<h3 id="attestations">Attestations</h3>

<p>An attestation is a signed statement about the image (or about another artifact attached to the image). Examples:</p>

<ul>
  <li>Provenance: where the build ran, what sources were used, what inputs were consumed</li>
  <li>SBOM: a machine-readable list of packages/components contained in the image</li>
  <li>Vulnerability scan results (careful: these go stale quickly)</li>
</ul>

<p>Cosign signs attestations using DSSE envelopes and commonly uses the in-toto attestation format.</p>

<h3 id="sboms">SBOMs</h3>

<p>An SBOM is a document — it’s valuable when you:</p>

<ul>
  <li>Generate it consistently</li>
  <li>Store it alongside the exact digest you shipped</li>
  <li>Sign it (so people can trust its origin)</li>
  <li>Make it discoverable by automation</li>
</ul>

<h2 id="why-sign-images-and-generate-attestations">Why sign images and generate attestations?</h2>

<p>If you’re already scanning images, signatures and attestations provide the missing chain of custody.</p>

<ul>
  <li><strong>Integrity</strong>: detect tampering (a signature ties to an immutable digest)</li>
  <li><strong>Authenticity</strong>: verify “this came from our pipeline/team”, not just “it exists in our registry”</li>
  <li><strong>Policy enforcement</strong>: gate deployments on verified signatures/attestations (Kubernetes admission, CI/CD checks)</li>
  <li><strong>Auditability</strong>: prove what was deployed and when, and how it was built</li>
  <li><strong>Better incident response</strong>: quickly answer “which builds include vulnerable component X?” when you have SBOMs attached to digests</li>
</ul>

<h2 id="where-do-signaturesattestations-live-oci-artifacts--the-referrers-api">Where do signatures/attestations live? OCI Artifacts + the Referrers API</h2>

<p>Modern container signing doesn’t embed signatures “inside” an image. Instead, registries store <em>additional OCI artifacts</em> that refer to the image.</p>

<p>The OCI Distribution Spec defines a <strong>Referrers API</strong> (OCI v1.1) that lets a client ask:</p>

<blockquote>
  <p>“List all artifacts that refer to this subject digest.”</p>
</blockquote>

<p>That makes signatures, SBOMs, provenance, scan reports, etc. first-class, discoverable artifacts associated with an image.</p>

<p>Azure Container Registry supports storing and discovering these supply-chain artifacts. Tools like ORAS can show the attached artifact graph, and ACR supports the OCI Referrers API in most scenarios.</p>

<p>Note: some environments may fall back to the older “referrers tag schema” approach instead of the Referrers API. For example, ACR’s documentation notes that some features (notably customer-managed key encrypted registries) may require fallback behavior.</p>

<h2 id="cosign-vs-notation-quick-comparison">Cosign vs Notation (quick comparison)</h2>

<p>Both Cosign and Notation ultimately store “signatures as OCI artifacts” in registries, but they’re optimised for different workflows.</p>

<h3 id="cosign-sigstore">Cosign (Sigstore)</h3>

<ul>
  <li>Excellent developer experience for <strong>keyless signing</strong> using OIDC identities (Fulcio certificates) and transparency logging (Rekor)</li>
  <li>Strong support for <strong>attestations</strong> (provenance, SBOM predicates, etc.)</li>
  <li>Works well in modern CI/CD environments where identity is provided by an OIDC issuer (GitHub Actions, etc.)</li>
</ul>

<h3 id="notation-notary-project">Notation (Notary Project)</h3>

<ul>
  <li>Industry standardisation effort for OCI signing with an emphasis on <strong>X.509 / trust policies</strong></li>
  <li>Strong fit for <strong>enterprise key management</strong>, including KMS/HSM-backed keys (for example via an Azure Key Vault plugin)</li>
  <li>Focused primarily on signing/verifying artifacts; attestations are commonly handled via separate OCI artifacts and tooling</li>
</ul>

<p>If you’re building a modern “provenance + SBOM + policy” workflow, Cosign is often the fastest path. If you need a standardised signature format and strong enterprise policy/trust-store modeling (and/or a vendor-supported path), Notation is worth serious consideration.</p>

<h2 id="walkthrough-build-push-sign-and-attach-attestations-in-acr">Walkthrough: build, push, sign, and attach attestations in ACR</h2>

<p>This walkthrough uses:</p>

<ul>
  <li>Azure Container Registry (ACR)</li>
  <li><code class="language-plaintext highlighter-rouge">cosign</code> for signing and attestations</li>
  <li><code class="language-plaintext highlighter-rouge">oras</code> to <em>discover</em> and reason about the artifact graph (Referrers API)</li>
</ul>

<h3 id="prerequisites">Prerequisites</h3>

<ul>
  <li>Azure CLI (<code class="language-plaintext highlighter-rouge">az</code>) authenticated to your subscription</li>
  <li>ACR with permissions to push images and artifacts</li>
  <li>Tools:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">cosign</code></li>
      <li><code class="language-plaintext highlighter-rouge">oras</code></li>
      <li><code class="language-plaintext highlighter-rouge">jq</code> (optional, but helpful)</li>
      <li>SBOM generator such as <code class="language-plaintext highlighter-rouge">syft</code> (optional)</li>
    </ul>
  </li>
</ul>

<p>On macOS you can typically install these with Homebrew (adjust as needed):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>cosign oras jq syft
</code></pre></div></div>

<h3 id="1-create-an-acr-and-sign-in">1) Create an ACR and sign in</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>az login

<span class="nb">export </span><span class="nv">LOCATION</span><span class="o">=</span>uksouth
<span class="nb">export </span><span class="nv">RG</span><span class="o">=</span>rg-acr-signing-demo
<span class="nb">export </span><span class="nv">ACR_NAME</span><span class="o">=</span>&lt;your-unique-acr-name&gt;

az group create <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$RG</span><span class="s2">"</span> <span class="nt">-l</span> <span class="s2">"</span><span class="nv">$LOCATION</span><span class="s2">"</span>
az acr create <span class="nt">-g</span> <span class="s2">"</span><span class="nv">$RG</span><span class="s2">"</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$ACR_NAME</span><span class="s2">"</span> <span class="nt">--sku</span> Standard

<span class="nb">export </span><span class="nv">REGISTRY</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ACR_NAME</span><span class="s2">.azurecr.io"</span>
az acr login <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$ACR_NAME</span><span class="s2">"</span>
</code></pre></div></div>

<h3 id="2-build-and-push-an-image-and-capture-the-digest">2) Build and push an image (and capture the digest)</h3>

<p>You can build locally with Docker and push, but for a clean “works anywhere” demo, ACR Tasks is handy.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">REPO</span><span class="o">=</span>demo/net-monitor
<span class="nb">export </span><span class="nv">TAG</span><span class="o">=</span>v1

<span class="c"># Example source repo used in Microsoft docs; swap this for your own Dockerfile repo.</span>
<span class="nb">export </span><span class="nv">IMAGE_SOURCE</span><span class="o">=</span>https://github.com/wabbit-networks/net-monitor.git#main

<span class="nv">DIGEST</span><span class="o">=</span><span class="si">$(</span>az acr build <span class="se">\</span>
	<span class="nt">-r</span> <span class="s2">"</span><span class="nv">$ACR_NAME</span><span class="s2">"</span> <span class="se">\</span>
	<span class="nt">-t</span> <span class="s2">"</span><span class="nv">$REGISTRY</span><span class="s2">/</span><span class="k">${</span><span class="nv">REPO</span><span class="k">}</span><span class="s2">:</span><span class="nv">$TAG</span><span class="s2">"</span> <span class="se">\</span>
	<span class="s2">"</span><span class="nv">$IMAGE_SOURCE</span><span class="s2">"</span> <span class="se">\</span>
	<span class="nt">--no-logs</span> <span class="se">\</span>
	<span class="nt">--query</span> <span class="s2">"outputImages[0].digest"</span> <span class="nt">-o</span> tsv<span class="si">)</span>

<span class="nb">export </span><span class="nv">IMAGE_DIGEST</span><span class="o">=</span><span class="s2">"</span><span class="nv">$REGISTRY</span><span class="s2">/</span><span class="k">${</span><span class="nv">REPO</span><span class="k">}</span><span class="s2">@</span><span class="nv">$DIGEST</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>From here on, use <code class="language-plaintext highlighter-rouge">$IMAGE_DIGEST</code> (digest reference) for signing and attestations.</p>

<h3 id="3-sign-the-image-with-cosign-keyless">3) Sign the image with Cosign (keyless)</h3>

<p>Keyless signing is Cosign’s default “batteries included” path.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cosign sign <span class="nt">--yes</span> <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>What to expect:</p>

<ul>
  <li>A browser-based OIDC flow (unless you’re already in a CI environment with an OIDC token)</li>
  <li>A short-lived signing certificate issued by Sigstore’s Fulcio</li>
  <li>A transparency log entry in Rekor (by default)</li>
  <li>A signature stored in your registry as an OCI artifact referring to the image</li>
</ul>

<h3 id="4-verify-the-cosign-signature">4) Verify the Cosign signature</h3>

<p>Verification should be part of your CI and/or cluster admission policy. The important idea is: verify not only “is it signed?” but “is it signed by the identity we trust?”.</p>

<p>For interactive keyless signing, the OIDC issuer is commonly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">OIDC_ISSUER</span><span class="o">=</span><span class="s2">"https://oauth2.sigstore.dev/auth"</span>
</code></pre></div></div>

<p>And the identity is typically the OIDC subject (often your email). Example:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cosign verify <span class="se">\</span>
	<span class="nt">--certificate-identity</span> <span class="s2">"you@example.com"</span> <span class="se">\</span>
	<span class="nt">--certificate-oidc-issuer</span> <span class="s2">"</span><span class="nv">$OIDC_ISSUER</span><span class="s2">"</span> <span class="se">\</span>
	<span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>In CI (for example GitHub Actions), you’d instead verify against that workflow identity and issuer (for example <code class="language-plaintext highlighter-rouge">https://token.actions.githubusercontent.com</code>), often using the <code class="language-plaintext highlighter-rouge">--certificate-identity-regexp</code> flags to match your org/repo/workflow.</p>

<h3 id="5-generate-an-sbom-and-attach-it-as-an-attestation-cosign">5) Generate an SBOM and attach it as an attestation (Cosign)</h3>

<p>One straightforward pattern is:</p>

<p>1) Generate an SBOM file
2) Attest to it (sign a statement that includes it)</p>

<p>Generate an SBOM with Syft (SPDX JSON example):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>syft packages <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span> <span class="nt">-o</span> spdx-json<span class="o">=</span>sbom.spdx.json
</code></pre></div></div>

<p>Create an attestation for that SBOM:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cosign attest <span class="nt">--yes</span> <span class="se">\</span>
	<span class="nt">--predicate</span> sbom.spdx.json <span class="se">\</span>
	<span class="nt">--type</span> spdxjson <span class="se">\</span>
	<span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>Verify the attestation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cosign verify-attestation <span class="se">\</span>
	<span class="nt">--certificate-identity</span> <span class="s2">"you@example.com"</span> <span class="se">\</span>
	<span class="nt">--certificate-oidc-issuer</span> <span class="s2">"</span><span class="nv">$OIDC_ISSUER</span><span class="s2">"</span> <span class="se">\</span>
	<span class="nt">--type</span> spdxjson <span class="se">\</span>
	<span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span> <span class="se">\</span>
	| jq <span class="nt">-r</span> <span class="s1">'.[0].payload'</span> <span class="se">\</span>
	| <span class="nb">base64</span> <span class="nt">--decode</span>
</code></pre></div></div>

<h3 id="6-discover-whats-attached-oci-referrers-with-oras">6) Discover what’s attached (OCI Referrers) with ORAS</h3>

<p>This is the piece most people miss: once signatures/SBOMs/attestations are stored as OCI artifacts, you can query the graph.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oras discover <span class="nt">-o</span> tree <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>You should see the subject image at the top, with child artifact types beneath it (signatures, attestations, and any other artifacts you attach).</p>

<h2 id="alternative-pattern-store-the-sbom-as-an-oci-artifact-then-sign-that">Alternative pattern: store the SBOM as an OCI artifact, then sign <em>that</em></h2>

<p>Sometimes you want the SBOM itself stored as a first-class artifact in the registry (so it can be pulled directly), and then you sign/attest to <em>that</em> artifact. OCI referrers support deep graphs: image → SBOM → signature.</p>

<p>Attach an SBOM file to the image as a referenced OCI artifact:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oras attach <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span> <span class="se">\</span>
	<span class="nt">--artifact-type</span> sbom/example <span class="se">\</span>
	./sbom.spdx.json:application/json
</code></pre></div></div>

<p>Discover again:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oras discover <span class="nt">-o</span> tree <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>This pattern is powerful when you want:</p>

<ul>
  <li>direct pull of SBOMs (<code class="language-plaintext highlighter-rouge">oras pull</code>), separate from verification tooling</li>
  <li>to sign the SBOM artifact independently (for example with Notation)</li>
</ul>

<h2 id="promoting-images-with-their-signaturesattestations">Promoting images <em>with</em> their signatures/attestations</h2>

<p>If you promote images across registries/environments, you should promote the <em>artifact graph</em>, not only the image manifest.</p>

<p>With ORAS you can copy an image and its referrers:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">TARGET_REPO</span><span class="o">=</span><span class="s2">"</span><span class="nv">$REGISTRY</span><span class="s2">/staging/</span><span class="nv">$REPO</span><span class="s2">"</span>
oras copy <span class="nt">-r</span> <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$TARGET_REPO</span><span class="s2">:</span><span class="nv">$TAG</span><span class="s2">"</span>
</code></pre></div></div>

<p>Then:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oras discover <span class="nt">-o</span> tree <span class="s2">"</span><span class="nv">$TARGET_REPO</span><span class="s2">:</span><span class="nv">$TAG</span><span class="s2">"</span>
</code></pre></div></div>

<h2 id="notation-brief-practical-example-in-azure">Notation (brief practical example in Azure)</h2>

<p>If you’re evaluating Notation in Azure, Microsoft provides a walkthrough that signs images in ACR using keys held in Azure Key Vault. The high-level flow looks like:</p>

<p>1) Install Notation + the Azure Key Vault plugin
2) Build/push image and sign by digest
3) Add certs to a trust store and create a trust policy
4) Verify</p>

<p>Signing example (Key Vault plugin; simplified):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>notation sign <span class="se">\</span>
	<span class="nt">--signature-format</span> cose <span class="se">\</span>
	<span class="nt">--id</span> <span class="s2">"</span><span class="nv">$KEY_ID</span><span class="s2">"</span> <span class="se">\</span>
	<span class="nt">--plugin</span> azure-kv <span class="se">\</span>
	<span class="nt">--plugin-config</span> <span class="nv">self_signed</span><span class="o">=</span><span class="nb">true</span> <span class="se">\</span>
	<span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>And list signatures:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>notation <span class="nb">ls</span> <span class="s2">"</span><span class="nv">$IMAGE_DIGEST</span><span class="s2">"</span>
</code></pre></div></div>

<p>An important nuance called out in Microsoft’s guidance: Notation can store signatures using the referrers tag schema by default, and can also use the OCI Referrers API depending on configuration/registry capabilities.</p>

<h2 id="common-pitfalls">Common pitfalls</h2>

<ul>
  <li><strong>Always sign by digest</strong> (<code class="language-plaintext highlighter-rouge">@sha256:...</code>), not by tag.</li>
  <li><strong>Verification must include identity constraints</strong>, not only “is signed?”.</li>
  <li><strong>Plan where your trust roots live</strong>: developer keyless identity, CI workload identity, or enterprise PKI (Notation) are different trust models.</li>
  <li><strong>Think about artifact lifecycle</strong>: deleting an image doesn’t always automatically delete every related artifact in every registry implementation.</li>
  <li><strong>Referrers API vs tag schema</strong>: some environments may require fallback behavior; test your exact ACR configuration (including encryption features) if you’re standardising on Referrers API workflows.</li>
</ul>

<h2 id="suggested-good-default-pipeline-shape">Suggested “good default” pipeline shape</h2>

<p>If you’re starting from scratch, a practical baseline is:</p>

<p>1) Build image
2) Push image
3) Generate SBOM
4) Create provenance (if your build system supports it)
5) Sign image digest
6) Attest SBOM/provenance to image digest
7) Enforce verification at deploy time (admission policy)</p>

<p>If you want to go further, add:</p>

<ul>
  <li>environment-specific signatures (promotion gates)</li>
  <li><code class="language-plaintext highlighter-rouge">oras copy -r</code> promotion of artifact graphs</li>
  <li>key management hardening (HSM/KMS), timestamping, and policy-as-code</li>
</ul>]]></content><author><name>Glen Thomas</name></author><category term="Software Engineering" /><category term="Security" /><category term="Containers" /><category term="OCI" /><category term="Supply Chain" /><category term="Sigstore" /><category term="Cosign" /><category term="Notary" /><category term="Notation" /><category term="ACR" /><category term="SBOM" /><category term="Attestations" /><summary type="html"><![CDATA[Signing container images is one of the highest-leverage things you can do to improve your software supply chain. A signature gives you a cryptographic way to answer “who produced this image?” and “has it been tampered with?”. Attestations (for example: provenance and SBOMs) help you answer deeper questions like “how was it built?” and “what’s inside it?”.]]></summary></entry><entry><title type="html">Building a macOS System Monitor with Vibe Coding and GitHub Copilot</title><link href="https://blog.glen-thomas.com/software%20engineering/2025/12/06/building-a-macos-dystem-monitor-with-vibe-coding-and-github-copilot.html" rel="alternate" type="text/html" title="Building a macOS System Monitor with Vibe Coding and GitHub Copilot" /><published>2025-12-06T23:25:00+00:00</published><updated>2025-12-06T23:25:00+00:00</updated><id>https://blog.glen-thomas.com/software%20engineering/2025/12/06/building-a-macos-dystem-monitor-with-vibe-coding-and-github-copilot</id><content type="html" xml:base="https://blog.glen-thomas.com/software%20engineering/2025/12/06/building-a-macos-dystem-monitor-with-vibe-coding-and-github-copilot.html"><![CDATA[<p>Building software has fundamentally changed. Over the past two days, I built <a href="https://github.com/glenthomas/peep">Peep</a>, a native macOS system monitor application, using a development approach that would have seemed like science fiction just a few years ago. This post documents my experience combining “vibe coding” with GitHub Copilot Chat to create a production-ready Electron application with a Rust native backend.</p>

<h2 id="what-is-peep">What is Peep?</h2>

<p>Peep is a system monitoring application for macOS that displays real-time information about your computer’s performance. Think of it as a modern, visually appealing alternative to Activity Monitor. It features:</p>

<ul>
  <li><strong>Real-time CPU and memory monitoring</strong> with animated gauges</li>
  <li><strong>Network and disk I/O tracking</strong> with historical charts</li>
  <li><strong>Process management</strong> with tree view and thread filtering</li>
  <li><strong>Battery status</strong> with time remaining estimates</li>
  <li><strong>Machine information</strong> including model name and macOS version</li>
</ul>

<p>The tech stack combines Electron for the desktop shell, React and TypeScript for the UI, and Rust via Neon bindings for high-performance native system access.</p>

<p><a href="/assets/images/peep-dashboard.png" class="align-center"><img src="/assets/images/peep-dashboard.png" alt="Peep Dashboard" /></a>
<a href="/assets/images/peep-process-list.png" class="align-center"><img src="/assets/images/peep-process-list.png" alt="Process List" /></a></p>

<h2 id="vibe-coding-a-new-development-paradigm">Vibe Coding: A New Development Paradigm</h2>

<p>“Vibe coding” is an emerging term for a development style where you describe what you want in natural language and iterate rapidly with AI assistance. Rather than meticulously planning every implementation detail upfront, you:</p>

<ol>
  <li><strong>Describe the desired outcome</strong> - “Add battery information to the header”</li>
  <li><strong>Review the generated solution</strong> - AI provides working code</li>
  <li><strong>Refine through conversation</strong> - “The icon isn’t showing correctly for discharging state”</li>
  <li><strong>Iterate until satisfied</strong> - Continue the dialogue until it works perfectly</li>
</ol>

<p>This approach flips traditional development on its head. Instead of spending hours reading documentation and debugging, you spend time <em>describing</em> and <em>refining</em>. The AI handles the boilerplate, the API lookups, and often catches edge cases you hadn’t considered.</p>

<h2 id="the-development-journey">The Development Journey</h2>

<h3 id="starting-point-architecture-decisions">Starting Point: Architecture Decisions</h3>

<p>The project began with a clear vision: a system monitor that felt native to macOS while being built with web technologies. The architecture emerged through conversation:</p>

<p><strong>Me:</strong> “I want to build a system monitor for macOS using Electron”</p>

<p><strong>Copilot:</strong> Suggested the full stack; Electron for the shell, React for the UI, and importantly, recommended Rust with Neon bindings for the native module rather than pure Node.js. This was crucial for performance when polling system metrics every few seconds.</p>

<p>The initial scaffolding; webpack config, TypeScript setup, IPC handlers, Rust/Neon integration, was generated in minutes. Tasks that would typically take half a day of configuration wrestling were done before my coffee got cold.</p>

<p>Copilot opens a pull request with a thorough explanation of the work that has been done. At this point I can pull the branch to my machine and do some QA.</p>

<p>The initial version created by Copilot looked like so:</p>

<p><a href="/assets/images/peep-initial-version.png" class="align-center"><img src="/assets/images/peep-initial-version.png" alt="Process Initial Version" /></a></p>

<p>If I find any bugs or want to make some minor improvements I can add a comment to the pull request.</p>

<blockquote>
  <p>glenthomas commented</p>

  <p>@copilot the nodejs project is trying to import the Rust binary and is failing with error:</p>

  <p>Failed to load native module: Error: Cannot find module ‘../../native/index.node’</p>

  <p>Require stack:</p>

  <p>/Users/glen.thomas/git/peep/dist/main.js</p>

  <p>at Module._resolveFilename (node:internal/modules/cjs/loader:1390:15)</p>
</blockquote>

<p>Copilot add the eyes emoji (👀) to to my comment to acknowledge my request and gets straight to work in a new session. A new comment is added to the pull request to let me know when the next stage of the work has been completed.</p>

<blockquote>
  <p>Copilot AI commented</p>

  <p>Fixed in commit e1003c2. The issue was that the native module path was using a hardcoded relative require path that didn’t resolve correctly at runtime.</p>

  <p>Changes made:</p>

  <ol>
    <li>Updated src/main/main.ts to use path.join(__dirname, ‘..’, ‘native’, ‘index.node’) for proper path resolution</li>
    <li>Added @types/node and @types/electron as dev dependencies (required for TypeScript compilation)</li>
    <li>Updated build script to use npx cargo-cp-artifact to ensure the tool is available</li>
    <li>Enhanced README troubleshooting section</li>
  </ol>

  <p>The native module now loads correctly. From dist/main.js, it resolves to <project-root>/native/index.node. Verified with test that shows the module loads successfully and can retrieve system info.</project-root></p>
</blockquote>

<h3 id="feature-development-the-tree-view-example">Feature Development: The Tree View Example</h3>

<p>One of the more complex features was adding a process tree view that shows parent-child relationships between processes. Here’s how that conversation went:</p>

<p><strong>Me:</strong> “Could we have an option to view process parent-child relationships?”</p>

<p>Within moments, I had:</p>
<ul>
  <li>A <code class="language-plaintext highlighter-rouge">buildProcessTree()</code> function that constructs a hierarchy from the flat process list</li>
  <li>A <code class="language-plaintext highlighter-rouge">renderProcessTree()</code> function with proper indentation and expand/collapse</li>
  <li>Toggle buttons for switching between list and tree views</li>
  <li>State management for tracking expanded nodes</li>
</ul>

<p>The implementation used the <code class="language-plaintext highlighter-rouge">ppid</code> (parent process ID) field that was already being collected by the Rust module. Copilot recognised the data was available and used it appropriately—something that would have required me to trace through the codebase manually.</p>

<h3 id="debugging-the-battery-icon-bug">Debugging: The Battery Icon Bug</h3>

<p>Not everything worked perfectly on the first try, and that’s where the iterative nature of vibe coding shines. After adding battery status to the header, I noticed the icon wasn’t correct:</p>

<p><strong>Me:</strong> “The battery state icon is not showing the correct icon. When the battery state is ‘discharging’, it shows the charging icon.”</p>

<p>Copilot identified the issue immediately: the <code class="language-plaintext highlighter-rouge">getStateIcon()</code> function was using <code class="language-plaintext highlighter-rouge">includes()</code> to match states, so “discharging” was matching the “charging” case first. The fix was simple—use exact matching with <code class="language-plaintext highlighter-rouge">===</code> and <code class="language-plaintext highlighter-rouge">startsWith()</code> instead.</p>

<p>This kind of bug could easily waste 20 minutes of console.log debugging. Instead, describing the symptom led directly to the fix.</p>

<h3 id="the-ui-polish-phase">The UI Polish Phase</h3>

<p>As the core functionality stabilised, I shifted to UI refinements. This is where vibe coding particularly excels - making small adjustments is as easy as asking:</p>

<ul>
  <li>“The columns, view type and show threads controls are stacked vertically, can they be stacked horizontally?” → Segmented button groups</li>
  <li>“On the CPU monitor and Memory monitor there is a bit of a vertical gap” → SVG viewport adjustments</li>
  <li>“Can we decrease the size of the gauges a little? Maybe 20%” → Proportional scaling of all gauge dimensions</li>
  <li>“The battery info is not properly centered in the header” → CSS Grid with <code class="language-plaintext highlighter-rouge">1fr auto 1fr</code></li>
</ul>

<p>Each of these would typically involve researching CSS properties, testing various approaches, and iterating. With Copilot, I described the visual problem and received targeted solutions.</p>

<h3 id="native-integration-challenges">Native Integration Challenges</h3>

<p>Some features required deeper system integration. When I asked about displaying the machine model name, the response included:</p>

<ol>
  <li>Using <code class="language-plaintext highlighter-rouge">sysctl</code> to read <code class="language-plaintext highlighter-rouge">hw.model</code> (e.g., “MacBookPro18,1”)</li>
  <li>A mapping table translating hardware identifiers to friendly names</li>
  <li>Integration with the existing <code class="language-plaintext highlighter-rouge">get_os_info()</code> Rust function</li>
</ol>

<p>Similarly, adding the macOS marketing name (Sonoma, Sequoia, etc.) required mapping Darwin version numbers to release names—knowledge that would require research but was immediately available through Copilot.</p>

<h3 id="when-ai-knowledge-falls-short">When AI Knowledge Falls Short</h3>

<p>It wasn’t all smooth sailing. One notable friction point came when working with the Rust <code class="language-plaintext highlighter-rouge">sysinfo</code> crate. Copilot confidently suggested methods and function calls that simply didn’t exist in the current version of the library.</p>

<p>For example, when implementing disk I/O monitoring per process, the AI suggested using methods that had either been renamed, deprecated, or never existed in the version I was using (0.37.x). The code would compile with errors like “method not found” or “no field named X on type Y”.</p>

<p>This is a fundamental limitation of AI coding assistants: their training data has a cutoff date, and libraries evolve. The <code class="language-plaintext highlighter-rouge">sysinfo</code> crate in particular has undergone significant API changes between versions, with methods being renamed and structs being reorganised.</p>

<p>The solution? I had to do things the old-fashioned way—pull up the <a href="https://docs.rs/sysinfo/latest/sysinfo/">sysinfo documentation on docs.rs</a>, find the correct method names for my version, and either correct the AI’s suggestions or implement the fix myself. In some cases, I’d share the compiler error and the relevant documentation snippet, and Copilot would adjust. In others, it was faster to just fix it manually.</p>

<p>This experience reinforced an important lesson: <strong>AI assistants are collaborators, not oracles</strong>. They’re incredibly useful for getting 80% of the way there quickly, but you still need domain knowledge and the ability to debug when their information is stale or incorrect.</p>

<h2 id="self-review-getting-copilot-to-improve-its-own-code">Self-Review: Getting Copilot to Improve Its Own Code</h2>

<p>One powerful technique that addresses concerns about AI-generated code quality is using Copilot to review and improve the very code it has produced. Critics might argue that AI assistants lack sufficient context while working incrementally, potentially leading to suboptimal solutions. However, this misses a crucial capability: <strong>asking the AI to review the solution as a whole</strong>.</p>

<p>After Copilot had implemented the core functionality, I took a step back and asked:</p>

<p><strong>Me:</strong> “Review the entire codebase and identify any issues, inefficiencies, or areas that could be improved.”</p>

<p>This prompted a comprehensive analysis that surfaced real problems I hadn’t noticed during rapid feature development:</p>

<p><strong>🔴 High Priority Issues</strong></p>

<ol>
  <li><strong>Duplicate <code class="language-plaintext highlighter-rouge">formatBytes</code> function</strong> - Defined in 4 separate files (<code class="language-plaintext highlighter-rouge">MemoryMonitor.tsx</code>, <code class="language-plaintext highlighter-rouge">DiskMonitor.tsx</code>, <code class="language-plaintext highlighter-rouge">NetworkMonitor.tsx</code>, <code class="language-plaintext highlighter-rouge">ProcessList.tsx</code>), each with slightly different implementations</li>
  <li><strong>Type safety issues in App.tsx</strong> - Using <code class="language-plaintext highlighter-rouge">any</code> types for state variables like <code class="language-plaintext highlighter-rouge">systemInfo</code>, <code class="language-plaintext highlighter-rouge">processes</code>, and <code class="language-plaintext highlighter-rouge">batteryInfo</code> instead of proper interfaces</li>
  <li><strong>Preload types don’t match actual API</strong> - The <code class="language-plaintext highlighter-rouge">ProcessInfo</code> interface was missing many fields the Rust module actually returns (<code class="language-plaintext highlighter-rouge">ppid</code>, <code class="language-plaintext highlighter-rouge">runTime</code>, <code class="language-plaintext highlighter-rouge">cpuTime</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">command</code>, <code class="language-plaintext highlighter-rouge">diskRead</code>, <code class="language-plaintext highlighter-rouge">diskWrite</code>, <code class="language-plaintext highlighter-rouge">isThread</code>)</li>
</ol>

<p><strong>🟡 Medium Priority Issues</strong></p>

<ol>
  <li><strong>Native module type is <code class="language-plaintext highlighter-rouge">any</code></strong> - No TypeScript declarations for the Rust bindings</li>
  <li><strong>Duplicate <code class="language-plaintext highlighter-rouge">formatStorage</code> function</strong> - Defined in both <code class="language-plaintext highlighter-rouge">DiskMonitor.tsx</code> and <code class="language-plaintext highlighter-rouge">NetworkMonitor.tsx</code></li>
  <li><strong>ChartJS registered multiple times</strong> - Each monitor component was independently registering ChartJS components, should be done once at app level</li>
  <li><strong>Large inline styles in App.tsx header</strong> - The battery status section (188-275 lines) had extensive inline styles that could be extracted to CSS</li>
  <li><strong>ProcessList.tsx is 664 lines</strong> - Very large component that could be split into <code class="language-plaintext highlighter-rouge">ProcessTable.tsx</code>, <code class="language-plaintext highlighter-rouge">ProcessControls.tsx</code>, and <code class="language-plaintext highlighter-rouge">ConfirmDialog.tsx</code></li>
</ol>

<p><strong>🟢 Low Priority</strong></p>

<ol>
  <li><strong>CSS variables defined but not fully used</strong> - Variables like <code class="language-plaintext highlighter-rouge">--spacing-xs</code> and <code class="language-plaintext highlighter-rouge">--font-sm</code> were defined but many inline styles used hardcoded values</li>
  <li><strong>Missing React.memo on monitor components</strong> - Could prevent unnecessary re-renders on gauge/chart components</li>
  <li><strong>Console.error for non-critical failures</strong> - Should use proper logging utility or remove in production</li>
</ol>

<p>For each issue, Copilot provided context and suggested fixes. The conversation flowed naturally:</p>

<p><strong>Me:</strong> “Fix the duplicate formatBytes functions”</p>

<p><strong>Copilot:</strong> [Creates <code class="language-plaintext highlighter-rouge">src/utils/formatters.ts</code> with a single, comprehensive implementation and updates all 4 files to import it]</p>

<p><strong>Me:</strong> “Add proper TypeScript types for the system info state”</p>

<p><strong>Copilot:</strong> [Creates interface definitions matching the Rust module’s output and updates all <code class="language-plaintext highlighter-rouge">any</code> types to use them]</p>

<p>This self-review pattern is remarkably effective because:</p>

<ol>
  <li><strong>Full context awareness</strong> - During review, the AI can see all the pieces together, not just the immediate task</li>
  <li><strong>Pattern recognition</strong> - It can spot anti-patterns across the codebase that emerge from incremental development</li>
  <li><strong>Best practices application</strong> - It can suggest idiomatic improvements that might have been missed during rapid feature development</li>
  <li><strong>Consistency checking</strong> - It identifies places where similar functionality is handled differently</li>
</ol>

<p>The key is being explicit about the review scope. Rather than asking a vague “make it better,” specific prompts work best:</p>

<ul>
  <li>“Review the React components for performance issues”</li>
  <li>“Check the Rust module for error handling problems”</li>
  <li>“Analyze the IPC communication for potential race conditions”</li>
  <li>“Review accessibility and ensure proper ARIA labels”</li>
</ul>

<p>This approach transforms a potential weakness into a strength. Yes, AI might produce less-than-perfect code when working with limited context on individual features. But it excels at identifying those imperfections when given the full picture and asked to review holistically.</p>

<h2 id="setting-up-the-release-pipeline">Setting Up the Release Pipeline</h2>

<p>One area where AI assistance proved invaluable was configuring the build and release pipeline. CI/CD configuration is notoriously fiddly, with countless options and edge cases.</p>

<p><strong>Me:</strong> “I would like to allow people finding this GitHub repository to be able to download and install the application. What options do I have?”</p>

<p>Copilot outlined several distribution strategies (GitHub Releases, Homebrew, Mac App Store) and recommended GitHub Releases with electron-builder for an open-source project. It then generated:</p>

<ul>
  <li>A <code class="language-plaintext highlighter-rouge">build.yml</code> workflow for CI on every push</li>
  <li>A <code class="language-plaintext highlighter-rouge">release.yml</code> workflow triggered by version tags</li>
  <li>Electron-builder configuration for both Intel and Apple Silicon</li>
  <li>README updates with download instructions</li>
</ul>

<p>When the workflows hit issues; wrong action names, permission errors, DMG creation failures on CI runners, each problem was solved through conversation:</p>

<ul>
  <li>“The action <code class="language-plaintext highlighter-rouge">dtolnay/rust-action@stable</code> does not exist” → Switched to <code class="language-plaintext highlighter-rouge">actions-rust-lang/setup-rust-toolchain@v1</code></li>
  <li>“Package ‘electron’ is only allowed in devDependencies” → Moved the dependency</li>
  <li>“hdiutil detach failing on CI” → Added retry logic for flaky DMG creation</li>
  <li>“403 Forbidden when creating release” → Added <code class="language-plaintext highlighter-rouge">permissions: contents: write</code></li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<h3 id="when-vibe-coding-excels">When Vibe Coding Excels</h3>

<ol>
  <li><strong>Boilerplate and configuration</strong> - Webpack configs, GitHub Actions, package.json scripts</li>
  <li><strong>API integration</strong> - Knowing which Electron or React APIs to use</li>
  <li><strong>Cross-referencing</strong> - Using data from one part of the app in another</li>
  <li><strong>Bug diagnosis</strong> - Describing symptoms and getting targeted fixes</li>
  <li><strong>Refactoring</strong> - “Can you change this from flex to grid layout?”</li>
</ol>

<h3 id="when-to-slow-down">When to Slow Down</h3>

<ol>
  <li><strong>Architecture decisions</strong> - Still worth thinking through yourself</li>
  <li><strong>Security-sensitive code</strong> - Review carefully, don’t blindly trust</li>
  <li><strong>Complex algorithms</strong> - Understand what’s generated, don’t just accept</li>
  <li><strong>Performance-critical paths</strong> - Verify the approach makes sense</li>
</ol>

<h3 id="the-collaboration-dynamic">The Collaboration Dynamic</h3>

<p>The most effective pattern I found was treating Copilot as a highly knowledgeable pair programmer who happens to type very fast. I would:</p>

<ol>
  <li><strong>Set context</strong> - Explain what exists and what I’m trying to achieve</li>
  <li><strong>Ask for options</strong> - “What are the ways to do X?”</li>
  <li><strong>Make decisions</strong> - Choose the approach that fits best</li>
  <li><strong>Request implementation</strong> - “Let’s go with option 2”</li>
  <li><strong>Iterate on details</strong> - “Can we also handle the edge case where…”</li>
</ol>

<p>This maintains human agency over the architecture and design while leveraging AI for implementation speed.</p>

<h2 id="the-results">The Results</h2>

<p>Peep went from concept to released application in a remarkably short time. The final product includes:</p>

<ul>
  <li><strong>5 monitoring components</strong> (CPU, Memory, Disk, Network, Processes)</li>
  <li><strong>Native Rust module</strong> with 15+ exported functions</li>
  <li><strong>Automated CI/CD</strong> with multi-architecture builds</li>
  <li><strong>GitHub Releases</strong> with DMG and ZIP downloads</li>
  <li><strong>Comprehensive documentation</strong> (README, CONTRIBUTING, CHANGELOG)</li>
</ul>

<p>More importantly, the codebase is clean and maintainable. The AI-generated code follows consistent patterns, includes appropriate comments, and handles edge cases I might have missed.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Building Peep reinforced my belief that we’re at an inflection point in software development. The combination of vibe coding (describing intent in natural language) with tools like GitHub Copilot Chat doesn’t replace programming skill; it amplifies it.</p>

<p>You still need to understand what you’re building, make architectural decisions, and verify the results. But the mechanical translation of ideas into code? That’s increasingly handled by AI, freeing developers to focus on the creative and strategic aspects of software creation.</p>

<p>If you haven’t tried this approach, I encourage you to experiment. Start with a side project, describe what you want, and see where the conversation takes you. You might be surprised how quickly your ideas become reality.</p>

<hr />

<p><em>Peep is open source and available on <a href="https://github.com/glenthomas/peep">GitHub</a>. Contributions welcome!</em></p>]]></content><author><name>Glen Thomas</name></author><category term="Software Engineering" /><category term="Electron" /><category term="Rust" /><category term="Typescript" /><category term="React" /><category term="AI" /><category term="GitHub Copilot" /><category term="Vibe Coding" /><summary type="html"><![CDATA[Building software has fundamentally changed. Over the past two days, I built Peep, a native macOS system monitor application, using a development approach that would have seemed like science fiction just a few years ago. This post documents my experience combining “vibe coding” with GitHub Copilot Chat to create a production-ready Electron application with a Rust native backend.]]></summary></entry><entry><title type="html">Mastering Docker Bake: Building Multi-Platform Images at Scale</title><link href="https://blog.glen-thomas.com/platform%20engineering/software%20engineering/2025/12/05/mastering-docker-bake-building-multi-platform-images-at-scale.html" rel="alternate" type="text/html" title="Mastering Docker Bake: Building Multi-Platform Images at Scale" /><published>2025-12-05T20:59:00+00:00</published><updated>2025-12-05T20:59:00+00:00</updated><id>https://blog.glen-thomas.com/platform%20engineering/software%20engineering/2025/12/05/mastering-docker-bake-building-multi-platform-images-at-scale</id><content type="html" xml:base="https://blog.glen-thomas.com/platform%20engineering/software%20engineering/2025/12/05/mastering-docker-bake-building-multi-platform-images-at-scale.html"><![CDATA[<p>Building Docker images has evolved far beyond simple <code class="language-plaintext highlighter-rouge">docker build</code> commands. Modern applications demand multi-platform support to run on both x86 and ARM architectures, multiple base images to support different runtime environments, and sophisticated tagging strategies to manage versions across development, staging, and production. Managing this complexity with shell scripts quickly becomes unwieldy, error-prone, and difficult to maintain.</p>

<p>Docker Bake solves these challenges with a declarative approach to building container images. It’s a high-level build orchestration tool that lets you define complex build configurations in a single file, then execute them consistently across local development and CI/CD pipelines. Instead of maintaining dozens of <code class="language-plaintext highlighter-rouge">docker build</code> commands with slightly different flags, you define your entire build matrix once and let Bake handle the orchestration.</p>

<p>In this comprehensive guide, I’ll show you how to use Docker Bake to build production-ready container images. We’ll start with the fundamentals, progress through real-world examples of multi-platform and multi-tag builds, and finish with GitHub Actions workflows that demonstrate how to integrate Bake into your CI/CD pipeline. By the end, you’ll have the knowledge to replace your complex build scripts with maintainable, declarative configuration.</p>

<h2 id="understanding-docker-bake">Understanding Docker Bake</h2>

<p>Before diving into examples, it’s worth understanding what Docker Bake actually does and why it exists. Docker Bake is part of Docker Buildx, the extended build capabilities that replaced the classic Docker builder. Whilst <code class="language-plaintext highlighter-rouge">docker buildx build</code> handles building a single image, <code class="language-plaintext highlighter-rouge">docker bake</code> orchestrates building multiple related images in one operation.</p>

<h3 id="why-docker-bake-exists">Why Docker Bake Exists</h3>

<p>Traditional Docker builds work well for simple use cases; a single Dockerfile producing a single image. But real world applications often need more complexity. You might need to build the same application for both AMD64 and ARM64 platforms. You might maintain separate images with different base operating systems. You might need to tag the same build with multiple version identifiers.</p>

<p>Without Bake, you’d write shell scripts that loop through platforms, base images, and tags, invoking <code class="language-plaintext highlighter-rouge">docker build</code> repeatedly with different arguments. These scripts become difficult to maintain, hard to test, and prone to subtle bugs where one combination of flags differs slightly from another.</p>

<p>Bake replaces these scripts with declarative configuration. You define what you want to build, not how to build it. The configuration format supports variables, inheritance, and composition, making it easier to maintain consistency across dozens of build targets.</p>

<h3 id="bake-configuration-format">Bake Configuration Format</h3>

<p>Docker Bake supports three configuration formats: HCL (HashiCorp Configuration Language), JSON, and Docker Compose files. HCL is the most expressive and the format I recommend. It supports variables, functions, and a clean syntax that reads naturally.</p>

<p>A Bake file consists of targets, groups, and variables. Targets define individual build operations; which Dockerfile to use, what tags to apply, which platforms to build for. Groups collect multiple targets so you can build them together. Variables parameterise your configuration, allowing the same file to work across different environments.</p>

<h3 id="how-bake-differs-from-regular-builds">How Bake Differs from Regular Builds</h3>

<p>When you run <code class="language-plaintext highlighter-rouge">docker buildx build</code>, you’re building a single image. When you run <code class="language-plaintext highlighter-rouge">docker buildx bake</code>, you can build dozens of images in parallel, each with different configurations, all defined in your Bake file. Bake also provides better defaults for multi-platform builds, automatically configuring builders and handling the complexity of cross-compilation.</p>

<p>Another key difference is composability. Bake files can reference other Bake files, allowing you to split configuration across multiple files and compose them at build time. This makes it possible to maintain shared configuration whilst allowing project-specific overrides.</p>

<h2 id="basic-docker-bake-configuration">Basic Docker Bake Configuration</h2>

<p>Let’s start with a simple example and build up complexity progressively. Create a file called <code class="language-plaintext highlighter-rouge">docker-bake.hcl</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"TAG"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${TAG}"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This configuration defines a single target called <code class="language-plaintext highlighter-rouge">app</code> that builds your Dockerfile and tags it with a registry prefix and version tag. The <code class="language-plaintext highlighter-rouge">default</code> group contains this target, so running <code class="language-plaintext highlighter-rouge">docker buildx bake</code> without arguments builds it.</p>

<p>Variables make this configuration flexible. Override them at runtime without modifying the file:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker buildx bake <span class="nt">--set</span> <span class="s2">"*.TAG=1.0.0"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">*</code> pattern applies the variable to all targets. You can also set variables through environment variables or by creating a <code class="language-plaintext highlighter-rouge">docker-bake.override.hcl</code> file that gets automatically merged with the main configuration.</p>

<h3 id="adding-context-and-arguments">Adding Context and Arguments</h3>

<p>Real applications need build arguments for things like version numbers, build timestamps, or configuration values. Add them to your target:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${TAG}"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">VERSION</span>    <span class="o">=</span> <span class="s2">"${TAG}"</span>
    <span class="nx">BUILD_DATE</span> <span class="o">=</span> <span class="nx">timestamp</span><span class="p">()</span>
    <span class="nx">VCS_REF</span>    <span class="o">=</span> <span class="s2">"git-sha"</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span>    <span class="p">=</span> <span class="s2">"${TAG}"</span>
    <span class="s2">"org.opencontainers.image.created"</span>    <span class="p">=</span> <span class="nx">timestamp</span><span class="p">()</span>
    <span class="s2">"org.opencontainers.image.source"</span>     <span class="p">=</span> <span class="s2">"https://github.com/myorg/myapp"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">args</code> block passes build arguments to your Dockerfile, accessible via <code class="language-plaintext highlighter-rouge">ARG</code> instructions. The <code class="language-plaintext highlighter-rouge">labels</code> block adds OCI-compliant metadata to your image. The <code class="language-plaintext highlighter-rouge">timestamp()</code> function generates the current timestamp, ensuring each build has accurate creation metadata.</p>

<h2 id="multi-platform-builds">Multi-Platform Builds</h2>

<p>Multi-platform builds are where Docker Bake really shines. Building for multiple architectures with traditional Docker builds requires multiple commands, managing different builders, and complex scripting. Bake handles all of this automatically.</p>

<h3 id="configuring-platform-support">Configuring Platform Support</h3>

<p>Update your target to build for both AMD64 and ARM64:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"TAG"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PLATFORMS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${TAG}"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="nx">PLATFORMS</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BUILDPLATFORM</span> <span class="o">=</span> <span class="s2">""</span>
    <span class="nx">TARGETPLATFORM</span> <span class="o">=</span> <span class="s2">""</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When you run <code class="language-plaintext highlighter-rouge">docker buildx bake</code>, Bake builds your image for both platforms in parallel, creating a multi-architecture manifest that automatically serves the correct image based on the host architecture.</p>

<p>The <code class="language-plaintext highlighter-rouge">BUILDPLATFORM</code> and <code class="language-plaintext highlighter-rouge">TARGETPLATFORM</code> arguments are automatically available in your Dockerfile when building multi-platform images. They let you customise build behaviour based on the target architecture:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">--platform=$BUILDPLATFORM golang:1.21</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>

<span class="k">ARG</span><span class="s"> TARGETPLATFORM</span>
<span class="k">ARG</span><span class="s"> BUILDPLATFORM</span>

<span class="k">RUN </span><span class="nb">echo</span> <span class="s2">"Building on </span><span class="nv">$BUILDPLATFORM</span><span class="s2"> for </span><span class="nv">$TARGETPLATFORM</span><span class="s2">"</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="c"># Use TARGETPLATFORM to set GOARCH appropriately</span>
<span class="k">RUN case</span> <span class="s2">"</span><span class="nv">$TARGETPLATFORM</span><span class="s2">"</span> <span class="k">in</span> <span class="se">\
</span>      <span class="s2">"linux/amd64"</span><span class="p">)</span> <span class="nv">GOARCH</span><span class="o">=</span>amd64 <span class="p">;;</span> <span class="se">\
</span>      <span class="s2">"linux/arm64"</span><span class="p">)</span> <span class="nv">GOARCH</span><span class="o">=</span>arm64 <span class="p">;;</span> <span class="se">\
</span>      <span class="s2">"linux/arm/v7"</span><span class="p">)</span> <span class="nv">GOARCH</span><span class="o">=</span>arm <span class="nv">GOARM</span><span class="o">=</span>7 <span class="p">;;</span> <span class="se">\
</span>    <span class="k">esac</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nv">GOOS</span><span class="o">=</span>linux go build <span class="nt">-o</span> myapp

<span class="k">FROM</span><span class="s"> alpine:3.19</span>
<span class="k">COPY</span><span class="s"> --from=builder /app/myapp /usr/local/bin/</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/usr/local/bin/myapp"]</span>
</code></pre></div></div>

<h3 id="platform-specific-optimisations">Platform-Specific Optimisations</h3>

<p>Sometimes you need platform-specific behaviour. For ARM builds, you might want to use a different base image or enable specific compiler optimisations. Create separate targets for each platform:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"TAG"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-amd64"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-common"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${TAG}-amd64"</span><span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"alpine:3.19"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-arm64"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-common"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/arm64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${TAG}-arm64"</span><span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"arm64v8/alpine:3.19"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-common"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">VERSION</span> <span class="o">=</span> <span class="s2">"${TAG}"</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span> <span class="p">=</span> <span class="s2">"${TAG}"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-amd64"</span><span class="p">,</span> <span class="s2">"app-arm64"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This configuration uses inheritance through the <code class="language-plaintext highlighter-rouge">inherits</code> field. The <code class="language-plaintext highlighter-rouge">app-common</code> target defines shared configuration, whilst platform-specific targets override only what differs. Building the default group builds both platforms with their specific optimisations.</p>

<h2 id="multi-tag-strategies">Multi-Tag Strategies</h2>

<p>Production container workflows often need multiple tags pointing to the same image. You might tag a release as <code class="language-plaintext highlighter-rouge">1.2.3</code>, <code class="language-plaintext highlighter-rouge">1.2</code>, <code class="language-plaintext highlighter-rouge">1</code>, and <code class="language-plaintext highlighter-rouge">latest</code> simultaneously. You might also tag builds with git commit SHAs for traceability. Bake makes this straightforward.</p>

<h3 id="semantic-versioning-tags">Semantic Versioning Tags</h3>

<p>Here’s a configuration that tags images with multiple semantic version levels:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"1.2.3"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"COMMIT_SHA"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"unknown"</span>
<span class="p">}</span>

<span class="nx">function</span> <span class="s2">"semver_tags"</span> <span class="p">{</span>
  <span class="nx">params</span> <span class="o">=</span> <span class="p">[</span><span class="nx">version</span><span class="p">]</span>
  <span class="nx">result</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${version}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${regex("</span><span class="err">^</span><span class="p">[</span><span class="mi">0</span><span class="o">-</span><span class="mi">9</span><span class="p">]</span><span class="o">+</span><span class="p">\\.[</span><span class="mi">0</span><span class="o">-</span><span class="mi">9</span><span class="p">]</span><span class="o">+</span><span class="s2">", version)}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${regex("</span><span class="err">^</span><span class="p">[</span><span class="mi">0</span><span class="o">-</span><span class="mi">9</span><span class="p">]</span><span class="o">+</span><span class="s2">", version)}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:latest"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="nx">semver_tags</span><span class="p">(</span><span class="nx">VERSION</span><span class="p">)</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">VERSION</span>    <span class="o">=</span> <span class="nx">VERSION</span>
    <span class="nx">COMMIT_SHA</span> <span class="o">=</span> <span class="nx">COMMIT_SHA</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span> <span class="p">=</span> <span class="nx">VERSION</span>
    <span class="s2">"org.opencontainers.image.revision"</span> <span class="o">=</span> <span class="nx">COMMIT_SHA</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">semver_tags</code> function takes a version like <code class="language-plaintext highlighter-rouge">1.2.3</code> and generates four tags: <code class="language-plaintext highlighter-rouge">1.2.3</code>, <code class="language-plaintext highlighter-rouge">1.2</code>, <code class="language-plaintext highlighter-rouge">1</code>, and <code class="language-plaintext highlighter-rouge">latest</code>. This ensures users can pin to specific versions or track major/minor releases.</p>

<h3 id="environment-specific-tags">Environment-Specific Tags</h3>

<p>Different environments often need different tagging strategies. Development builds might use branch names and commit SHAs, whilst production uses semantic versions:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"ENVIRONMENT"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"dev"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"dev"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"BRANCH"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"main"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"COMMIT_SHA"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"unknown"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">function</span> <span class="s2">"dev_tags"</span> <span class="p">{</span>
  <span class="nx">params</span> <span class="o">=</span> <span class="p">[</span><span class="nx">branch</span><span class="p">,</span> <span class="nx">sha</span><span class="p">]</span>
  <span class="nx">result</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${branch}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${branch}-${sha}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:dev"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="nx">function</span> <span class="s2">"prod_tags"</span> <span class="p">{</span>
  <span class="nx">params</span> <span class="o">=</span> <span class="p">[</span><span class="nx">version</span><span class="p">]</span>
  <span class="nx">result</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${version}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${regex("</span><span class="err">^</span><span class="p">[</span><span class="mi">0</span><span class="o">-</span><span class="mi">9</span><span class="p">]</span><span class="o">+</span><span class="p">\\.[</span><span class="mi">0</span><span class="o">-</span><span class="mi">9</span><span class="p">]</span><span class="o">+</span><span class="s2">", version)}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${regex("</span><span class="err">^</span><span class="p">[</span><span class="mi">0</span><span class="o">-</span><span class="mi">9</span><span class="p">]</span><span class="o">+</span><span class="s2">", version)}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:latest"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-dev"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="nx">dev_tags</span><span class="p">(</span><span class="nx">BRANCH</span><span class="p">,</span> <span class="nx">COMMIT_SHA</span><span class="p">)</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-prod"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="nx">prod_tags</span><span class="p">(</span><span class="nx">VERSION</span><span class="p">)</span>
  
  <span class="nx">output</span>     <span class="o">=</span> <span class="p">[</span><span class="s2">"type=registry,push=true"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"dev"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-dev"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"prod"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-prod"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Build development images with <code class="language-plaintext highlighter-rouge">docker buildx bake dev</code> and production images with <code class="language-plaintext highlighter-rouge">docker buildx bake prod</code>. Each group uses appropriate tags and platforms for its environment.</p>

<h2 id="multiple-base-images">Multiple Base Images</h2>

<p>Some applications need variants built from different base images. You might offer an Alpine-based image for minimal size and a Debian-based image for better compatibility. Or you might support multiple runtime versions—Node.js 18 and Node.js 20, for example.</p>

<h3 id="base-image-variants">Base Image Variants</h3>

<p>Here’s a configuration that builds the same application with multiple base images:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PLATFORMS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-alpine"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile.alpine"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="nx">PLATFORMS</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-alpine"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:alpine"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"alpine:3.19"</span>
    <span class="nx">VERSION</span>    <span class="o">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span> <span class="p">=</span> <span class="nx">VERSION</span>
    <span class="s2">"org.opencontainers.image.base.name"</span> <span class="o">=</span> <span class="s2">"alpine:3.19"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-debian"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile.debian"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="nx">PLATFORMS</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-debian"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:latest"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"debian:bookworm-slim"</span>
    <span class="nx">VERSION</span>    <span class="o">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span> <span class="p">=</span> <span class="nx">VERSION</span>
    <span class="s2">"org.opencontainers.image.base.name"</span> <span class="o">=</span> <span class="s2">"debian:bookworm-slim"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-ubuntu"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile.ubuntu"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="nx">PLATFORMS</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-ubuntu"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:ubuntu"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"ubuntu:22.04"</span>
    <span class="nx">VERSION</span>    <span class="o">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span> <span class="p">=</span> <span class="nx">VERSION</span>
    <span class="s2">"org.opencontainers.image.base.name"</span> <span class="o">=</span> <span class="s2">"ubuntu:22.04"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-alpine"</span><span class="p">,</span> <span class="s2">"app-debian"</span><span class="p">,</span> <span class="s2">"app-ubuntu"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"alpine"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-alpine"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"debian"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-debian"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Running <code class="language-plaintext highlighter-rouge">docker buildx bake</code> builds all three variants. Running <code class="language-plaintext highlighter-rouge">docker buildx bake alpine</code> builds only the Alpine variant. Each variant has appropriate tags indicating its base image.</p>

<h3 id="single-dockerfile-with-multiple-bases">Single Dockerfile with Multiple Bases</h3>

<p>If your Dockerfiles differ only in the base image, you can use a single Dockerfile with build arguments:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-alpine"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${VERSION}-alpine"</span><span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"alpine:3.19"</span>
    <span class="nx">IMAGE_VARIANT</span> <span class="o">=</span> <span class="s2">"alpine"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-debian"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${VERSION}-debian"</span><span class="p">,</span> <span class="s2">"${REGISTRY}/myapp:${VERSION}"</span><span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">BASE_IMAGE</span> <span class="o">=</span> <span class="s2">"debian:bookworm-slim"</span>
    <span class="nx">IMAGE_VARIANT</span> <span class="o">=</span> <span class="s2">"debian"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-common"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">VERSION</span> <span class="o">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-alpine"</span><span class="p">,</span> <span class="s2">"app-debian"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Your Dockerfile uses the <code class="language-plaintext highlighter-rouge">BASE_IMAGE</code> argument:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ARG</span><span class="s"> BASE_IMAGE=alpine:3.19</span>
<span class="k">FROM</span><span class="s"> ${BASE_IMAGE}</span>

<span class="k">ARG</span><span class="s"> VERSION</span>
<span class="k">ARG</span><span class="s"> IMAGE_VARIANT</span>

<span class="k">LABEL</span><span class="s"> org.opencontainers.image.version="${VERSION}"</span>
<span class="k">LABEL</span><span class="s"> org.opencontainers.image.variant="${IMAGE_VARIANT}"</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">RUN if</span> <span class="o">[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">IMAGE_VARIANT</span><span class="k">}</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"alpine"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="se">\
</span>      apk add <span class="nt">--no-cache</span> ca-certificates<span class="p">;</span> <span class="se">\
</span>    <span class="k">else</span> <span class="se">\
</span>      apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> ca-certificates <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists/<span class="k">*</span><span class="p">;</span> <span class="se">\
</span>    <span class="k">fi</span>

<span class="k">ENTRYPOINT</span><span class="s"> ["/app/myapp"]</span>
</code></pre></div></div>

<p>This approach reduces duplication when the only difference between variants is the base image and package manager.</p>

<h2 id="runtime-version-matrix">Runtime Version Matrix</h2>

<p>Applications often need to support multiple runtime versions. A Python application might support Python 3.10, 3.11, and 3.12. A Node.js application might support Node 18, 20, and 21. Bake makes it easy to build a complete matrix.</p>

<h3 id="python-version-matrix-example">Python Version Matrix Example</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PLATFORMS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PYTHON_VERSIONS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"3.10"</span><span class="p">,</span> <span class="s2">"3.11"</span><span class="p">,</span> <span class="s2">"3.12"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-python-310"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-python-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-python3.10"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:python3.10"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">PYTHON_VERSION</span> <span class="o">=</span> <span class="s2">"3.10"</span>
    <span class="nx">PYTHON_IMAGE</span> <span class="o">=</span> <span class="s2">"python:3.10-slim"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-python-311"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-python-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-python3.11"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:python3.11"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">PYTHON_VERSION</span> <span class="o">=</span> <span class="s2">"3.11"</span>
    <span class="nx">PYTHON_IMAGE</span> <span class="o">=</span> <span class="s2">"python:3.11-slim"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-python-312"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-python-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-python3.12"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}"</span><span class="p">,</span>
    <span class="s2">"${REGISTRY}/myapp:latest"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">PYTHON_VERSION</span> <span class="o">=</span> <span class="s2">"3.12"</span>
    <span class="nx">PYTHON_IMAGE</span> <span class="o">=</span> <span class="s2">"python:3.12-slim"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-python-common"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="nx">PLATFORMS</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">VERSION</span> <span class="o">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span> <span class="p">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-python-310"</span><span class="p">,</span> <span class="s2">"app-python-311"</span><span class="p">,</span> <span class="s2">"app-python-312"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"python-latest"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-python-312"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The corresponding Dockerfile:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ARG</span><span class="s"> PYTHON_IMAGE=python:3.12-slim</span>
<span class="k">FROM</span><span class="s"> ${PYTHON_IMAGE}</span>

<span class="k">ARG</span><span class="s"> VERSION</span>
<span class="k">ARG</span><span class="s"> PYTHON_VERSION</span>

<span class="k">LABEL</span><span class="s"> org.opencontainers.image.version="${VERSION}"</span>
<span class="k">LABEL</span><span class="s"> org.opencontainers.image.python.version="${PYTHON_VERSION}"</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="k">COPY</span><span class="s"> requirements.txt .</span>
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">--no-cache-dir</span> <span class="nt">-r</span> requirements.txt

<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">CMD</span><span class="s"> ["python", "app.py"]</span>
</code></pre></div></div>

<h2 id="advanced-bake-patterns">Advanced Bake Patterns</h2>

<p>Once you’re comfortable with basic Bake configurations, several advanced patterns can make your builds even more powerful.</p>

<h3 id="matrix-builds-with-dynamic-targets">Matrix Builds with Dynamic Targets</h3>

<p>For large build matrices, manually defining every combination becomes tedious. Whilst Bake doesn’t support true matrix generation in HCL, you can use environment variables and multiple Bake files to achieve similar results.</p>

<p>Create a base configuration <code class="language-plaintext highlighter-rouge">docker-bake.hcl</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"docker.io/myorg"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PYTHON_VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"3.12"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"BASE_VARIANT"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"slim"</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${REGISTRY}/myapp:${VERSION}-python${PYTHON_VERSION}-${BASE_VARIANT}"</span>
  <span class="p">]</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">PYTHON_IMAGE</span> <span class="o">=</span> <span class="s2">"python:${PYTHON_VERSION}-${BASE_VARIANT}"</span>
    <span class="nx">VERSION</span>      <span class="o">=</span> <span class="nx">VERSION</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then use a shell script to invoke Bake multiple times with different variables:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-e</span>

<span class="nv">VERSIONS</span><span class="o">=(</span><span class="s2">"3.10"</span> <span class="s2">"3.11"</span> <span class="s2">"3.12"</span><span class="o">)</span>
<span class="nv">VARIANTS</span><span class="o">=(</span><span class="s2">"slim"</span> <span class="s2">"alpine"</span><span class="o">)</span>

<span class="k">for </span>version <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">VERSIONS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
  for </span>variant <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">VARIANTS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    </span><span class="nb">echo</span> <span class="s2">"Building Python </span><span class="k">${</span><span class="nv">version</span><span class="k">}</span><span class="s2"> </span><span class="k">${</span><span class="nv">variant</span><span class="k">}</span><span class="s2">"</span>
    docker buildx bake <span class="se">\</span>
      <span class="nt">--set</span> <span class="s2">"*.PYTHON_VERSION=</span><span class="k">${</span><span class="nv">version</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
      <span class="nt">--set</span> <span class="s2">"*.BASE_VARIANT=</span><span class="k">${</span><span class="nv">variant</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
      app
  <span class="k">done
done</span>
</code></pre></div></div>

<h3 id="conditional-building">Conditional Building</h3>

<p>Sometimes you want to build certain targets only in specific conditions. Whilst Bake doesn’t support conditionals directly, you can use groups and selective building:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"BUILD_ARCH"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"all"</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-amd64"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-common"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${VERSION}-amd64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-arm64"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"app-common"</span><span class="p">]</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/arm64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:${VERSION}-arm64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-common"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"all"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-amd64"</span><span class="p">,</span> <span class="s2">"app-arm64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"amd64"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-amd64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"arm64"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-arm64"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Build only AMD64 with <code class="language-plaintext highlighter-rouge">docker buildx bake amd64</code>, only ARM64 with <code class="language-plaintext highlighter-rouge">docker buildx bake arm64</code>, or both with <code class="language-plaintext highlighter-rouge">docker buildx bake all</code>.</p>

<h3 id="build-secrets-and-ssh-forwarding">Build Secrets and SSH Forwarding</h3>

<p>Production builds often need access to private dependencies or repositories. Bake supports BuildKit secrets and SSH forwarding:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"latest"</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"myapp:${VERSION}"</span><span class="p">]</span>
  
  <span class="nx">secret</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"id=npm_token,env=NPM_TOKEN"</span><span class="p">,</span>
    <span class="s2">"id=github_token,env=GITHUB_TOKEN"</span>
  <span class="p">]</span>
  
  <span class="nx">ssh</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"default"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In your Dockerfile, mount secrets:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> node:20-alpine</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="k">COPY</span><span class="s"> package*.json ./</span>

<span class="c"># Mount npm token as secret for private registry access</span>
<span class="k">RUN </span><span class="nt">--mount</span><span class="o">=</span><span class="nb">type</span><span class="o">=</span>secret,id<span class="o">=</span>npm_token <span class="se">\
</span>    <span class="nb">echo</span> <span class="s2">"//registry.npmjs.org/:_authToken=</span><span class="si">$(</span><span class="nb">cat</span> /run/secrets/npm_token<span class="si">)</span><span class="s2">"</span> <span class="o">&gt;</span> .npmrc <span class="o">&amp;&amp;</span> <span class="se">\
</span>    npm ci <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> .npmrc

<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">RUN </span>npm run build

<span class="k">CMD</span><span class="s"> ["npm", "start"]</span>
</code></pre></div></div>

<p>For SSH access to private Git repositories:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> node:20-alpine</span>

<span class="k">RUN </span>apk add <span class="nt">--no-cache</span> git openssh-client

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Mount SSH socket for private repo access</span>
<span class="k">RUN </span><span class="nt">--mount</span><span class="o">=</span><span class="nb">type</span><span class="o">=</span>ssh <span class="se">\
</span>    <span class="nb">mkdir</span> <span class="nt">-p</span> ~/.ssh <span class="o">&amp;&amp;</span> <span class="se">\
</span>    ssh-keyscan github.com <span class="o">&gt;&gt;</span> ~/.ssh/known_hosts <span class="o">&amp;&amp;</span> <span class="se">\
</span>    npm <span class="nb">install </span>git+ssh://git@github.com/myorg/private-package.git

<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">CMD</span><span class="s"> ["npm", "start"]</span>
</code></pre></div></div>

<h2 id="github-actions-integration">GitHub Actions Integration</h2>

<p>Docker Bake integrates seamlessly with GitHub Actions, providing a clean way to build and push container images in CI/CD pipelines.</p>

<h3 id="basic-github-actions-workflow">Basic GitHub Actions Workflow</h3>

<p>Create <code class="language-plaintext highlighter-rouge">.github/workflows/docker-build.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build and Push Docker Images</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
      <span class="pi">-</span> <span class="s">develop</span>
    <span class="na">tags</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">v*'</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">REGISTRY</span><span class="pi">:</span> <span class="s">ghcr.io</span>
  <span class="na">IMAGE_NAME</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
      
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract metadata</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">meta</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">VERSION=latest</span>
          <span class="s">if [[ $GITHUB_REF == refs/tags/v* ]]; then</span>
            <span class="s">VERSION=${GITHUB_REF#refs/tags/v}</span>
          <span class="s">elif [[ $GITHUB_REF == refs/heads/main ]]; then</span>
            <span class="s">VERSION=main</span>
          <span class="s">elif [[ $GITHUB_REF == refs/heads/* ]]; then</span>
            <span class="s">VERSION=${GITHUB_REF#refs/heads/}</span>
          <span class="s">fi</span>
          
          <span class="s">echo "version=${VERSION}" &gt;&gt; $GITHUB_OUTPUT</span>
          <span class="s">echo "commit_sha=${GITHUB_SHA::8}" &gt;&gt; $GITHUB_OUTPUT</span>
          <span class="s">echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" &gt;&gt; $GITHUB_OUTPUT</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build images</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">./docker-bake.hcl</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.VERSION=$</span>
            <span class="s">*.COMMIT_SHA=$</span>
            <span class="s">*.REGISTRY=$/$</span>
          <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<p>This workflow builds your images on every push and pull request, pushing only when changes are merged to main or when tags are created.</p>

<h3 id="multi-platform-builds-in-github-actions">Multi-Platform Builds in GitHub Actions</h3>

<p>For multi-platform builds, configure QEMU emulation:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build Multi-Platform Images</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">tags</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">v*'</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">REGISTRY</span><span class="pi">:</span> <span class="s">ghcr.io</span>
  <span class="na">IMAGE_NAME</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
      
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up QEMU</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-qemu-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">arm64,arm'</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract version</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">version</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">VERSION=${GITHUB_REF#refs/tags/v}</span>
          <span class="s">echo "version=${VERSION}" &gt;&gt; $GITHUB_OUTPUT</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push multi-platform images</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">./docker-bake.hcl</span>
          <span class="na">targets</span><span class="pi">:</span> <span class="s">default</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.VERSION=$</span>
            <span class="s">*.REGISTRY=$/$</span>
            <span class="s">*.PLATFORMS=linux/amd64,linux/arm64</span>
          <span class="na">push</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<h3 id="matrix-builds-in-github-actions">Matrix Builds in GitHub Actions</h3>

<p>For building multiple variants, use GitHub Actions matrix strategy:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build Image Matrix</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">REGISTRY</span><span class="pi">:</span> <span class="s">ghcr.io</span>
  <span class="na">IMAGE_NAME</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">strategy</span><span class="pi">:</span>
      <span class="na">matrix</span><span class="pi">:</span>
        <span class="na">python-version</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">3.10'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">3.11'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">3.12'</span><span class="pi">]</span>
        <span class="na">base-variant</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">slim'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">alpine'</span><span class="pi">]</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">./docker-bake.hcl</span>
          <span class="na">targets</span><span class="pi">:</span> <span class="s">app</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.PYTHON_VERSION=$</span>
            <span class="s">*.BASE_VARIANT=$</span>
            <span class="s">*.REGISTRY=$/$</span>
          <span class="na">push</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<p>This workflow builds all combinations of Python versions and base variants in parallel.</p>

<h3 id="build-caching-in-ci">Build Caching in CI</h3>

<p>BuildKit caching dramatically speeds up CI builds. Configure cache exports and imports:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build with Caching</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">REGISTRY</span><span class="pi">:</span> <span class="s">ghcr.io</span>
  <span class="na">IMAGE_NAME</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
      
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push with cache</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">./docker-bake.hcl</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.REGISTRY=$/$</span>
            <span class="s">*.cache-from=type=registry,ref=$/$:buildcache</span>
            <span class="s">*.cache-to=type=registry,ref=$/$:buildcache,mode=max</span>
          <span class="na">push</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<p>Update your <code class="language-plaintext highlighter-rouge">docker-bake.hcl</code> to support cache configuration:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"CACHE_FROM"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">""</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"CACHE_TO"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">""</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  
  <span class="nx">cache-from</span> <span class="o">=</span> <span class="nx">CACHE_FROM</span> <span class="o">!=</span> <span class="s2">""</span> <span class="o">?</span> <span class="p">[</span><span class="nx">CACHE_FROM</span><span class="p">]</span> <span class="o">:</span> <span class="p">[]</span>
  <span class="nx">cache-to</span>   <span class="o">=</span> <span class="nx">CACHE_TO</span> <span class="o">!=</span> <span class="s2">""</span> <span class="o">?</span> <span class="p">[</span><span class="nx">CACHE_TO</span><span class="p">]</span> <span class="o">:</span> <span class="p">[]</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="reusable-workflow-for-multiple-repositories">Reusable Workflow for Multiple Repositories</h3>

<p>Create a reusable workflow that can be used across multiple repositories. In your organisation’s <code class="language-plaintext highlighter-rouge">.github</code> repository, create <code class="language-plaintext highlighter-rouge">.github/workflows/docker-bake-reusable.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Reusable Docker Bake Workflow</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">workflow_call</span><span class="pi">:</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">bake-file</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Path</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">docker-bake.hcl</span><span class="nv"> </span><span class="s">file'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./docker-bake.hcl'</span>
      <span class="na">bake-target</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Bake</span><span class="nv"> </span><span class="s">target</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">build'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">default'</span>
      <span class="na">registry</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Container</span><span class="nv"> </span><span class="s">registry'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">ghcr.io'</span>
      <span class="na">platforms</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Platforms</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">build</span><span class="nv"> </span><span class="s">for'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64,linux/arm64'</span>
      <span class="na">push</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Push</span><span class="nv"> </span><span class="s">images</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">registry'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
        <span class="na">default</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">registry-username</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Registry</span><span class="nv"> </span><span class="s">username'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
      <span class="na">registry-password</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Registry</span><span class="nv"> </span><span class="s">password'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up QEMU</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-qemu-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract metadata</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">meta</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">VERSION=latest</span>
          <span class="s">if [[ $GITHUB_REF == refs/tags/v* ]]; then</span>
            <span class="s">VERSION=${GITHUB_REF#refs/tags/v}</span>
          <span class="s">elif [[ $GITHUB_REF == refs/heads/main ]]; then</span>
            <span class="s">VERSION=main</span>
          <span class="s">fi</span>
          
          <span class="s">echo "version=${VERSION}" &gt;&gt; $GITHUB_OUTPUT</span>
          <span class="s">echo "commit_sha=${GITHUB_SHA::8}" &gt;&gt; $GITHUB_OUTPUT</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">targets</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.VERSION=$</span>
            <span class="s">*.COMMIT_SHA=$</span>
            <span class="s">*.REGISTRY=$/$</span>
            <span class="s">*.PLATFORMS=$</span>
          <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<p>Use it in your application repositories:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build Docker Images</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
    <span class="na">tags</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">v*'</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">uses</span><span class="pi">:</span> <span class="s">myorg/.github/.github/workflows/docker-bake-reusable.yml@main</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64,linux/arm64'</span>
      <span class="na">push</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">registry-password</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<h2 id="complete-real-world-example">Complete Real-World Example</h2>

<p>Let’s put everything together with a complete example: a Node.js application that supports multiple Node versions, multiple platforms, and builds both development and production variants.</p>

<h3 id="project-structure">Project Structure</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.
├── docker-bake.hcl
├── docker-bake.override.hcl.example
├── Dockerfile
├── package.json
├── src/
│   └── app.js
└── .github/
    └── workflows/
        └── docker.yml
</code></pre></div></div>

<h3 id="docker-bakehcl">docker-bake.hcl</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"VERSION"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"dev"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"ghcr.io/myorg"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"COMMIT_SHA"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"unknown"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"BUILD_DATE"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">""</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"NODE_VERSIONS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"18"</span><span class="p">,</span> <span class="s2">"20"</span><span class="p">,</span> <span class="s2">"21"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PLATFORMS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">,</span> <span class="s2">"linux/arm64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">function</span> <span class="s2">"semver_tags"</span> <span class="p">{</span>
  <span class="nx">params</span> <span class="o">=</span> <span class="p">[</span><span class="nx">registry</span><span class="p">,</span> <span class="nx">app</span><span class="p">,</span> <span class="nx">version</span><span class="p">,</span> <span class="nx">node_version</span><span class="p">]</span>
  <span class="nx">result</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"${registry}/${app}:${version}-node${node_version}"</span><span class="p">,</span>
    <span class="s2">"${registry}/${app}:node${node_version}"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="nx">function</span> <span class="s2">"prod_tags"</span> <span class="p">{</span>
  <span class="nx">params</span> <span class="o">=</span> <span class="p">[</span><span class="nx">registry</span><span class="p">,</span> <span class="nx">app</span><span class="p">,</span> <span class="nx">version</span><span class="p">,</span> <span class="nx">node_version</span><span class="p">]</span>
  <span class="nx">result</span> <span class="o">=</span> <span class="nx">concat</span><span class="p">(</span>
    <span class="nx">semver_tags</span><span class="p">(</span><span class="nx">registry</span><span class="p">,</span> <span class="nx">app</span><span class="p">,</span> <span class="nx">version</span><span class="p">,</span> <span class="nx">node_version</span><span class="p">),</span>
    <span class="nx">node_version</span> <span class="o">==</span> <span class="s2">"20"</span> <span class="o">?</span> <span class="p">[</span>
      <span class="s2">"${registry}/${app}:${version}"</span><span class="p">,</span>
      <span class="s2">"${registry}/${app}:latest"</span>
    <span class="p">]</span> <span class="o">:</span> <span class="p">[]</span>
  <span class="p">)</span>
<span class="p">}</span>

<span class="c1"># Node 18 targets</span>
<span class="nx">target</span> <span class="s2">"app-node18-dev"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"node-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:dev-node18"</span><span class="p">]</span>
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">NODE_VERSION</span> <span class="o">=</span> <span class="s2">"18"</span>
    <span class="nx">NODE_ENV</span> <span class="o">=</span> <span class="s2">"development"</span>
  <span class="p">}</span>
  <span class="nx">platforms</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-node18-prod"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"node-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="nx">prod_tags</span><span class="p">(</span><span class="nx">REGISTRY</span><span class="p">,</span> <span class="s2">"myapp"</span><span class="p">,</span> <span class="nx">VERSION</span><span class="p">,</span> <span class="s2">"18"</span><span class="p">)</span>
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">NODE_VERSION</span> <span class="o">=</span> <span class="s2">"18"</span>
    <span class="nx">NODE_ENV</span> <span class="o">=</span> <span class="s2">"production"</span>
  <span class="p">}</span>
  <span class="nx">output</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"type=registry,push=true"</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Node 20 targets</span>
<span class="nx">target</span> <span class="s2">"app-node20-dev"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"node-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:dev-node20"</span><span class="p">,</span> <span class="s2">"${REGISTRY}/myapp:dev"</span><span class="p">]</span>
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">NODE_VERSION</span> <span class="o">=</span> <span class="s2">"20"</span>
    <span class="nx">NODE_ENV</span> <span class="o">=</span> <span class="s2">"development"</span>
  <span class="p">}</span>
  <span class="nx">platforms</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-node20-prod"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"node-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="nx">prod_tags</span><span class="p">(</span><span class="nx">REGISTRY</span><span class="p">,</span> <span class="s2">"myapp"</span><span class="p">,</span> <span class="nx">VERSION</span><span class="p">,</span> <span class="s2">"20"</span><span class="p">)</span>
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">NODE_VERSION</span> <span class="o">=</span> <span class="s2">"20"</span>
    <span class="nx">NODE_ENV</span> <span class="o">=</span> <span class="s2">"production"</span>
  <span class="p">}</span>
  <span class="nx">output</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"type=registry,push=true"</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Node 21 targets</span>
<span class="nx">target</span> <span class="s2">"app-node21-dev"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"node-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="p">[</span><span class="s2">"${REGISTRY}/myapp:dev-node21"</span><span class="p">]</span>
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">NODE_VERSION</span> <span class="o">=</span> <span class="s2">"21"</span>
    <span class="nx">NODE_ENV</span> <span class="o">=</span> <span class="s2">"development"</span>
  <span class="p">}</span>
  <span class="nx">platforms</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">target</span> <span class="s2">"app-node21-prod"</span> <span class="p">{</span>
  <span class="nx">inherits</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"node-common"</span><span class="p">]</span>
  <span class="nx">tags</span>       <span class="o">=</span> <span class="nx">prod_tags</span><span class="p">(</span><span class="nx">REGISTRY</span><span class="p">,</span> <span class="s2">"myapp"</span><span class="p">,</span> <span class="nx">VERSION</span><span class="p">,</span> <span class="s2">"21"</span><span class="p">)</span>
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">NODE_VERSION</span> <span class="o">=</span> <span class="s2">"21"</span>
    <span class="nx">NODE_ENV</span> <span class="o">=</span> <span class="s2">"production"</span>
  <span class="p">}</span>
  <span class="nx">output</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"type=registry,push=true"</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Common target configuration</span>
<span class="nx">target</span> <span class="s2">"node-common"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"Dockerfile"</span>
  <span class="nx">platforms</span>  <span class="o">=</span> <span class="nx">PLATFORMS</span>
  
  <span class="nx">args</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">VERSION</span>    <span class="o">=</span> <span class="nx">VERSION</span>
    <span class="nx">COMMIT_SHA</span> <span class="o">=</span> <span class="nx">COMMIT_SHA</span>
    <span class="nx">BUILD_DATE</span> <span class="o">=</span> <span class="nx">BUILD_DATE</span> <span class="o">!=</span> <span class="s2">""</span> <span class="o">?</span> <span class="nx">BUILD_DATE</span> <span class="o">:</span> <span class="nx">timestamp</span><span class="err">()</span>
  <span class="p">}</span>
  
  <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"org.opencontainers.image.version"</span>    <span class="p">=</span> <span class="nx">VERSION</span>
    <span class="s2">"org.opencontainers.image.revision"</span>   <span class="o">=</span> <span class="nx">COMMIT_SHA</span>
    <span class="s2">"org.opencontainers.image.created"</span>    <span class="o">=</span> <span class="nx">BUILD_DATE</span> <span class="o">!=</span> <span class="s2">""</span> <span class="o">?</span> <span class="nx">BUILD_DATE</span> <span class="o">:</span> <span class="nx">timestamp</span><span class="p">()</span>
    <span class="s2">"org.opencontainers.image.source"</span>     <span class="p">=</span> <span class="s2">"https://github.com/myorg/myapp"</span>
    <span class="s2">"org.opencontainers.image.vendor"</span>     <span class="p">=</span> <span class="s2">"MyOrg"</span>
  <span class="p">}</span>
  
  <span class="nx">cache-from</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"type=registry,ref=${REGISTRY}/myapp:buildcache"</span><span class="p">]</span>
  <span class="nx">cache-to</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"type=registry,ref=${REGISTRY}/myapp:buildcache,mode=max"</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Groups for different scenarios</span>
<span class="nx">group</span> <span class="s2">"dev"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-node18-dev"</span><span class="p">,</span> <span class="s2">"app-node20-dev"</span><span class="p">,</span> <span class="s2">"app-node21-dev"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"prod"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-node18-prod"</span><span class="p">,</span> <span class="s2">"app-node20-prod"</span><span class="p">,</span> <span class="s2">"app-node21-prod"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"node20"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-node20-dev"</span><span class="p">,</span> <span class="s2">"app-node20-prod"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">group</span> <span class="s2">"default"</span> <span class="p">{</span>
  <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"app-node20-dev"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="dockerfile">Dockerfile</h3>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ARG</span><span class="s"> NODE_VERSION=20</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">node:${NODE_VERSION}-alpine</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span>

<span class="k">ARG</span><span class="s"> VERSION</span>
<span class="k">ARG</span><span class="s"> COMMIT_SHA</span>
<span class="k">ARG</span><span class="s"> BUILD_DATE</span>
<span class="k">ARG</span><span class="s"> NODE_ENV=production</span>

<span class="k">LABEL</span><span class="s"> org.opencontainers.image.version="${VERSION}"</span>
<span class="k">LABEL</span><span class="s"> org.opencontainers.image.revision="${COMMIT_SHA}"</span>
<span class="k">LABEL</span><span class="s"> org.opencontainers.image.created="${BUILD_DATE}"</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Dependencies stage</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">dependencies</span>

<span class="k">COPY</span><span class="s"> package*.json ./</span>

<span class="k">RUN if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$NODE_ENV</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"production"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="se">\
</span>      npm ci <span class="nt">--only</span><span class="o">=</span>production<span class="p">;</span> <span class="se">\
</span>    <span class="k">else</span> <span class="se">\
</span>      npm ci<span class="p">;</span> <span class="se">\
</span>    <span class="k">fi</span>

<span class="c"># Build stage (for TypeScript or other build steps)</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">dependencies</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span>

<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">RUN if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$NODE_ENV</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"production"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="se">\
</span>      npm run build 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="p">;</span> <span class="se">\
</span>    <span class="k">fi</span>

<span class="c"># Production stage</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">production</span>

<span class="k">ENV</span><span class="s"> NODE_ENV=production</span>

<span class="c"># Copy only production dependencies</span>
<span class="k">COPY</span><span class="s"> --from=dependencies /app/node_modules ./node_modules</span>
<span class="k">COPY</span><span class="s"> --from=build /app/dist ./dist 2&gt;/dev/null || true</span>
<span class="k">COPY</span><span class="s"> package*.json ./</span>
<span class="k">COPY</span><span class="s"> src ./src</span>

<span class="c"># Create non-root user</span>
<span class="k">RUN </span>addgroup <span class="nt">-g</span> 1001 <span class="nt">-S</span> nodejs <span class="o">&amp;&amp;</span> <span class="se">\
</span>    adduser <span class="nt">-S</span> nodejs <span class="nt">-u</span> 1001 <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">chown</span> <span class="nt">-R</span> nodejs:nodejs /app

<span class="k">USER</span><span class="s"> nodejs</span>

<span class="k">EXPOSE</span><span class="s"> 3000</span>

<span class="k">HEALTHCHECK</span><span class="s"> --interval=30s --timeout=3s --start-period=5s --retries=3 \</span>
  CMD node -e "require('http').get('http://localhost:3000/health', (r) =&gt; {process.exit(r.statusCode === 200 ? 0 : 1)})"

<span class="k">CMD</span><span class="s"> ["node", "src/app.js"]</span>

<span class="c"># Development stage</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">dependencies</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">development</span>

<span class="k">ENV</span><span class="s"> NODE_ENV=development</span>

<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">USER</span><span class="s"> node</span>

<span class="k">EXPOSE</span><span class="s"> 3000</span>

<span class="k">CMD</span><span class="s"> ["npm", "run", "dev"]</span>
</code></pre></div></div>

<h3 id="github-actions-workflow">GitHub Actions Workflow</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build and Push Docker Images</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
      <span class="pi">-</span> <span class="s">develop</span>
    <span class="na">tags</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">v*'</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">REGISTRY</span><span class="pi">:</span> <span class="s">ghcr.io</span>
  <span class="na">IMAGE_NAME</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build-dev</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name == 'pull_request' || github.ref == 'refs/heads/develop'</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build development images</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="s">./docker-bake.hcl</span>
          <span class="na">targets</span><span class="pi">:</span> <span class="s">dev</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.REGISTRY=$/$</span>
            <span class="s">*.COMMIT_SHA=$</span>
          <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
  
  <span class="na">build-prod</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up QEMU</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-qemu-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">arm64'</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract version</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">version</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">if [[ $GITHUB_REF == refs/tags/v* ]]; then</span>
            <span class="s">VERSION=${GITHUB_REF#refs/tags/v}</span>
          <span class="s">else</span>
            <span class="s">VERSION=main-${GITHUB_SHA::8}</span>
          <span class="s">fi</span>
          <span class="s">echo "version=${VERSION}" &gt;&gt; $GITHUB_OUTPUT</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push production images</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/bake-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="s">./docker-bake.hcl</span>
          <span class="na">targets</span><span class="pi">:</span> <span class="s">prod</span>
          <span class="na">set</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">*.VERSION=$</span>
            <span class="s">*.COMMIT_SHA=$</span>
            <span class="s">*.REGISTRY=$/$</span>
            <span class="s">*.BUILD_DATE=$</span>
          <span class="na">push</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<h2 id="best-practices-and-tips">Best Practices and Tips</h2>

<p>After working with Docker Bake across numerous projects, several patterns have emerged as particularly effective.</p>

<h3 id="keep-bake-files-declarative">Keep Bake Files Declarative</h3>

<p>Resist the temptation to add too much logic to your Bake files. They work best when they declare what you want to build, not how to build it. Complex conditionals and computations belong in CI scripts or separate tooling, not in Bake configuration.</p>

<h3 id="use-inheritance-extensively">Use Inheritance Extensively</h3>

<p>The <code class="language-plaintext highlighter-rouge">inherits</code> field is one of Bake’s most powerful features. Define common configuration once in a base target, then inherit from it in specific targets. This reduces duplication and ensures consistency across related builds.</p>

<h3 id="organise-targets-into-logical-groups">Organise Targets into Logical Groups</h3>

<p>Groups make it easy to build related targets together. Create groups for different scenarios: <code class="language-plaintext highlighter-rouge">dev</code> for development builds, <code class="language-plaintext highlighter-rouge">prod</code> for production, <code class="language-plaintext highlighter-rouge">ci</code> for continuous integration. Users can then run <code class="language-plaintext highlighter-rouge">docker buildx bake dev</code> without needing to know which specific targets that includes.</p>

<h3 id="version-your-bake-files">Version Your Bake Files</h3>

<p>Treat your <code class="language-plaintext highlighter-rouge">docker-bake.hcl</code> files as code. Version them in Git, review changes through pull requests, and test modifications before merging. Bake configuration changes can have significant impact on your build process.</p>

<h3 id="document-your-targets">Document Your Targets</h3>

<p>Add comments explaining what each target does and when to use it. Future maintainers (including yourself) will appreciate understanding the purpose of <code class="language-plaintext highlighter-rouge">app-python-311-alpine-optimised</code> without having to reverse-engineer the configuration.</p>

<h3 id="use-override-files-for-local-development">Use Override Files for Local Development</h3>

<p>Create a <code class="language-plaintext highlighter-rouge">docker-bake.override.hcl.example</code> file that developers can copy to <code class="language-plaintext highlighter-rouge">docker-bake.override.hcl</code> for local customisation. Bake automatically merges override files, allowing developers to adjust registry paths or platforms without modifying the main configuration.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># docker-bake.override.hcl.example</span>
<span class="nx">variable</span> <span class="s2">"REGISTRY"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"localhost:5000"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"PLATFORMS"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"linux/amd64"</span><span class="p">]</span>  <span class="c1"># Build only local platform for speed</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="validate-your-bake-files">Validate Your Bake Files</h3>

<p>Before committing changes, validate your Bake files:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker buildx bake <span class="nt">--print</span>
</code></pre></div></div>

<p>This shows the resolved configuration without actually building anything, catching syntax errors and misconfigurations early.</p>

<h3 id="cache-aggressively-in-ci">Cache Aggressively in CI</h3>

<p>Build caching can reduce CI build times from minutes to seconds. Configure both <code class="language-plaintext highlighter-rouge">cache-from</code> and <code class="language-plaintext highlighter-rouge">cache-to</code> in your targets and ensure your CI environment pushes cache layers to a registry.</p>

<h3 id="monitor-build-times">Monitor Build Times</h3>

<p>Track how long your builds take and optimise the slow ones. Multi-stage builds with effective layer caching make the biggest difference. BuildKit’s build statistics show where time is spent.</p>

<h2 id="troubleshooting-common-issues">Troubleshooting Common Issues</h2>

<p>Even with careful configuration, you’ll occasionally encounter issues. Here are solutions to the most common problems.</p>

<h3 id="bake-cant-find-dockerfile">Bake Can’t Find Dockerfile</h3>

<p>Error: <code class="language-plaintext highlighter-rouge">failed to solve: failed to read dockerfile</code></p>

<p>This usually means the context or dockerfile path is wrong. Remember that paths in Bake files are relative to the Bake file’s location, not your current directory. Use <code class="language-plaintext highlighter-rouge">./</code> explicitly:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">target</span> <span class="s2">"app"</span> <span class="p">{</span>
  <span class="nx">context</span>    <span class="o">=</span> <span class="s2">"."</span>
  <span class="nx">dockerfile</span> <span class="o">=</span> <span class="s2">"./Dockerfile"</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="platforms-not-building">Platforms Not Building</h3>

<p>If multi-platform builds fail, ensure you have the correct builder:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker buildx create <span class="nt">--name</span> multiplatform <span class="nt">--driver</span> docker-container <span class="nt">--use</span>
docker buildx inspect <span class="nt">--bootstrap</span>
</code></pre></div></div>

<p>Then verify your builder supports the platforms you need:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker buildx inspect multiplatform
</code></pre></div></div>

<h3 id="cache-not-working">Cache Not Working</h3>

<p>If builds aren’t using cache despite configuration, check that:</p>

<ol>
  <li>Cache references are accessible (correct registry, authentication)</li>
  <li>You’re using <code class="language-plaintext highlighter-rouge">mode=max</code> for cache-to to cache all layers</li>
  <li>Your Dockerfile uses layer caching effectively (copy dependencies before code)</li>
</ol>

<h3 id="variables-not-resolving">Variables Not Resolving</h3>

<p>If variables show as literal strings instead of values, check that:</p>

<ol>
  <li>You’re using <code class="language-plaintext highlighter-rouge">${}</code> syntax, not <code class="language-plaintext highlighter-rouge">$</code> alone: <code class="language-plaintext highlighter-rouge">${VERSION}</code>, not <code class="language-plaintext highlighter-rouge">$VERSION</code></li>
  <li>Variables are defined before use</li>
  <li>You’re not mixing environment variables with Bake variables (prefix env vars with <code class="language-plaintext highlighter-rouge">$</code>)</li>
</ol>

<h3 id="build-fails-in-ci-but-works-locally">Build Fails in CI But Works Locally</h3>

<p>This often indicates environmental differences:</p>

<ol>
  <li>Check platform architecture (CI might be different from local)</li>
  <li>Verify secrets and SSH keys are available in CI</li>
  <li>Ensure network access for downloading dependencies</li>
  <li>Check resource limits (CI runners might have less memory/CPU)</li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>Docker Bake transforms container image builds from imperative scripts into declarative configuration. Instead of maintaining complex shell scripts that orchestrate dozens of <code class="language-plaintext highlighter-rouge">docker build</code> commands, you define your complete build matrix in a single file that serves as documentation, automation, and contract all at once.</p>

<p>The patterns we’ve explored—multi-platform builds, multiple base images, runtime version matrices, sophisticated tagging strategies—all become manageable through Bake’s inheritance, functions, and groups. Your build configuration becomes more maintainable, easier to understand, and more reliable.</p>

<p>For production container workflows, Bake isn’t just a convenience, it’s a force multiplier that lets you maintain complex build matrices without drowning in complexity. The GitHub Actions integrations show how Bake fits naturally into modern CI/CD pipelines, providing consistent builds from local development through production deployment.</p>

<p>As your containerisation needs grow, Docker Bake grows with you. Start simple with basic targets and groups, then progressively add platforms, variants, and optimisations as requirements evolve. The declarative configuration ensures that complexity remains manageable no matter how large your build matrix becomes.</p>

<p>The investment in learning Docker Bake pays dividends quickly. Your builds become faster through better caching, more reliable through consistent configuration, and more maintainable through clear declarative structure. You’ve built a foundation that will scale from a handful of images to hundreds of build variants without requiring fundamental rewrites.</p>]]></content><author><name>Glen Thomas</name></author><category term="Platform Engineering" /><category term="Software Engineering" /><category term="Docker" /><category term="Docker Bake" /><category term="Multi-Platform" /><category term="CI/CD" /><category term="GitHub Actions" /><category term="Container Images" /><summary type="html"><![CDATA[Building Docker images has evolved far beyond simple docker build commands. Modern applications demand multi-platform support to run on both x86 and ARM architectures, multiple base images to support different runtime environments, and sophisticated tagging strategies to manage versions across development, staging, and production. Managing this complexity with shell scripts quickly becomes unwieldy, error-prone, and difficult to maintain.]]></summary></entry><entry><title type="html">Container Security Fundamentals: Protecting Your Containerised Applications</title><link href="https://blog.glen-thomas.com/platform%20engineering/software%20engineering/2025/12/01/container-security-fundamentals-protecting-your-containerised-applications.html" rel="alternate" type="text/html" title="Container Security Fundamentals: Protecting Your Containerised Applications" /><published>2025-12-01T21:18:00+00:00</published><updated>2025-12-01T21:18:00+00:00</updated><id>https://blog.glen-thomas.com/platform%20engineering/software%20engineering/2025/12/01/container-security-fundamentals-protecting-your-containerised-applications</id><content type="html" xml:base="https://blog.glen-thomas.com/platform%20engineering/software%20engineering/2025/12/01/container-security-fundamentals-protecting-your-containerised-applications.html"><![CDATA[<p>Containers have fundamentally changed how we build and deploy applications, but they’ve also introduced new security considerations that many teams struggle to address comprehensively. The speed and convenience of containers can lull organisations into a false sense of security, treating them as lightweight virtual machines without understanding the fundamentally different security model they represent.</p>

<p>The reality is that containers share the host kernel with every other container on the same machine. A vulnerability in that kernel affects every container. A container escape gives an attacker access to the entire host and potentially every other workload running there. Understanding these risks—and the mechanisms Linux provides to mitigate them—is essential for anyone running containers in production.</p>

<p>In this comprehensive guide, I’ll walk you through the fundamental security concepts that underpin container isolation, the practical measures you can implement to harden your containerised applications, and the tools and workflows that make security a natural part of your development process rather than an afterthought bolted on at deployment time.</p>

<h2 id="understanding-container-isolation">Understanding Container Isolation</h2>

<p>Before we can secure containers effectively, we need to understand how they achieve isolation in the first place. Containers aren’t virtual machines—they don’t run separate kernels or emulate hardware. Instead, they use Linux kernel features to create isolated environments that share the same kernel whilst appearing to be separate systems.</p>

<h3 id="the-shared-kernel-model">The Shared Kernel Model</h3>

<p>Every container on a host shares the same Linux kernel. When you run <code class="language-plaintext highlighter-rouge">docker run ubuntu:22.04</code>, you’re not running an Ubuntu kernel—you’re running the host’s kernel with an Ubuntu userspace. This is fundamentally different from virtual machines, where each VM runs its own kernel instance.</p>

<p>This shared kernel model brings both efficiency and risk. Containers start in milliseconds because there’s no kernel to boot. They use less memory because there’s no kernel overhead per container. But a kernel vulnerability affects every container on the host, and a container escape potentially compromises every workload.</p>

<p>Understanding this model shapes how we think about container security. We’re not securing isolated systems; we’re securing processes that share resources with other processes whilst maintaining the illusion of isolation.</p>

<h3 id="linux-namespaces">Linux Namespaces</h3>

<p>Namespaces are the primary isolation mechanism for containers. They partition kernel resources so that one set of processes sees one set of resources whilst another set of processes sees a different set. Linux provides several namespace types, each isolating a different aspect of the system.</p>

<p>The PID namespace isolates process IDs. Inside a container, processes see themselves as PID 1, 2, 3, and so on, but from the host’s perspective, these same processes have entirely different PIDs. This prevents containers from seeing or signalling processes in other containers.</p>

<p>The network namespace isolates network interfaces, routing tables, and firewall rules. Each container gets its own network stack, appearing to have dedicated network interfaces even though the physical hardware is shared.</p>

<p>The mount namespace isolates filesystem mount points. A container sees its own root filesystem without access to the host’s filesystem or other containers’ filesystems.</p>

<p>The UTS namespace isolates hostname and domain name. Each container can have its own hostname without affecting others.</p>

<p>The IPC namespace isolates inter-process communication resources like shared memory segments and message queues. This prevents containers from communicating through these mechanisms unless explicitly allowed.</p>

<p>The user namespace maps user and group IDs inside the container to different IDs outside. Root inside the container can be mapped to an unprivileged user on the host, significantly reducing the impact of container escapes.</p>

<p>Here’s how you can inspect namespaces for a running container:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Get the container's PID on the host</span>
<span class="nv">CONTAINER_PID</span><span class="o">=</span><span class="si">$(</span>docker inspect <span class="nt">--format</span> <span class="s1">''</span> my-container<span class="si">)</span>

<span class="c"># List the namespaces for this process</span>
<span class="nb">ls</span> <span class="nt">-la</span> /proc/<span class="nv">$CONTAINER_PID</span>/ns/

<span class="c"># Compare with host namespaces</span>
<span class="nb">ls</span> <span class="nt">-la</span> /proc/1/ns/
</code></pre></div></div>

<h3 id="control-groups-cgroups">Control Groups (cgroups)</h3>

<p>While namespaces provide isolation of what a container can see, cgroups control what resources a container can use. They limit and account for CPU, memory, disk I/O, and network bandwidth consumption.</p>

<p>Without cgroups, a single container could consume all available memory, causing the kernel’s out-of-memory killer to terminate other containers or even critical host processes. A container could monopolise CPU, starving other workloads. Cgroups prevent this resource starvation.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># View cgroup limits for a container</span>
docker inspect <span class="nt">--format</span> <span class="s1">''</span> my-container
docker inspect <span class="nt">--format</span> <span class="s1">''</span> my-container

<span class="c"># Set resource limits when running a container</span>
docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--memory</span><span class="o">=</span><span class="s2">"512m"</span> <span class="se">\</span>
  <span class="nt">--memory-swap</span><span class="o">=</span><span class="s2">"512m"</span> <span class="se">\</span>
  <span class="nt">--cpus</span><span class="o">=</span><span class="s2">"1.5"</span> <span class="se">\</span>
  <span class="nt">--pids-limit</span><span class="o">=</span>100 <span class="se">\</span>
  myapp:latest
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--memory</code> flag limits total memory. The <code class="language-plaintext highlighter-rouge">--memory-swap</code> flag set equal to <code class="language-plaintext highlighter-rouge">--memory</code> disables swap, preventing the container from using swap space when memory is exhausted. The <code class="language-plaintext highlighter-rouge">--cpus</code> flag limits CPU usage to 1.5 cores. The <code class="language-plaintext highlighter-rouge">--pids-limit</code> flag prevents fork bombs by limiting the number of processes.</p>

<h3 id="linux-capabilities">Linux Capabilities</h3>

<p>Traditional Unix security divides the world into two categories: root, which can do anything, and everyone else, which is heavily restricted. This is too coarse-grained for containers. You might need a container that can bind to privileged ports but can’t load kernel modules.</p>

<p>Linux capabilities split root’s powers into distinct units that can be granted independently. Instead of running as root with all powers, you can run as root with only the specific capabilities needed.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run a container with minimal capabilities</span>
docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--cap-drop</span><span class="o">=</span>ALL <span class="se">\</span>
  <span class="nt">--cap-add</span><span class="o">=</span>NET_BIND_SERVICE <span class="se">\</span>
  myapp:latest

<span class="c"># List capabilities of a running container</span>
docker inspect <span class="nt">--format</span> <span class="s1">''</span> my-container
docker inspect <span class="nt">--format</span> <span class="s1">''</span> my-container
</code></pre></div></div>

<p>Key capabilities to understand:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CAP_NET_BIND_SERVICE</code>: Bind to ports below 1024</li>
  <li><code class="language-plaintext highlighter-rouge">CAP_NET_RAW</code>: Use raw sockets (needed for ping)</li>
  <li><code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>: A catch-all for various admin operations (avoid granting this)</li>
  <li><code class="language-plaintext highlighter-rouge">CAP_SYS_PTRACE</code>: Trace processes (needed for debugging)</li>
  <li><code class="language-plaintext highlighter-rouge">CAP_CHOWN</code>: Change file ownership</li>
  <li><code class="language-plaintext highlighter-rouge">CAP_SETUID</code> / <code class="language-plaintext highlighter-rouge">CAP_SETGID</code>: Change user/group IDs</li>
</ul>

<p>The principle of least privilege demands dropping all capabilities and adding back only those specifically required. Most applications need far fewer capabilities than Docker grants by default.</p>

<h3 id="seccomp-profiles">Seccomp Profiles</h3>

<p>Seccomp (secure computing mode) filters system calls that a process can make. The Linux kernel provides hundreds of syscalls, but most applications need only a fraction of them. Seccomp profiles whitelist or blacklist specific syscalls, reducing the kernel attack surface available to a container.</p>

<p>Docker applies a default seccomp profile that blocks around 44 syscalls considered dangerous, including <code class="language-plaintext highlighter-rouge">reboot</code>, <code class="language-plaintext highlighter-rouge">mount</code>, and <code class="language-plaintext highlighter-rouge">kexec_load</code>. You can create custom profiles for tighter restrictions:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"defaultAction"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SCMP_ACT_ERRNO"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"defaultErrnoRet"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"architectures"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"SCMP_ARCH_X86_64"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"SCMP_ARCH_AARCH64"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"syscalls"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"names"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"accept"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"accept4"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"bind"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"clone"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"close"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"connect"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"dup"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"dup2"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"execve"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"exit"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"exit_group"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"fcntl"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"fstat"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"futex"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"getpid"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"getsockname"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"getsockopt"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"listen"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"mmap"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"mprotect"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"munmap"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"open"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"openat"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"read"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"recvfrom"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"rt_sigaction"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"rt_sigprocmask"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"sendto"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"setsockopt"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"socket"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"write"</span><span class="w">
      </span><span class="p">],</span><span class="w">
      </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SCMP_ACT_ALLOW"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Apply a custom seccomp profile:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--security-opt</span> <span class="nv">seccomp</span><span class="o">=</span>/path/to/profile.json <span class="se">\</span>
  myapp:latest
</code></pre></div></div>

<p>To generate a seccomp profile for your application, you can use tools like <code class="language-plaintext highlighter-rouge">strace</code> to observe which syscalls your application actually makes, then create a whitelist profile.</p>

<h2 id="image-security">Image Security</h2>

<p>Container images are the foundation of your containerised applications. A vulnerable or malicious base image compromises everything built upon it. Securing your images starts with choosing the right base and extends through scanning, signing, and supply chain verification.</p>

<h3 id="choosing-secure-base-images">Choosing Secure Base Images</h3>

<p>The base image you choose dramatically affects your security posture. A full Ubuntu image contains thousands of packages, each potentially harbouring vulnerabilities. A minimal image contains only what’s necessary, reducing attack surface significantly.</p>

<p><strong>Distroless Images</strong></p>

<p>Google’s distroless images contain only your application and its runtime dependencies—no shell, no package manager, no unnecessary utilities. An attacker who gains code execution can’t spawn a shell because there isn’t one.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build stage</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.21</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">RUN </span><span class="nv">CGO_ENABLED</span><span class="o">=</span>0 go build <span class="nt">-o</span> myapp

<span class="c"># Runtime stage - distroless</span>
<span class="k">FROM</span><span class="s"> gcr.io/distroless/static-debian12</span>

<span class="k">COPY</span><span class="s"> --from=builder /app/myapp /</span>
<span class="k">USER</span><span class="s"> nonroot:nonroot</span>

<span class="k">ENTRYPOINT</span><span class="s"> ["/myapp"]</span>
</code></pre></div></div>

<p><strong>Alpine Linux</strong></p>

<p>Alpine uses musl libc instead of glibc and provides a minimal base around 5MB. It includes a package manager but far fewer default packages than traditional distributions.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> alpine:3.19</span>

<span class="k">RUN </span>apk add <span class="nt">--no-cache</span> ca-certificates tzdata

<span class="k">COPY</span><span class="s"> myapp /usr/local/bin/</span>
<span class="k">USER</span><span class="s"> nobody:nobody</span>

<span class="k">ENTRYPOINT</span><span class="s"> ["/usr/local/bin/myapp"]</span>
</code></pre></div></div>

<p><strong>Scratch Images</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">scratch</code> image is literally empty; no files, no filesystem, nothing. It’s suitable for statically compiled binaries that include everything they need.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.21</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">RUN </span><span class="nv">CGO_ENABLED</span><span class="o">=</span>0 <span class="nv">GOOS</span><span class="o">=</span>linux go build <span class="nt">-a</span> <span class="nt">-ldflags</span> <span class="s1">'-extldflags "-static"'</span> <span class="nt">-o</span> myapp

<span class="k">FROM</span><span class="s"> scratch</span>

<span class="k">COPY</span><span class="s"> --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/</span>
<span class="k">COPY</span><span class="s"> --from=builder /app/myapp /</span>

<span class="k">ENTRYPOINT</span><span class="s"> ["/myapp"]</span>
</code></pre></div></div>

<h3 id="vulnerability-scanning">Vulnerability Scanning</h3>

<p>Vulnerability scanners analyse your images against databases of known vulnerabilities (CVEs). They identify vulnerable packages and provide severity ratings to help prioritise remediation.</p>

<p><strong>Trivy</strong></p>

<p>Trivy is a comprehensive, fast scanner that checks for OS package vulnerabilities, language-specific dependencies, misconfigurations, and secrets.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Scan an image</span>
trivy image myapp:latest

<span class="c"># Scan with specific severity threshold</span>
trivy image <span class="nt">--severity</span> HIGH,CRITICAL myapp:latest

<span class="c"># Scan and fail if vulnerabilities found (useful for CI)</span>
trivy image <span class="nt">--exit-code</span> 1 <span class="nt">--severity</span> CRITICAL myapp:latest

<span class="c"># Scan a Dockerfile for misconfigurations</span>
trivy config Dockerfile

<span class="c"># Generate SBOM while scanning</span>
trivy image <span class="nt">--format</span> spdx-json <span class="nt">--output</span> sbom.spdx.json myapp:latest
</code></pre></div></div>

<p><strong>Grype</strong></p>

<p>Grype from Anchore focuses specifically on vulnerability scanning with excellent accuracy and speed.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Scan an image</span>
grype myapp:latest

<span class="c"># Output as JSON for processing</span>
grype <span class="nt">-o</span> json myapp:latest <span class="o">&gt;</span> vulnerabilities.json

<span class="c"># Fail on specific severity</span>
grype myapp:latest <span class="nt">--fail-on</span> critical
</code></pre></div></div>

<p><strong>Snyk</strong></p>

<p>Snyk provides vulnerability scanning with remediation advice and integrates well with development workflows.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Scan an image</span>
snyk container <span class="nb">test </span>myapp:latest

<span class="c"># Monitor for new vulnerabilities</span>
snyk container monitor myapp:latest

<span class="c"># Scan and provide fix recommendations</span>
snyk container <span class="nb">test </span>myapp:latest <span class="nt">--file</span><span class="o">=</span>Dockerfile
</code></pre></div></div>

<h3 id="multi-stage-builds-for-security">Multi-Stage Builds for Security</h3>

<p>Multi-stage builds are a security feature as much as an optimisation. They ensure build tools, source code, test data, and secrets used during build don’t end up in your final image.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Stage 1: Dependencies</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">node:20-alpine</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">dependencies</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> package*.json ./</span>
<span class="k">RUN </span>npm ci <span class="nt">--only</span><span class="o">=</span>production

<span class="c"># Stage 2: Build</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">node:20-alpine</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> package*.json ./</span>
<span class="k">RUN </span>npm ci

<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">RUN </span>npm run build
<span class="k">RUN </span>npm run <span class="nb">test</span>

<span class="c"># Stage 3: Production - only runtime artifacts</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">node:20-alpine</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">production</span>

<span class="k">RUN </span>addgroup <span class="nt">-g</span> 1001 <span class="nt">-S</span> nodejs <span class="o">&amp;&amp;</span> <span class="se">\
</span>    adduser <span class="nt">-S</span> nodejs <span class="nt">-u</span> 1001

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Copy only production dependencies</span>
<span class="k">COPY</span><span class="s"> --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules</span>

<span class="c"># Copy only built artifacts</span>
<span class="k">COPY</span><span class="s"> --from=builder --chown=nodejs:nodejs /app/dist ./dist</span>
<span class="k">COPY</span><span class="s"> --from=builder --chown=nodejs:nodejs /app/package.json ./</span>

<span class="k">USER</span><span class="s"> nodejs</span>

<span class="k">EXPOSE</span><span class="s"> 3000</span>

<span class="k">CMD</span><span class="s"> ["node", "dist/server.js"]</span>
</code></pre></div></div>

<p>Notice what doesn’t appear in the final image: npm (we use node directly), the full node_modules with dev dependencies, source TypeScript files, test files, and any secrets used during the build process. The final image contains only what’s needed to run the application.</p>

<h3 id="image-signing-and-verification">Image Signing and Verification</h3>

<p>Image signing cryptographically proves that an image came from a trusted source and hasn’t been tampered with. Sigstore’s Cosign has become the de facto standard for container image signing.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Generate a key pair (for traditional key-based signing)</span>
cosign generate-key-pair

<span class="c"># Sign an image with a key</span>
cosign sign <span class="nt">--key</span> cosign.key myregistry/myapp:v1.0.0

<span class="c"># Sign using keyless signing (recommended)</span>
<span class="c"># This uses OIDC identity from your CI provider</span>
cosign sign myregistry/myapp:v1.0.0

<span class="c"># Verify a signature</span>
cosign verify <span class="nt">--key</span> cosign.pub myregistry/myapp:v1.0.0

<span class="c"># Verify keyless signature</span>
cosign verify <span class="se">\</span>
  <span class="nt">--certificate-identity</span><span class="o">=</span>https://github.com/myorg/myapp/.github/workflows/build.yml@refs/tags/v1.0.0 <span class="se">\</span>
  <span class="nt">--certificate-oidc-issuer</span><span class="o">=</span>https://token.actions.githubusercontent.com <span class="se">\</span>
  myregistry/myapp:v1.0.0
</code></pre></div></div>

<p>Keyless signing with Sigstore is particularly powerful for CI/CD. It uses your CI provider’s OIDC identity to sign images, eliminating the need to manage signing keys. The signature includes claims about who built the image and in what context, providing strong provenance guarantees.</p>

<h3 id="software-bill-of-materials-sbom">Software Bill of Materials (SBOM)</h3>

<p>An SBOM lists all components in your software, including libraries, dependencies, and their versions. When a new vulnerability is discovered, an SBOM lets you quickly determine which of your images are affected.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Generate SBOM with Syft</span>
syft myapp:latest <span class="nt">-o</span> spdx-json <span class="o">&gt;</span> sbom.spdx.json

<span class="c"># Generate SBOM with Trivy</span>
trivy image <span class="nt">--format</span> spdx-json <span class="nt">--output</span> sbom.json myapp:latest

<span class="c"># Attach SBOM to image with Cosign</span>
cosign attach sbom <span class="nt">--sbom</span> sbom.spdx.json myregistry/myapp:v1.0.0

<span class="c"># Scan SBOM for vulnerabilities</span>
grype sbom:sbom.spdx.json
</code></pre></div></div>

<p>Store SBOMs alongside your images and keep them for the lifetime of the image. When Log4Shell or the next major vulnerability hits, you’ll be able to query your SBOMs to identify affected images within minutes rather than days.</p>

<h2 id="runtime-security">Runtime Security</h2>

<p>Securing images is essential but insufficient. Runtime security focuses on protecting containers while they execute, limiting what they can do even if compromised.</p>

<h3 id="running-as-non-root">Running as Non-Root</h3>

<p>By default, containers run as root. A vulnerability that allows code execution gives the attacker root privileges inside the container. Whilst namespaces and capabilities limit what root can do, defence in depth demands running as an unprivileged user.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> node:20-alpine</span>

<span class="c"># Create a non-root user</span>
<span class="k">RUN </span>addgroup <span class="nt">-g</span> 1001 <span class="nt">-S</span> appgroup <span class="o">&amp;&amp;</span> <span class="se">\
</span>    adduser <span class="nt">-S</span> appuser <span class="nt">-u</span> 1001 <span class="nt">-G</span> appgroup

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Change ownership of application files</span>
<span class="k">COPY</span><span class="s"> --chown=appuser:appgroup . .</span>

<span class="k">RUN </span>npm ci <span class="nt">--only</span><span class="o">=</span>production

<span class="c"># Switch to non-root user</span>
<span class="k">USER</span><span class="s"> appuser</span>

<span class="k">EXPOSE</span><span class="s"> 3000</span>

<span class="k">CMD</span><span class="s"> ["node", "server.js"]</span>
</code></pre></div></div>

<p>Some images require specific user IDs to match existing filesystem permissions or to integrate with orchestration platforms. Kubernetes, for example, can enforce that containers run as non-root:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Pod</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">secure-pod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">securityContext</span><span class="pi">:</span>
    <span class="na">runAsNonRoot</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">runAsUser</span><span class="pi">:</span> <span class="m">1001</span>
    <span class="na">runAsGroup</span><span class="pi">:</span> <span class="m">1001</span>
    <span class="na">fsGroup</span><span class="pi">:</span> <span class="m">1001</span>
  <span class="na">containers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">app</span>
      <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:latest</span>
      <span class="na">securityContext</span><span class="pi">:</span>
        <span class="na">allowPrivilegeEscalation</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">readOnlyRootFilesystem</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">capabilities</span><span class="pi">:</span>
          <span class="na">drop</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="s">ALL</span>
</code></pre></div></div>

<h3 id="read-only-root-filesystem">Read-Only Root Filesystem</h3>

<p>A read-only root filesystem prevents attackers from modifying binaries, dropping malware, or tampering with configuration. Any write attempts fail, limiting post-exploitation options.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run with read-only filesystem</span>
docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--read-only</span> <span class="se">\</span>
  <span class="nt">--tmpfs</span> /tmp:rw,noexec,nosuid,size<span class="o">=</span>64m <span class="se">\</span>
  <span class="nt">--tmpfs</span> /var/run:rw,noexec,nosuid,size<span class="o">=</span>16m <span class="se">\</span>
  myapp:latest
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--tmpfs</code> mounts provide writable temporary directories in memory for applications that need to write temporary files or PID files. The <code class="language-plaintext highlighter-rouge">noexec</code> option prevents execution of files in these directories.</p>

<p>Your Dockerfile should prepare for read-only operation:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> node:20-alpine</span>

<span class="k">RUN </span>adduser <span class="nt">-S</span> appuser

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> --chown=appuser . .</span>
<span class="k">RUN </span>npm ci <span class="nt">--only</span><span class="o">=</span>production

<span class="c"># Ensure no writes needed to root filesystem</span>
<span class="c"># Pre-create any directories the app expects to exist</span>
<span class="k">RUN </span><span class="nb">mkdir</span> <span class="nt">-p</span> /app/cache <span class="o">&amp;&amp;</span> <span class="nb">chown </span>appuser /app/cache

<span class="k">USER</span><span class="s"> appuser</span>

<span class="k">CMD</span><span class="s"> ["node", "server.js"]</span>
</code></pre></div></div>

<h3 id="resource-limits">Resource Limits</h3>

<p>Resource limits prevent denial-of-service attacks where a compromised container exhausts host resources. They also protect against accidental resource exhaustion from memory leaks or runaway processes.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--memory</span><span class="o">=</span><span class="s2">"256m"</span> <span class="se">\</span>
  <span class="nt">--memory-swap</span><span class="o">=</span><span class="s2">"256m"</span> <span class="se">\</span>
  <span class="nt">--memory-reservation</span><span class="o">=</span><span class="s2">"128m"</span> <span class="se">\</span>
  <span class="nt">--cpus</span><span class="o">=</span><span class="s2">"0.5"</span> <span class="se">\</span>
  <span class="nt">--cpu-shares</span><span class="o">=</span>512 <span class="se">\</span>
  <span class="nt">--pids-limit</span><span class="o">=</span>50 <span class="se">\</span>
  <span class="nt">--ulimit</span> <span class="nv">nofile</span><span class="o">=</span>1024:1024 <span class="se">\</span>
  <span class="nt">--ulimit</span> <span class="nb">nproc</span><span class="o">=</span>64:64 <span class="se">\</span>
  myapp:latest
</code></pre></div></div>

<p>In Kubernetes, specify resource requests and limits:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Pod</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">resource-limited-pod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">containers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">app</span>
      <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:latest</span>
      <span class="na">resources</span><span class="pi">:</span>
        <span class="na">requests</span><span class="pi">:</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">128Mi"</span>
          <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">250m"</span>
        <span class="na">limits</span><span class="pi">:</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
          <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">500m"</span>
</code></pre></div></div>

<h3 id="network-policies">Network Policies</h3>

<p>Network policies control which containers can communicate with each other and with external services. By default, all containers can communicate with all other containers—a compromised container can probe and attack anything on the network.</p>

<p>In Kubernetes, NetworkPolicy resources define allowed traffic:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">NetworkPolicy</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">api-network-policy</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">production</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">podSelector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">api</span>
  <span class="na">policyTypes</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">Ingress</span>
    <span class="pi">-</span> <span class="s">Egress</span>
  <span class="na">ingress</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">from</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">podSelector</span><span class="pi">:</span>
            <span class="na">matchLabels</span><span class="pi">:</span>
              <span class="na">app</span><span class="pi">:</span> <span class="s">frontend</span>
        <span class="pi">-</span> <span class="na">namespaceSelector</span><span class="pi">:</span>
            <span class="na">matchLabels</span><span class="pi">:</span>
              <span class="na">name</span><span class="pi">:</span> <span class="s">monitoring</span>
      <span class="na">ports</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
          <span class="na">port</span><span class="pi">:</span> <span class="m">8080</span>
  <span class="na">egress</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">to</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">podSelector</span><span class="pi">:</span>
            <span class="na">matchLabels</span><span class="pi">:</span>
              <span class="na">app</span><span class="pi">:</span> <span class="s">database</span>
      <span class="na">ports</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
          <span class="na">port</span><span class="pi">:</span> <span class="m">5432</span>
    <span class="pi">-</span> <span class="na">to</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">namespaceSelector</span><span class="pi">:</span> <span class="pi">{}</span>
          <span class="na">podSelector</span><span class="pi">:</span>
            <span class="na">matchLabels</span><span class="pi">:</span>
              <span class="na">k8s-app</span><span class="pi">:</span> <span class="s">kube-dns</span>
      <span class="na">ports</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">protocol</span><span class="pi">:</span> <span class="s">UDP</span>
          <span class="na">port</span><span class="pi">:</span> <span class="m">53</span>
</code></pre></div></div>

<p>This policy allows the API pod to receive traffic only from the frontend application and monitoring namespace, and to send traffic only to the database and DNS.</p>

<p>For Docker without Kubernetes, use Docker networks to isolate containers:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create isolated networks</span>
docker network create <span class="nt">--internal</span> backend-net
docker network create frontend-net

<span class="c"># Database only on internal network (no external access)</span>
docker run <span class="nt">-d</span> <span class="nt">--network</span> backend-net <span class="nt">--name</span> db postgres:15

<span class="c"># API on both networks</span>
docker run <span class="nt">-d</span> <span class="nt">--network</span> backend-net <span class="nt">--network</span> frontend-net <span class="nt">--name</span> api myapi:latest

<span class="c"># Frontend only on frontend network</span>
docker run <span class="nt">-d</span> <span class="nt">--network</span> frontend-net <span class="nt">-p</span> 80:80 <span class="nt">--name</span> web nginx:alpine
</code></pre></div></div>

<h3 id="runtime-threat-detection">Runtime Threat Detection</h3>

<p>Runtime security tools monitor container behaviour and alert on or block suspicious activity. They detect attacks that static scanning can’t catch—exploits against zero-day vulnerabilities, malicious insider activity, or attacks using legitimate tools.</p>

<p><strong>Falco</strong></p>

<p>Falco is an open-source runtime security tool that monitors syscalls and alerts on suspicious behaviour.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Example Falco rules</span>
<span class="pi">-</span> <span class="na">rule</span><span class="pi">:</span> <span class="s">Terminal Shell in Container</span>
  <span class="na">desc</span><span class="pi">:</span> <span class="s">Detect shell spawned in a container</span>
  <span class="na">condition</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">spawned_process and </span>
    <span class="s">container and </span>
    <span class="s">shell_procs and </span>
    <span class="s">proc.tty != 0</span>
  <span class="na">output</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">Shell spawned in container </span>
    <span class="s">(user=%user.name container=%container.name shell=%proc.name </span>
    <span class="s">parent=%proc.pname cmdline=%proc.cmdline)</span>
  <span class="na">priority</span><span class="pi">:</span> <span class="s">WARNING</span>
  <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">container</span><span class="pi">,</span> <span class="nv">shell</span><span class="pi">,</span> <span class="nv">mitre_execution</span><span class="pi">]</span>

<span class="pi">-</span> <span class="na">rule</span><span class="pi">:</span> <span class="s">Write Below Binary Dir</span>
  <span class="na">desc</span><span class="pi">:</span> <span class="s">Detect writes to binary directories</span>
  <span class="na">condition</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">open_write and </span>
    <span class="s">container and </span>
    <span class="s">bin_dir</span>
  <span class="na">output</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">Write to binary directory </span>
    <span class="s">(user=%user.name container=%container.name file=%fd.name)</span>
  <span class="na">priority</span><span class="pi">:</span> <span class="s">CRITICAL</span>
  <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">container</span><span class="pi">,</span> <span class="nv">filesystem</span><span class="pi">,</span> <span class="nv">mitre_persistence</span><span class="pi">]</span>

<span class="pi">-</span> <span class="na">rule</span><span class="pi">:</span> <span class="s">Outbound Connection to Unusual Port</span>
  <span class="na">desc</span><span class="pi">:</span> <span class="s">Detect outbound connections to non-standard ports</span>
  <span class="na">condition</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">outbound and </span>
    <span class="s">container and </span>
    <span class="s">not (fd.sport in (80, 443, 53, 8080, 8443, 5432, 6379, 3306))</span>
  <span class="na">output</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">Unusual outbound connection </span>
    <span class="s">(container=%container.name connection=%fd.name)</span>
  <span class="na">priority</span><span class="pi">:</span> <span class="s">NOTICE</span>
  <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">container</span><span class="pi">,</span> <span class="nv">network</span><span class="pi">,</span> <span class="nv">mitre_exfiltration</span><span class="pi">]</span>
</code></pre></div></div>

<p>Deploy Falco in Kubernetes:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">DaemonSet</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">falco</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">falco</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">falco</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">falco</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">serviceAccountName</span><span class="pi">:</span> <span class="s">falco</span>
      <span class="na">hostNetwork</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">hostPID</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">containers</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">falco</span>
          <span class="na">image</span><span class="pi">:</span> <span class="s">falcosecurity/falco:latest</span>
          <span class="na">securityContext</span><span class="pi">:</span>
            <span class="na">privileged</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">volumeMounts</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">dev</span>
              <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/host/dev</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">proc</span>
              <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/host/proc</span>
              <span class="na">readOnly</span><span class="pi">:</span> <span class="kc">true</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">etc</span>
              <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/host/etc</span>
              <span class="na">readOnly</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">volumes</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">dev</span>
          <span class="na">hostPath</span><span class="pi">:</span>
            <span class="na">path</span><span class="pi">:</span> <span class="s">/dev</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">proc</span>
          <span class="na">hostPath</span><span class="pi">:</span>
            <span class="na">path</span><span class="pi">:</span> <span class="s">/proc</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">etc</span>
          <span class="na">hostPath</span><span class="pi">:</span>
            <span class="na">path</span><span class="pi">:</span> <span class="s">/etc</span>
</code></pre></div></div>

<h2 id="secret-management">Secret Management</h2>

<p>Secrets in containers require careful handling. Environment variables, while convenient, can leak through logs, process listings, or debug endpoints. Build-time secrets must never persist in image layers. Runtime secrets should be injected securely and rotated regularly.</p>

<h3 id="build-time-secrets">Build-Time Secrets</h3>

<p>Docker BuildKit provides secure secret mounting during builds. Secrets are available only during the build step that mounts them and don’t persist in image layers.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># syntax=docker/dockerfile:1.4</span>
<span class="k">FROM</span><span class="s"> node:20-alpine</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> package*.json ./</span>

<span class="c"># Mount npm token as secret - only available during this RUN</span>
<span class="k">RUN </span><span class="nt">--mount</span><span class="o">=</span><span class="nb">type</span><span class="o">=</span>secret,id<span class="o">=</span>npm_token <span class="se">\
</span>    <span class="nv">NPM_TOKEN</span><span class="o">=</span><span class="si">$(</span><span class="nb">cat</span> /run/secrets/npm_token<span class="si">)</span> <span class="se">\
</span>    <span class="nb">echo</span> <span class="s2">"//registry.npmjs.org/:_authToken=</span><span class="k">${</span><span class="nv">NPM_TOKEN</span><span class="k">}</span><span class="s2">"</span> <span class="o">&gt;</span> .npmrc <span class="o">&amp;&amp;</span> <span class="se">\
</span>    npm ci <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> .npmrc

<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">RUN </span>npm run build

<span class="k">CMD</span><span class="s"> ["node", "dist/server.js"]</span>
</code></pre></div></div>

<p>Build with secrets:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Pass secret from environment variable</span>
<span class="nb">export </span><span class="nv">NPM_TOKEN</span><span class="o">=</span><span class="s2">"your-token-here"</span>
docker buildx build <span class="nt">--secret</span> <span class="nb">id</span><span class="o">=</span>npm_token,env<span class="o">=</span>NPM_TOKEN <span class="nt">-t</span> myapp:latest <span class="nb">.</span>

<span class="c"># Pass secret from file</span>
<span class="nb">echo</span> <span class="s2">"your-token-here"</span> <span class="o">&gt;</span> npm_token.txt
docker buildx build <span class="nt">--secret</span> <span class="nb">id</span><span class="o">=</span>npm_token,src<span class="o">=</span>npm_token.txt <span class="nt">-t</span> myapp:latest <span class="nb">.</span>
</code></pre></div></div>

<p>For SSH access to private repositories:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># syntax=docker/dockerfile:1.4</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.21</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Mount SSH agent for private repo access</span>
<span class="k">RUN </span><span class="nt">--mount</span><span class="o">=</span><span class="nb">type</span><span class="o">=</span>ssh <span class="se">\
</span>    <span class="nb">mkdir</span> <span class="nt">-p</span> ~/.ssh <span class="o">&amp;&amp;</span> <span class="se">\
</span>    ssh-keyscan github.com <span class="o">&gt;&gt;</span> ~/.ssh/known_hosts <span class="o">&amp;&amp;</span> <span class="se">\
</span>    go mod download

<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">RUN </span>go build <span class="nt">-o</span> myapp

<span class="k">FROM</span><span class="s"> gcr.io/distroless/static</span>
<span class="k">COPY</span><span class="s"> --from=builder /app/myapp /</span>
<span class="k">CMD</span><span class="s"> ["/myapp"]</span>
</code></pre></div></div>

<p>Build with SSH forwarding:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker buildx build <span class="nt">--ssh</span> default <span class="nt">-t</span> myapp:latest <span class="nb">.</span>
</code></pre></div></div>

<h3 id="runtime-secrets">Runtime Secrets</h3>

<p>For runtime secrets, avoid environment variables where possible. Use mounted files or integrate with secret management systems.</p>

<p><strong>Docker Secrets (Swarm)</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create a secret</span>
<span class="nb">echo</span> <span class="s2">"supersecretpassword"</span> | docker secret create db_password -

<span class="c"># Use in service</span>
docker service create <span class="se">\</span>
  <span class="nt">--name</span> api <span class="se">\</span>
  <span class="nt">--secret</span> db_password <span class="se">\</span>
  myapp:latest
</code></pre></div></div>

<p>Inside the container, the secret appears at <code class="language-plaintext highlighter-rouge">/run/secrets/db_password</code>.</p>

<p><strong>Kubernetes Secrets</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Secret</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">app-secrets</span>
<span class="na">type</span><span class="pi">:</span> <span class="s">Opaque</span>
<span class="na">stringData</span><span class="pi">:</span>
  <span class="na">database-password</span><span class="pi">:</span> <span class="s2">"</span><span class="s">supersecretpassword"</span>
  <span class="na">api-key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">sk-1234567890"</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Pod</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">app</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">containers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">app</span>
      <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:latest</span>
      <span class="na">volumeMounts</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">secrets</span>
          <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/etc/secrets</span>
          <span class="na">readOnly</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">volumes</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">secrets</span>
      <span class="na">secret</span><span class="pi">:</span>
        <span class="na">secretName</span><span class="pi">:</span> <span class="s">app-secrets</span>
</code></pre></div></div>

<p><strong>External Secret Stores</strong></p>

<p>For production, integrate with external secret stores like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Using External Secrets Operator with AWS Secrets Manager</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">external-secrets.io/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ExternalSecret</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">app-secrets</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">refreshInterval</span><span class="pi">:</span> <span class="s">1h</span>
  <span class="na">secretStoreRef</span><span class="pi">:</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterSecretStore</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">aws-secrets-manager</span>
  <span class="na">target</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">app-secrets</span>
  <span class="na">data</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">secretKey</span><span class="pi">:</span> <span class="s">database-password</span>
      <span class="na">remoteRef</span><span class="pi">:</span>
        <span class="na">key</span><span class="pi">:</span> <span class="s">production/database</span>
        <span class="na">property</span><span class="pi">:</span> <span class="s">password</span>
    <span class="pi">-</span> <span class="na">secretKey</span><span class="pi">:</span> <span class="s">api-key</span>
      <span class="na">remoteRef</span><span class="pi">:</span>
        <span class="na">key</span><span class="pi">:</span> <span class="s">production/api</span>
        <span class="na">property</span><span class="pi">:</span> <span class="s">key</span>
</code></pre></div></div>

<h2 id="cicd-security-integration">CI/CD Security Integration</h2>

<p>Security must be automated into your CI/CD pipeline. Manual security reviews don’t scale, and security gates that slow down deployment get bypassed. Build security into the pipeline so it happens automatically on every build.</p>

<h3 id="comprehensive-security-pipeline">Comprehensive Security Pipeline</h3>

<p>Here’s a GitHub Actions workflow that integrates vulnerability scanning, SBOM generation, and image signing:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Secure Container Build</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">v*'</span><span class="pi">]</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">REGISTRY</span><span class="pi">:</span> <span class="s">ghcr.io</span>
  <span class="na">IMAGE_NAME</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">security-scan</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">security-events</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run Trivy vulnerability scanner (filesystem)</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">aquasecurity/trivy-action@master</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">scan-type</span><span class="pi">:</span> <span class="s1">'</span><span class="s">fs'</span>
          <span class="na">scan-ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">.'</span>
          <span class="na">format</span><span class="pi">:</span> <span class="s1">'</span><span class="s">sarif'</span>
          <span class="na">output</span><span class="pi">:</span> <span class="s1">'</span><span class="s">trivy-fs-results.sarif'</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload Trivy scan results to GitHub Security</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">github/codeql-action/upload-sarif@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">sarif_file</span><span class="pi">:</span> <span class="s1">'</span><span class="s">trivy-fs-results.sarif'</span>

  <span class="na">build-and-push</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">security-scan</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
      <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>  <span class="c1"># For keyless signing</span>
      <span class="na">security-events</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">digest</span><span class="pi">:</span> <span class="s">$</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract metadata</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">meta</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/metadata-action@v5</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">images</span><span class="pi">:</span> <span class="s">$/$</span>
          <span class="na">tags</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">type=ref,event=branch</span>
            <span class="s">type=ref,event=pr</span>
            <span class="s">type=semver,pattern=</span>
            <span class="s">type=semver,pattern=.</span>
            <span class="s">type=sha</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">build</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/build-push-action@v5</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>
          <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">tags</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">labels</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">cache-from</span><span class="pi">:</span> <span class="s">type=gha</span>
          <span class="na">cache-to</span><span class="pi">:</span> <span class="s">type=gha,mode=max</span>
          <span class="na">provenance</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">sbom</span><span class="pi">:</span> <span class="kc">true</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run Trivy vulnerability scanner (image)</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">aquasecurity/trivy-action@master</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">image-ref</span><span class="pi">:</span> <span class="s">$/$:$</span>
          <span class="na">format</span><span class="pi">:</span> <span class="s1">'</span><span class="s">sarif'</span>
          <span class="na">output</span><span class="pi">:</span> <span class="s1">'</span><span class="s">trivy-image-results.sarif'</span>
          <span class="na">severity</span><span class="pi">:</span> <span class="s1">'</span><span class="s">CRITICAL,HIGH'</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload Trivy scan results</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">github/codeql-action/upload-sarif@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">sarif_file</span><span class="pi">:</span> <span class="s1">'</span><span class="s">trivy-image-results.sarif'</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>

  <span class="na">sign-image</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build-and-push</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
      <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install Cosign</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">sigstore/cosign-installer@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Sign the image with Cosign</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DIGEST</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">cosign sign --yes $/$@${DIGEST}</span>

  <span class="na">generate-sbom</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build-and-push</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name != 'pull_request'</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">packages</span><span class="pi">:</span> <span class="s">write</span>
    
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install Syft</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">anchore/sbom-action/download-syft@v0</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Container Registry</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/login-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">registry</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">username</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">password</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Generate SBOM</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DIGEST</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">syft $/$@${DIGEST} -o spdx-json &gt; sbom.spdx.json</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload SBOM as artifact</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">sbom</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">sbom.spdx.json</span>
</code></pre></div></div>

<h3 id="security-gates">Security Gates</h3>

<p>Define security thresholds that must be met before images can be deployed:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Add to your build job</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Check vulnerability threshold</span>
  <span class="na">uses</span><span class="pi">:</span> <span class="s">aquasecurity/trivy-action@master</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">image-ref</span><span class="pi">:</span> <span class="s">$/$:$</span>
    <span class="na">format</span><span class="pi">:</span> <span class="s1">'</span><span class="s">table'</span>
    <span class="na">exit-code</span><span class="pi">:</span> <span class="s1">'</span><span class="s">1'</span>  <span class="c1"># Fail the build</span>
    <span class="na">severity</span><span class="pi">:</span> <span class="s1">'</span><span class="s">CRITICAL'</span>  <span class="c1"># Only fail on critical vulnerabilities</span>
    <span class="na">ignore-unfixed</span><span class="pi">:</span> <span class="kc">true</span>  <span class="c1"># Ignore vulnerabilities without fixes</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Verify image signature before deployment</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">cosign verify \</span>
      <span class="s">--certificate-identity-regexp="https://github.com/$/*" \</span>
      <span class="s">--certificate-oidc-issuer=https://token.actions.githubusercontent.com \</span>
      <span class="s">$/$@$</span>
</code></pre></div></div>

<h3 id="admission-control">Admission Control</h3>

<p>In Kubernetes, use admission controllers to enforce security policies. Kyverno and OPA Gatekeeper can verify image signatures, enforce security contexts, and block non-compliant deployments.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Kyverno policy requiring signed images</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">kyverno.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterPolicy</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">require-signed-images</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">validationFailureAction</span><span class="pi">:</span> <span class="s">Enforce</span>
  <span class="na">background</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">verify-signature</span>
      <span class="na">match</span><span class="pi">:</span>
        <span class="na">any</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">resources</span><span class="pi">:</span>
              <span class="na">kinds</span><span class="pi">:</span>
                <span class="pi">-</span> <span class="s">Pod</span>
      <span class="na">verifyImages</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">imageReferences</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">ghcr.io/myorg/*"</span>
          <span class="na">attestors</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">entries</span><span class="pi">:</span>
                <span class="pi">-</span> <span class="na">keyless</span><span class="pi">:</span>
                    <span class="na">issuer</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://token.actions.githubusercontent.com"</span>
                    <span class="na">subject</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://github.com/myorg/*"</span>
<span class="nn">---</span>
<span class="c1"># Policy enforcing security contexts</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">kyverno.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterPolicy</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">require-security-context</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">validationFailureAction</span><span class="pi">:</span> <span class="s">Enforce</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">require-non-root</span>
      <span class="na">match</span><span class="pi">:</span>
        <span class="na">any</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">resources</span><span class="pi">:</span>
              <span class="na">kinds</span><span class="pi">:</span>
                <span class="pi">-</span> <span class="s">Pod</span>
      <span class="na">validate</span><span class="pi">:</span>
        <span class="na">message</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Containers</span><span class="nv"> </span><span class="s">must</span><span class="nv"> </span><span class="s">run</span><span class="nv"> </span><span class="s">as</span><span class="nv"> </span><span class="s">non-root"</span>
        <span class="na">pattern</span><span class="pi">:</span>
          <span class="na">spec</span><span class="pi">:</span>
            <span class="na">securityContext</span><span class="pi">:</span>
              <span class="na">runAsNonRoot</span><span class="pi">:</span> <span class="kc">true</span>
            <span class="na">containers</span><span class="pi">:</span>
              <span class="pi">-</span> <span class="na">securityContext</span><span class="pi">:</span>
                  <span class="na">allowPrivilegeEscalation</span><span class="pi">:</span> <span class="kc">false</span>
                  <span class="na">capabilities</span><span class="pi">:</span>
                    <span class="na">drop</span><span class="pi">:</span>
                      <span class="pi">-</span> <span class="s">ALL</span>
</code></pre></div></div>

<h2 id="security-checklist">Security Checklist</h2>

<p>Here’s a comprehensive checklist for container security. Use it as a starting point and adapt it to your specific requirements:</p>

<h3 id="image-security-1">Image Security</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use minimal base images (distroless, Alpine, or scratch)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Pin base image versions with digests, not just tags</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Scan images for vulnerabilities in CI/CD</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Generate and store SBOMs for all images</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Sign images with Cosign or similar</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use multi-stage builds to exclude build tools</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Remove unnecessary packages and files</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Don’t install debugging tools in production images</li>
</ul>

<h3 id="runtime-security-1">Runtime Security</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Run containers as non-root users</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Drop all capabilities and add only what’s needed</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use read-only root filesystems</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Set resource limits (memory, CPU, PIDs)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Apply seccomp profiles</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Disable privilege escalation</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use network policies to restrict communication</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Deploy runtime threat detection (Falco)</li>
</ul>

<h3 id="secret-management-1">Secret Management</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Never commit secrets to version control</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use BuildKit secrets for build-time credentials</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Mount secrets as files, not environment variables</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Integrate with external secret stores</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Rotate secrets regularly</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Audit secret access</li>
</ul>

<h3 id="cicd-security">CI/CD Security</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Scan code and dependencies before building</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Scan images after building</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Sign images in CI/CD pipeline</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Verify signatures before deployment</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use admission controllers to enforce policies</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Implement security gates with severity thresholds</li>
</ul>

<h3 id="infrastructure-security">Infrastructure Security</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Keep container runtime updated</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Keep orchestration platform updated</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Use private registries with authentication</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Enable audit logging</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Monitor and alert on security events</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Regularly review and update security policies</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Container security isn’t a single tool or practice—it’s a comprehensive approach that spans the entire lifecycle from base image selection through runtime monitoring. The shared kernel model means that container isolation, while effective, is not equivalent to the stronger isolation of virtual machines. Defence in depth is essential.</p>

<p>Start with the fundamentals: understand namespaces, cgroups, and capabilities. Choose minimal base images that reduce attack surface. Scan for vulnerabilities and generate SBOMs so you can respond quickly when new CVEs emerge. Run as non-root with minimal capabilities. Apply network policies to limit blast radius.</p>

<p>Automate security into your CI/CD pipeline so it happens consistently on every build. Sign your images to prove provenance. Use admission controllers to enforce policies at deployment time. Deploy runtime security tools to detect attacks that static analysis can’t catch.</p>

<p>Container security is an ongoing practice, not a one-time implementation. Vulnerabilities are discovered daily. Attackers evolve their techniques. Your security posture must evolve with them. Regular audits, updated policies, and continuous monitoring ensure your containerised applications remain protected as the threat landscape changes.</p>

<p>The investment in container security pays dividends in reduced risk, faster incident response, and confidence that your applications are protected by multiple layers of defence. You’ve built a foundation for secure containerised applications that will serve you well as your deployment grows.</p>]]></content><author><name>Glen Thomas</name></author><category term="Platform Engineering" /><category term="Software Engineering" /><category term="Docker" /><category term="Container Images" /><category term="Security" /><category term="Kubernetes" /><category term="DevSecOps" /><summary type="html"><![CDATA[Containers have fundamentally changed how we build and deploy applications, but they’ve also introduced new security considerations that many teams struggle to address comprehensively. The speed and convenience of containers can lull organisations into a false sense of security, treating them as lightweight virtual machines without understanding the fundamentally different security model they represent.]]></summary></entry><entry><title type="html">Building a Hub and Spoke Network Topology in Azure: A Comprehensive Guide</title><link href="https://blog.glen-thomas.com/platform%20engineering/2025/10/15/building-a-hub-and-spoke-network-topology-in-azure.html" rel="alternate" type="text/html" title="Building a Hub and Spoke Network Topology in Azure: A Comprehensive Guide" /><published>2025-10-15T22:51:00+01:00</published><updated>2025-10-15T22:51:00+01:00</updated><id>https://blog.glen-thomas.com/platform%20engineering/2025/10/15/building-a-hub-and-spoke-network-topology-in-azure</id><content type="html" xml:base="https://blog.glen-thomas.com/platform%20engineering/2025/10/15/building-a-hub-and-spoke-network-topology-in-azure.html"><![CDATA[<p>Network architecture is one of those foundational decisions that shapes everything you build on top of it. Get it right at the start, and you’ll have a scalable, secure platform that grows with your organisation. Get it wrong, and you’ll spend years fighting technical debt, security vulnerabilities, and operational complexity that compounds with every new workload you deploy.</p>

<p>For Azure-based platforms, the hub and spoke topology has emerged as the de facto standard for enterprise network design, and for good reason. It provides the perfect balance between isolation and connectivity, centralises security controls without creating bottlenecks, and scales elegantly as your organisation grows from a handful of applications to hundreds of workloads across multiple regions.</p>

<p>In this comprehensive guide, I’ll walk you through building a production-ready hub and spoke network topology in Azure using Terraform. We’ll explore the architectural principles that make this pattern so effective, implement the infrastructure with security best practices embedded at every layer, and address the operational considerations that separate toy examples from production-ready platforms.</p>

<h2 id="understanding-the-hub-and-spoke-pattern">Understanding the Hub and Spoke Pattern</h2>

<p>Before we write a single line of Terraform, it’s worth understanding why this architectural pattern exists and what problems it solves. The hub and spoke topology isn’t just about drawing circles and lines on architecture diagrams, it’s a deliberate approach to managing complexity whilst maintaining security and operational efficiency.</p>

<h3 id="the-problem-with-flat-networks">The Problem with Flat Networks</h3>

<p>In the early days of cloud adoption, many organisations built flat network topologies where every virtual network could communicate with every other virtual network through full-mesh peering. For three or four VNets, this works fine. By the time you reach ten VNets, you’re managing 45 peering connections. At twenty VNets, that number balloons to 190 peering connections.</p>

<p>Beyond the sheer management overhead, flat topologies create security challenges. Every VNet becomes a potential attack vector for every other VNet. Network security groups become impossibly complex as you try to maintain granular control over which applications can communicate. Audit trails become muddled as traffic flows directly between workloads without passing through central inspection points.</p>

<h3 id="the-hub-and-spoke-solution">The Hub and Spoke Solution</h3>

<p>The hub and spoke pattern solves these problems through centralisation. Instead of every VNet peering with every other VNet, spoke VNets peer only with a central hub VNet. The hub contains shared services; firewalls, VPN gateways, DNS servers, monitoring infrastructure, that all spokes can access. Traffic between spokes flows through the hub, where it can be inspected, logged, and controlled.</p>

<p>This topology dramatically reduces complexity. Twenty spoke VNets require only twenty peering connections to the hub, rather than 190 in a mesh topology. Security controls centralise in the hub, making them easier to maintain and audit. Network changes in one spoke don’t affect others unless you explicitly allow it.</p>

<p>The pattern also aligns beautifully with organisational structure. Each spoke can represent a different team, application, or environment. Development teams get their own isolated network space whilst platform engineering maintains central control over security policy, connectivity to on-premises networks, and shared infrastructure.</p>

<h3 id="when-to-use-this-pattern">When to Use This Pattern</h3>

<p>Hub and spoke topology makes sense when you have multiple workloads that need some degree of isolation from each other but also require shared services or connectivity to external networks. This describes most enterprise scenarios.</p>

<p>If you’re building a simple proof of concept with a single application in a single VNet, hub and spoke is overkill. But the moment you start thinking about multiple environments, multiple teams, or connections to on-premises networks, this pattern starts paying dividends. The initial investment in building the hub pays off quickly as you add spokes without increasing complexity.</p>

<h2 id="architectural-design-decisions">Architectural Design Decisions</h2>

<p>Every hub and spoke implementation requires making deliberate choices about how to structure the network, secure traffic, and handle connectivity. These decisions shape everything from IP address allocation to firewall rules to routing configuration.</p>

<h3 id="ip-address-planning">IP Address Planning</h3>

<p>IP address planning is one of those tasks that feels tedious until you get it wrong, at which point it becomes a nightmare. Once workloads are deployed and using IP addresses, changing them requires downtime, coordination, and significant risk.</p>

<p>The cardinal rule is to be generous with address space allocation. Azure supports RFC 1918 private address ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and there’s no cost to reserving large blocks. I recommend allocating a /16 (65,536 addresses) for the hub and at least a /20 (4,096 addresses) for each spoke, with room to grow.</p>

<p>Within the hub, subdivide address space by function. Allocate a subnet for Azure Firewall, another for VPN Gateway, another for Azure Bastion, and so on. Each Azure service has specific subnet sizing requirements; Azure Firewall needs at least a /26, VPN Gateway needs at least a /27, Azure Bastion needs at least a /26. Factor in future growth when sizing these subnets.</p>

<p>For spoke VNets, organise subnets by tier or function. Web tier gets a subnet, application tier gets another, data tier gets a third. Keep subnet sizes consistent across spokes where possible—it makes automation easier and helps with capacity planning.</p>

<p>Document your IP address allocation scheme from the start. Future you, six months from now, will thank present you for writing down which address ranges are allocated to which environments and teams.</p>

<h3 id="hub-vnet-design">Hub VNet Design</h3>

<p>The hub VNet serves as the central point of connectivity for your entire network topology. It needs to accommodate several distinct functions, each with its own subnet requirements.</p>

<p>Azure Firewall sits at the heart of the hub, inspecting all traffic between spokes and controlling outbound internet access. It requires a dedicated subnet named <code class="language-plaintext highlighter-rouge">AzureFirewallSubnet</code> with a minimum size of /26, though /24 gives you more room for scale.</p>

<p>If you’re connecting to on-premises networks via VPN or ExpressRoute, you’ll need a Gateway subnet. VPN Gateway and ExpressRoute Gateway both require a subnet named <code class="language-plaintext highlighter-rouge">GatewaySubnet</code> with a minimum size of /27, though /26 is recommended for production deployments.</p>

<p>Azure Bastion provides secure RDP and SSH access to virtual machines without exposing them to the public internet. It needs a subnet named <code class="language-plaintext highlighter-rouge">AzureBastionSubnet</code> with a minimum size of /26.</p>

<p>For shared services like jump boxes, monitoring infrastructure, or centralised DNS servers, create additional subnets sized appropriately for your needs. A /27 or /28 often suffices for these management workloads.</p>

<h3 id="spoke-vnet-design">Spoke VNet Design</h3>

<p>Spoke VNets contain your actual workloads; the applications, databases, and services that deliver value to your organisation. Design them with isolation and security in mind.</p>

<p>Each spoke should map to a clear organisational or technical boundary. You might create spokes per environment (development, staging, production), per team (platform, data, mobile), per application tier (frontend, backend, data), or some combination. The key is consistency; establish a pattern and stick to it.</p>

<p>Within each spoke, use subnets to create security boundaries. Don’t put web servers and databases in the same subnet. Subnet boundaries allow you to apply network security groups that control which traffic is allowed. A compromised web server in its own subnet can be prevented from accessing database servers in a different subnet.</p>

<p>Consider creating separate subnets for private endpoints if you’re using Azure PaaS services like Storage Accounts or SQL Databases with private connectivity. This makes it easier to manage DNS resolution and network security group rules for these endpoints.</p>

<h3 id="routing-architecture">Routing Architecture</h3>

<p>In a hub and spoke topology, routing determines how traffic flows between spokes. By default, spoke-to-spoke traffic doesn’t work even with peering configured; Azure VNet peering is non-transitive. If Spoke A peers with the Hub, and Spoke B peers with the Hub, traffic from Spoke A cannot reach Spoke B without additional configuration.</p>

<p>This is actually a security feature. It means you explicitly control which spokes can communicate, rather than allowing all-to-all communication by default.</p>

<p>To enable spoke-to-spoke communication, you have two options. The simpler approach uses Azure Firewall as a router. Configure user-defined routes (UDRs) in each spoke that send traffic destined for other spoke address ranges to the Azure Firewall. The firewall inspects the traffic and forwards it to the destination spoke.</p>

<p>The alternative approach deploys a network virtual appliance (NVA) in the hub for routing, but for most scenarios, Azure Firewall provides sufficient routing capabilities alongside its security functions. Using a single service for both simplifies management and reduces cost.</p>

<h3 id="connectivity-options">Connectivity Options</h3>

<p>Your hub and spoke topology likely needs connectivity beyond Azure. On-premises datacentres, remote offices, and mobile workers all need secure access to cloud resources.</p>

<p>For site-to-site connectivity to on-premises networks, you’ll choose between VPN Gateway and ExpressRoute. VPN Gateway provides encrypted IPsec tunnels over the public internet and supports throughput up to 10 Gbps with the VpnGw5 SKU. It’s cost-effective and suitable for most scenarios.</p>

<p>ExpressRoute provides private connectivity that doesn’t traverse the public internet, with dedicated bandwidth from 50 Mbps to 100 Gbps. It costs more but provides better performance, reliability, and security for mission-critical workloads. Many organisations use both; ExpressRoute for primary connectivity and VPN Gateway as a failover path.</p>

<p>For remote user access, Azure Bastion provides secure RDP and SSH connectivity without exposing virtual machines to the internet. It eliminates the need for jump boxes with public IP addresses and provides audit logging of all remote access sessions.</p>

<h3 id="visual-architecture">Visual Architecture</h3>

<p>To help visualise how all these components fit together, here’s a diagram showing a typical hub and spoke topology with three spoke VNets:</p>

<div class="mermaid-container" style="background: white; padding: 20px; border-radius: 8px; margin: 2em 0; cursor: pointer;" onclick="this.requestFullscreen()">
<pre class="mermaid">
graph TB
    subgraph Internet["Internet"]
        OnPrem["On-Premises Network<br />192.168.0.0/16"]
        Users["Remote Users"]
    end

    subgraph Hub["Hub VNet<br />10.0.0.0/16"]
        subgraph FirewallSubnet["AzureFirewallSubnet<br />10.0.0.0/24"]
            AFW["Azure Firewall<br />10.0.0.4"]
        end
        
        subgraph GatewaySubnet["GatewaySubnet<br />10.0.1.0/24"]
            VPN["VPN Gateway"]
        end
        
        subgraph BastionSubnet["AzureBastionSubnet<br />10.0.2.0/24"]
            Bastion["Azure Bastion"]
        end
        
        subgraph ManagementSubnet["Management Subnet<br />10.0.3.0/24"]
            Monitor["Monitoring<br />Infrastructure"]
        end
    end

    subgraph Spoke1["Production Spoke<br />10.1.0.0/16"]
        subgraph WebSubnet1["Web Subnet<br />10.1.1.0/24"]
            Web1["Web Servers"]
        end
        subgraph AppSubnet1["App Subnet<br />10.1.2.0/24"]
            App1["Application<br />Servers"]
        end
        subgraph DataSubnet1["Data Subnet<br />10.1.3.0/24"]
            DB1["Databases"]
        end
        RT1["Route Table"]
    end

    subgraph Spoke2["Development Spoke<br />10.2.0.0/16"]
        subgraph WorkloadSubnet2["Workload Subnet<br />10.2.1.0/24"]
            Workload2["Development<br />Workloads"]
        end
        RT2["Route Table"]
    end

    subgraph Spoke3["Shared Services Spoke<br />10.3.0.0/16"]
        subgraph ServicesSubnet3["Services Subnet<br />10.3.1.0/24"]
            DNS["DNS Servers"]
            AD["Active Directory"]
        end
        RT3["Route Table"]
    end

    OnPrem -.-&gt;|"VPN Tunnel<br />IPsec"| VPN
    Users -.-&gt;|"RDP/SSH"| Bastion
    
    VPN --&gt;|"Peering"| AFW
    Bastion -.-&gt;|"Secure Access"| Web1
    Bastion -.-&gt;|"Secure Access"| Workload2
    
    AFW &lt;--&gt;|"VNet Peering<br />Allow Gateway Transit"| Spoke1
    AFW &lt;--&gt;|"VNet Peering<br />Allow Gateway Transit"| Spoke2
    AFW &lt;--&gt;|"VNet Peering<br />Allow Gateway Transit"| Spoke3
    
    RT1 -.-&gt;|"0.0.0.0/0 → Firewall<br />10.2.0.0/16 → Firewall<br />10.3.0.0/16 → Firewall"| AFW
    RT2 -.-&gt;|"0.0.0.0/0 → Firewall<br />10.1.0.0/16 → Firewall<br />10.3.0.0/16 → Firewall"| AFW
    RT3 -.-&gt;|"0.0.0.0/0 → Firewall<br />10.1.0.0/16 → Firewall<br />10.2.0.0/16 → Firewall"| AFW
    
    Web1 --&gt;|"App Traffic"| App1
    App1 --&gt;|"Database<br />Queries"| DB1
    
    Workload2 -.-&gt;|"Via Firewall"| DNS
    App1 -.-&gt;|"Via Firewall"| DNS
    
    AFW --&gt;|"Inspect &amp; Log"| Internet
    
    Monitor -.-&gt;|"Collect Logs"| AFW
    Monitor -.-&gt;|"Collect Logs"| VPN
    Monitor -.-&gt;|"Collect Logs"| Bastion

    classDef hubStyle fill:#0078d4,stroke:#003d7a,stroke-width:2px,color:#fff
    classDef spokeStyle fill:#50e6ff,stroke:#0078d4,stroke-width:2px,color:#000
    classDef securityStyle fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff
    classDef gatewayStyle fill:#69db7c,stroke:#2b8a3e,stroke-width:2px,color:#000
    
    class AFW securityStyle
    class VPN,Bastion gatewayStyle
    class Spoke1,Spoke2,Spoke3 spokeStyle
```
</pre>
</div>

<p style="text-align: center; font-size: 0.9em; color: #666; margin-top: -1em;"><em>Click the diagram to view fullscreen</em></p>

<p>This diagram illustrates several key architectural points. The hub sits at the centre with four distinct subnets, each serving a specific purpose. Azure Firewall acts as the central inspection point for all inter-spoke traffic and internet-bound traffic. VPN Gateway provides the encrypted tunnel to on-premises networks, whilst Azure Bastion offers secure administrative access without exposing VMs to the internet.</p>

<p>Each spoke VNet peers directly with the hub but not with other spokes. The route tables in each spoke contain user-defined routes that send traffic destined for other spokes through the Azure Firewall’s private IP address. This ensures all spoke-to-spoke communication flows through the firewall where it can be inspected, logged, and controlled by centralised firewall policies.</p>

<p>Notice how the production spoke uses a traditional three-tier subnet design (web, application, data), whilst the development spoke has a simpler structure with a single workload subnet. The shared services spoke contains infrastructure used by multiple teams, like DNS servers and Active Directory. This flexibility in spoke design is one of the pattern’s strengths—each spoke can be structured according to its specific requirements whilst maintaining consistent connectivity and security through the hub.</p>

<h2 id="infrastructure-as-code-with-terraform">Infrastructure as Code with Terraform</h2>

<p>Now let’s translate these architectural principles into actual infrastructure. We’ll build this incrementally, starting with the hub and progressively adding spokes and connectivity.</p>

<h3 id="foundation-and-variables">Foundation and Variables</h3>

<p>Start by defining the variables that will drive your configuration. Create <code class="language-plaintext highlighter-rouge">variables.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"environment"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Environment name (e.g., prod, staging, dev)"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"location"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Primary Azure region"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="s2">"uksouth"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"organisation"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Organisation name for resource naming"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"hub_vnet_address_space"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Address space for hub VNet"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">[</span><span class="s2">"10.0.0.0/16"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"spoke_vnets"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of spoke VNets to create"</span>
  <span class="nx">type</span> <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
    <span class="nx">address_space</span> <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
    <span class="nx">subnets</span> <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
      <span class="nx">address_prefix</span> <span class="o">=</span> <span class="nx">string</span>
      <span class="nx">service_endpoints</span> <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">),</span> <span class="p">[])</span>
      <span class="nx">delegation</span> <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
        <span class="nx">name</span> <span class="o">=</span> <span class="nx">string</span>
        <span class="nx">service_delegation</span> <span class="o">=</span> <span class="nx">object</span><span class="p">({</span>
          <span class="nx">name</span>    <span class="o">=</span> <span class="nx">string</span>
          <span class="nx">actions</span> <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">),</span> <span class="p">[])</span>
        <span class="p">})</span>
      <span class="p">}))</span>
    <span class="p">}))</span>
  <span class="p">}))</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"enable_vpn_gateway"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Whether to deploy VPN Gateway in hub"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">bool</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"enable_bastion"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Whether to deploy Azure Bastion in hub"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">bool</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"on_premises_address_spaces"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Address spaces of on-premises networks for VPN"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">[]</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"tags"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Tags to apply to all resources"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Create <code class="language-plaintext highlighter-rouge">locals.tf</code> for computed values:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">locals</span> <span class="p">{</span>
  <span class="c1"># Common tags applied to all resources</span>
  <span class="nx">common_tags</span> <span class="o">=</span> <span class="nx">merge</span><span class="p">(</span>
    <span class="nx">var</span><span class="p">.</span><span class="nx">tags</span><span class="p">,</span>
    <span class="p">{</span>
      <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
      <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
      <span class="nx">pattern</span>     <span class="o">=</span> <span class="s2">"hub-and-spoke"</span>
    <span class="p">}</span>
  <span class="p">)</span>

  <span class="c1"># Resource naming convention</span>
  <span class="nx">hub_name</span> <span class="o">=</span> <span class="s2">"hub-${var.environment}-${var.location}"</span>
  
  <span class="c1"># Hub subnet configuration</span>
  <span class="nx">hub_subnets</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">firewall</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">name</span>           <span class="o">=</span> <span class="s2">"AzureFirewallSubnet"</span>
      <span class="nx">address_prefix</span> <span class="o">=</span> <span class="nx">cidrsubnet</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="nx">gateway</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">name</span>           <span class="o">=</span> <span class="s2">"GatewaySubnet"</span>
      <span class="nx">address_prefix</span> <span class="o">=</span> <span class="nx">cidrsubnet</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="nx">bastion</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">name</span>           <span class="o">=</span> <span class="s2">"AzureBastionSubnet"</span>
      <span class="nx">address_prefix</span> <span class="o">=</span> <span class="nx">cidrsubnet</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="nx">management</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">name</span>           <span class="o">=</span> <span class="s2">"snet-management"</span>
      <span class="nx">address_prefix</span> <span class="o">=</span> <span class="nx">cidrsubnet</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="hub-virtual-network">Hub Virtual Network</h3>

<p>Create the hub VNet with all its subnets. Create <code class="language-plaintext highlighter-rouge">hub.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Resource group for hub networking</span>
<span class="nx">resource</span> <span class="s2">"azurerm_resource_group"</span> <span class="s2">"hub"</span> <span class="p">{</span>
  <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"rg-network-${local.hub_name}"</span>
  <span class="nx">location</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">tags</span>     <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Hub virtual network</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network"</span> <span class="s2">"hub"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"vnet-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">address_space</span>       <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_address_space</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Hub subnets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_subnet"</span> <span class="s2">"hub_firewall"</span> <span class="p">{</span>
  <span class="nx">name</span>                 <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">firewall</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_name</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">address_prefixes</span>     <span class="o">=</span> <span class="p">[</span><span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">firewall</span><span class="p">.</span><span class="nx">address_prefix</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"azurerm_subnet"</span> <span class="s2">"hub_gateway"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                 <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">gateway</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_name</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">address_prefixes</span>     <span class="o">=</span> <span class="p">[</span><span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">gateway</span><span class="p">.</span><span class="nx">address_prefix</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"azurerm_subnet"</span> <span class="s2">"hub_bastion"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_bastion</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                 <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">bastion</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_name</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">address_prefixes</span>     <span class="o">=</span> <span class="p">[</span><span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">bastion</span><span class="p">.</span><span class="nx">address_prefix</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"azurerm_subnet"</span> <span class="s2">"hub_management"</span> <span class="p">{</span>
  <span class="nx">name</span>                 <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">management</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_name</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">address_prefixes</span>     <span class="o">=</span> <span class="p">[</span><span class="nx">local</span><span class="p">.</span><span class="nx">hub_subnets</span><span class="p">.</span><span class="nx">management</span><span class="p">.</span><span class="nx">address_prefix</span><span class="p">]</span>

  <span class="nx">service_endpoints</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"Microsoft.Storage"</span><span class="p">,</span>
    <span class="s2">"Microsoft.KeyVault"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Network security group for management subnet</span>
<span class="nx">resource</span> <span class="s2">"azurerm_network_security_group"</span> <span class="s2">"hub_management"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"nsg-${local.hub_name}-management"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># NSG association</span>
<span class="nx">resource</span> <span class="s2">"azurerm_subnet_network_security_group_association"</span> <span class="s2">"hub_management"</span> <span class="p">{</span>
  <span class="nx">subnet_id</span>                 <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">hub_management</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">network_security_group_id</span> <span class="o">=</span> <span class="nx">azurerm_network_security_group</span><span class="p">.</span><span class="nx">hub_management</span><span class="p">.</span><span class="nx">id</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="azure-firewall">Azure Firewall</h3>

<p>Azure Firewall provides the central security control point for the topology. Create <code class="language-plaintext highlighter-rouge">firewall.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Public IP for Azure Firewall</span>
<span class="nx">resource</span> <span class="s2">"azurerm_public_ip"</span> <span class="s2">"firewall"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"pip-firewall-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">allocation_method</span>   <span class="o">=</span> <span class="s2">"Static"</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"Standard"</span>
  <span class="nx">zones</span>               <span class="o">=</span> <span class="p">[</span><span class="s2">"1"</span><span class="p">,</span> <span class="s2">"2"</span><span class="p">,</span> <span class="s2">"3"</span><span class="p">]</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Azure Firewall</span>
<span class="nx">resource</span> <span class="s2">"azurerm_firewall"</span> <span class="s2">"hub"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"afw-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">sku_name</span>            <span class="o">=</span> <span class="s2">"AZFW_VNet"</span>
  <span class="nx">sku_tier</span>            <span class="o">=</span> <span class="s2">"Standard"</span>
  <span class="nx">firewall_policy_id</span>  <span class="o">=</span> <span class="nx">azurerm_firewall_policy</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">zones</span>               <span class="o">=</span> <span class="p">[</span><span class="s2">"1"</span><span class="p">,</span> <span class="s2">"2"</span><span class="p">,</span> <span class="s2">"3"</span><span class="p">]</span>

  <span class="nx">ip_configuration</span> <span class="p">{</span>
    <span class="nx">name</span>                 <span class="o">=</span> <span class="s2">"configuration"</span>
    <span class="nx">subnet_id</span>            <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">hub_firewall</span><span class="p">.</span><span class="nx">id</span>
    <span class="nx">public_ip_address_id</span> <span class="o">=</span> <span class="nx">azurerm_public_ip</span><span class="p">.</span><span class="nx">firewall</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Firewall Policy</span>
<span class="nx">resource</span> <span class="s2">"azurerm_firewall_policy"</span> <span class="s2">"hub"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"afwp-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"Standard"</span>
  
  <span class="nx">threat_intelligence_mode</span> <span class="o">=</span> <span class="s2">"Alert"</span>

  <span class="nx">dns</span> <span class="p">{</span>
    <span class="nx">proxy_enabled</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>

  <span class="nx">intrusion_detection</span> <span class="p">{</span>
    <span class="nx">mode</span> <span class="o">=</span> <span class="s2">"Alert"</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Firewall Policy Rule Collection Group - Network Rules</span>
<span class="nx">resource</span> <span class="s2">"azurerm_firewall_policy_rule_collection_group"</span> <span class="s2">"network_rules"</span> <span class="p">{</span>
  <span class="nx">name</span>               <span class="o">=</span> <span class="s2">"network-rules"</span>
  <span class="nx">firewall_policy_id</span> <span class="o">=</span> <span class="nx">azurerm_firewall_policy</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">priority</span>           <span class="o">=</span> <span class="mi">100</span>

  <span class="nx">network_rule_collection</span> <span class="p">{</span>
    <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"allow-spoke-to-spoke"</span>
    <span class="nx">priority</span> <span class="o">=</span> <span class="mi">100</span>
    <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>

    <span class="nx">rule</span> <span class="p">{</span>
      <span class="nx">name</span>                  <span class="o">=</span> <span class="s2">"allow-all-spoke-to-spoke"</span>
      <span class="nx">protocols</span>             <span class="o">=</span> <span class="p">[</span><span class="s2">"Any"</span><span class="p">]</span>
      <span class="nx">source_addresses</span>      <span class="o">=</span> <span class="p">[</span><span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="nx">v</span><span class="p">.</span><span class="nx">address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">]]</span>
      <span class="nx">destination_addresses</span> <span class="o">=</span> <span class="p">[</span><span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="nx">v</span><span class="p">.</span><span class="nx">address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">]]</span>
      <span class="nx">destination_ports</span>     <span class="o">=</span> <span class="p">[</span><span class="s2">"*"</span><span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">network_rule_collection</span> <span class="p">{</span>
    <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"allow-dns"</span>
    <span class="nx">priority</span> <span class="o">=</span> <span class="mi">110</span>
    <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>

    <span class="nx">rule</span> <span class="p">{</span>
      <span class="nx">name</span>                  <span class="o">=</span> <span class="s2">"allow-dns-outbound"</span>
      <span class="nx">protocols</span>             <span class="o">=</span> <span class="p">[</span><span class="s2">"UDP"</span><span class="p">]</span>
      <span class="nx">source_addresses</span>      <span class="o">=</span> <span class="p">[</span><span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="nx">v</span><span class="p">.</span><span class="nx">address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">]]</span>
      <span class="nx">destination_addresses</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"*"</span><span class="p">]</span>
      <span class="nx">destination_ports</span>     <span class="o">=</span> <span class="p">[</span><span class="s2">"53"</span><span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Firewall Policy Rule Collection Group - Application Rules</span>
<span class="nx">resource</span> <span class="s2">"azurerm_firewall_policy_rule_collection_group"</span> <span class="s2">"application_rules"</span> <span class="p">{</span>
  <span class="nx">name</span>               <span class="o">=</span> <span class="s2">"application-rules"</span>
  <span class="nx">firewall_policy_id</span> <span class="o">=</span> <span class="nx">azurerm_firewall_policy</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">priority</span>           <span class="o">=</span> <span class="mi">200</span>

  <span class="nx">application_rule_collection</span> <span class="p">{</span>
    <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"allow-azure-services"</span>
    <span class="nx">priority</span> <span class="o">=</span> <span class="mi">100</span>
    <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>

    <span class="nx">rule</span> <span class="p">{</span>
      <span class="nx">name</span> <span class="o">=</span> <span class="s2">"allow-azure-management"</span>
      <span class="nx">protocols</span> <span class="p">{</span>
        <span class="nx">type</span> <span class="o">=</span> <span class="s2">"Https"</span>
        <span class="nx">port</span> <span class="o">=</span> <span class="mi">443</span>
      <span class="p">}</span>
      <span class="nx">source_addresses</span> <span class="o">=</span> <span class="p">[</span><span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="nx">v</span><span class="p">.</span><span class="nx">address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">]]</span>
      <span class="nx">destination_fqdns</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s2">"*.azure.com"</span><span class="p">,</span>
        <span class="s2">"*.microsoft.com"</span><span class="p">,</span>
        <span class="s2">"*.windows.net"</span><span class="p">,</span>
        <span class="s2">"*.azure-automation.net"</span>
      <span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">application_rule_collection</span> <span class="p">{</span>
    <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"allow-ubuntu-updates"</span>
    <span class="nx">priority</span> <span class="o">=</span> <span class="mi">110</span>
    <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>

    <span class="nx">rule</span> <span class="p">{</span>
      <span class="nx">name</span> <span class="o">=</span> <span class="s2">"allow-apt-repositories"</span>
      <span class="nx">protocols</span> <span class="p">{</span>
        <span class="nx">type</span> <span class="o">=</span> <span class="s2">"Http"</span>
        <span class="nx">port</span> <span class="o">=</span> <span class="mi">80</span>
      <span class="p">}</span>
      <span class="nx">protocols</span> <span class="p">{</span>
        <span class="nx">type</span> <span class="o">=</span> <span class="s2">"Https"</span>
        <span class="nx">port</span> <span class="o">=</span> <span class="mi">443</span>
      <span class="p">}</span>
      <span class="nx">source_addresses</span> <span class="o">=</span> <span class="p">[</span><span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="nx">v</span><span class="p">.</span><span class="nx">address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">]]</span>
      <span class="nx">destination_fqdns</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s2">"*.ubuntu.com"</span><span class="p">,</span>
        <span class="s2">"*.canonical.com"</span>
      <span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="vpn-gateway">VPN Gateway</h3>

<p>For hybrid connectivity to on-premises networks, deploy VPN Gateway. Create <code class="language-plaintext highlighter-rouge">vpn-gateway.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Public IP for VPN Gateway</span>
<span class="nx">resource</span> <span class="s2">"azurerm_public_ip"</span> <span class="s2">"vpn_gateway"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"pip-vpngw-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">allocation_method</span>   <span class="o">=</span> <span class="s2">"Static"</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"Standard"</span>
  <span class="nx">zones</span>               <span class="o">=</span> <span class="p">[</span><span class="s2">"1"</span><span class="p">,</span> <span class="s2">"2"</span><span class="p">,</span> <span class="s2">"3"</span><span class="p">]</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># VPN Gateway</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network_gateway"</span> <span class="s2">"hub"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"vpngw-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>

  <span class="nx">type</span>     <span class="o">=</span> <span class="s2">"Vpn"</span>
  <span class="nx">vpn_type</span> <span class="o">=</span> <span class="s2">"RouteBased"</span>

  <span class="nx">active_active</span> <span class="o">=</span> <span class="kc">false</span>
  <span class="nx">enable_bgp</span>    <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">sku</span>           <span class="o">=</span> <span class="s2">"VpnGw2AZ"</span>
  <span class="nx">generation</span>    <span class="o">=</span> <span class="s2">"Generation2"</span>

  <span class="nx">ip_configuration</span> <span class="p">{</span>
    <span class="nx">name</span>                          <span class="o">=</span> <span class="s2">"vnetGatewayConfig"</span>
    <span class="nx">public_ip_address_id</span>          <span class="o">=</span> <span class="nx">azurerm_public_ip</span><span class="p">.</span><span class="nx">vpn_gateway</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">id</span>
    <span class="nx">private_ip_address_allocation</span> <span class="o">=</span> <span class="s2">"Dynamic"</span>
    <span class="nx">subnet_id</span>                     <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">hub_gateway</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">id</span>
  <span class="p">}</span>

  <span class="nx">bgp_settings</span> <span class="p">{</span>
    <span class="nx">asn</span> <span class="o">=</span> <span class="mi">65515</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="err">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Local Network Gateway for on-premises</span>
<span class="nx">resource</span> <span class="s2">"azurerm_local_network_gateway"</span> <span class="s2">"onpremises"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span> <span class="o">&amp;&amp;</span> <span class="nx">length</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">on_premises_address_spaces</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"lng-onpremises-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>

  <span class="nx">gateway_address</span> <span class="o">=</span> <span class="s2">"0.0.0.0"</span> <span class="c1"># Replace with actual on-premises gateway IP</span>
  <span class="nx">address_space</span>   <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">on_premises_address_spaces</span>

  <span class="nx">bgp_settings</span> <span class="p">{</span>
    <span class="nx">asn</span>                 <span class="o">=</span> <span class="mi">65000</span>
    <span class="nx">bgp_peering_address</span> <span class="o">=</span> <span class="s2">"192.168.1.1"</span> <span class="c1"># Replace with actual on-premises BGP peer IP</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="azure-bastion">Azure Bastion</h3>

<p>For secure remote access to virtual machines, deploy Azure Bastion. Create <code class="language-plaintext highlighter-rouge">bastion.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Public IP for Azure Bastion</span>
<span class="nx">resource</span> <span class="s2">"azurerm_public_ip"</span> <span class="s2">"bastion"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_bastion</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"pip-bastion-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">allocation_method</span>   <span class="o">=</span> <span class="s2">"Static"</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"Standard"</span>
  <span class="nx">zones</span>               <span class="o">=</span> <span class="p">[</span><span class="s2">"1"</span><span class="p">,</span> <span class="s2">"2"</span><span class="p">,</span> <span class="s2">"3"</span><span class="p">]</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Azure Bastion</span>
<span class="nx">resource</span> <span class="s2">"azurerm_bastion_host"</span> <span class="s2">"hub"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_bastion</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"bastion-${local.hub_name}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"Standard"</span>

  <span class="nx">copy_paste_enabled</span>     <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">file_copy_enabled</span>      <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">ip_connect_enabled</span>     <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">shareable_link_enabled</span> <span class="o">=</span> <span class="kc">false</span>
  <span class="nx">tunneling_enabled</span>      <span class="o">=</span> <span class="kc">true</span>

  <span class="nx">ip_configuration</span> <span class="p">{</span>
    <span class="nx">name</span>                 <span class="o">=</span> <span class="s2">"configuration"</span>
    <span class="nx">subnet_id</span>            <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">hub_bastion</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">id</span>
    <span class="nx">public_ip_address_id</span> <span class="o">=</span> <span class="nx">azurerm_public_ip</span><span class="p">.</span><span class="nx">bastion</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">id</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="err">.</span><span class="nx">common_tags</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="spoke-virtual-networks">Spoke Virtual Networks</h3>

<p>Create spoke VNets dynamically based on the input variable. Create <code class="language-plaintext highlighter-rouge">spokes.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Resource groups for spoke VNets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_resource_group"</span> <span class="s2">"spokes"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span>

  <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"rg-network-spoke-${each.key}-${var.environment}"</span>
  <span class="nx">location</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">tags</span>     <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Spoke virtual networks</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network"</span> <span class="s2">"spokes"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"vnet-spoke-${each.key}-${var.environment}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">address_space</span>       <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">address_space</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Spoke subnets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_subnet"</span> <span class="s2">"spoke_subnets"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">merge</span><span class="p">([</span>
    <span class="nx">for</span> <span class="nx">spoke_key</span><span class="p">,</span> <span class="nx">spoke</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="p">{</span>
      <span class="nx">for</span> <span class="nx">subnet_key</span><span class="p">,</span> <span class="nx">subnet</span> <span class="nx">in</span> <span class="nx">spoke</span><span class="p">.</span><span class="nx">subnets</span> <span class="o">:</span>
      <span class="s2">"${spoke_key}-${subnet_key}"</span> <span class="o">=&gt;</span> <span class="nx">merge</span><span class="p">(</span><span class="nx">subnet</span><span class="p">,</span> <span class="p">{</span>
        <span class="nx">spoke_key</span> <span class="o">=</span> <span class="nx">spoke_key</span>
        <span class="nx">vnet_name</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">spoke_key</span><span class="p">].</span><span class="nx">name</span>
        <span class="nx">rg_name</span>   <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">spoke_key</span><span class="p">].</span><span class="nx">name</span>
      <span class="p">})</span>
    <span class="p">}</span>
  <span class="p">]...)</span>

  <span class="nx">name</span>                 <span class="o">=</span> <span class="s2">"snet-${each.value.spoke_key}-${split("</span><span class="o">-</span><span class="s2">", each.key)[1]}"</span>
  <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">rg_name</span>
  <span class="nx">virtual_network_name</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">vnet_name</span>
  <span class="nx">address_prefixes</span>     <span class="o">=</span> <span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">address_prefix</span><span class="p">]</span>
  <span class="nx">service_endpoints</span>    <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">service_endpoints</span>

  <span class="nx">dynamic</span> <span class="s2">"delegation"</span> <span class="p">{</span>
    <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">delegation</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">delegation</span><span class="p">]</span> <span class="o">:</span> <span class="p">[]</span>
    <span class="nx">content</span> <span class="p">{</span>
      <span class="nx">name</span> <span class="o">=</span> <span class="nx">delegation</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">name</span>

      <span class="nx">service_delegation</span> <span class="p">{</span>
        <span class="nx">name</span>    <span class="o">=</span> <span class="nx">delegation</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">service_delegation</span><span class="p">.</span><span class="nx">name</span>
        <span class="nx">actions</span> <span class="o">=</span> <span class="nx">delegation</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">service_delegation</span><span class="p">.</span><span class="nx">actions</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Network security groups for spoke subnets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_network_security_group"</span> <span class="s2">"spoke_subnets"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">spoke_subnets</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"nsg-${each.key}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">resource_group_name</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># NSG associations</span>
<span class="nx">resource</span> <span class="s2">"azurerm_subnet_network_security_group_association"</span> <span class="s2">"spoke_subnets"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">spoke_subnets</span>

  <span class="nx">subnet_id</span>                 <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">network_security_group_id</span> <span class="o">=</span> <span class="nx">azurerm_network_security_group</span><span class="p">.</span><span class="nx">spoke_subnets</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">id</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="vnet-peering">VNet Peering</h3>

<p>Connect spokes to the hub through VNet peering. Create <code class="language-plaintext highlighter-rouge">peering.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Hub to spoke peering</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network_peering"</span> <span class="s2">"hub_to_spoke"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span>

  <span class="nx">name</span>                      <span class="o">=</span> <span class="s2">"peer-hub-to-${each.key}"</span>
  <span class="nx">resource_group_name</span>       <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_name</span>      <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">remote_virtual_network_id</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">id</span>

  <span class="nx">allow_virtual_network_access</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">allow_forwarded_traffic</span>      <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">allow_gateway_transit</span>        <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span>
  <span class="nx">use_remote_gateways</span>          <span class="o">=</span> <span class="kc">false</span>
<span class="p">}</span>

<span class="c1"># Spoke to hub peering</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network_peering"</span> <span class="s2">"spoke_to_hub"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span>

  <span class="nx">name</span>                      <span class="o">=</span> <span class="s2">"peer-${each.key}-to-hub"</span>
  <span class="nx">resource_group_name</span>       <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">virtual_network_name</span>      <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">remote_virtual_network_id</span> <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">allow_virtual_network_access</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">allow_forwarded_traffic</span>      <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">allow_gateway_transit</span>        <span class="o">=</span> <span class="kc">false</span>
  <span class="nx">use_remote_gateways</span>          <span class="o">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">enable_vpn_gateway</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">azurerm_virtual_network_gateway</span><span class="p">.</span><span class="nx">hub</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="routing-configuration">Routing Configuration</h3>

<p>Configure user-defined routes to direct traffic through Azure Firewall. Create <code class="language-plaintext highlighter-rouge">routing.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Route table for spoke VNets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_route_table"</span> <span class="s2">"spokes"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"rt-spoke-${each.key}-${var.environment}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">name</span>

  <span class="nx">disable_bgp_route_propagation</span> <span class="o">=</span> <span class="kc">false</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Route to send internet traffic through firewall</span>
<span class="nx">resource</span> <span class="s2">"azurerm_route"</span> <span class="s2">"spoke_internet_via_firewall"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span>

  <span class="nx">name</span>                   <span class="o">=</span> <span class="s2">"route-internet-via-firewall"</span>
  <span class="nx">resource_group_name</span>    <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">route_table_name</span>       <span class="o">=</span> <span class="nx">azurerm_route_table</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">address_prefix</span>         <span class="o">=</span> <span class="s2">"0.0.0.0/0"</span>
  <span class="nx">next_hop_type</span>          <span class="o">=</span> <span class="s2">"VirtualAppliance"</span>
  <span class="nx">next_hop_in_ip_address</span> <span class="o">=</span> <span class="nx">azurerm_firewall</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">ip_configuration</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">private_ip_address</span>
<span class="p">}</span>

<span class="c1"># Routes to send spoke-to-spoke traffic through firewall</span>
<span class="nx">resource</span> <span class="s2">"azurerm_route"</span> <span class="s2">"spoke_to_spoke_via_firewall"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">merge</span><span class="p">([</span>
    <span class="nx">for</span> <span class="nx">spoke_key</span><span class="p">,</span> <span class="nx">spoke</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span> <span class="p">{</span>
      <span class="nx">for</span> <span class="nx">other_spoke_key</span><span class="p">,</span> <span class="nx">other_spoke</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">spoke_vnets</span> <span class="o">:</span>
      <span class="s2">"${spoke_key}-to-${other_spoke_key}"</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">source_spoke</span>      <span class="o">=</span> <span class="nx">spoke_key</span>
        <span class="nx">destination_spoke</span> <span class="o">=</span> <span class="nx">other_spoke_key</span>
        <span class="nx">address_prefix</span>    <span class="o">=</span> <span class="nx">other_spoke</span><span class="err">.</span><span class="nx">address_space</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
      <span class="p">}</span>
      <span class="nx">if</span> <span class="nx">spoke_key</span> <span class="o">!=</span> <span class="nx">other_spoke_key</span>
    <span class="p">}</span>
  <span class="p">]...)</span>

  <span class="nx">name</span>                   <span class="o">=</span> <span class="s2">"route-to-${each.value.destination_spoke}"</span>
  <span class="nx">resource_group_name</span>    <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">source_spoke</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">route_table_name</span>       <span class="o">=</span> <span class="nx">azurerm_route_table</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">source_spoke</span><span class="p">].</span><span class="nx">name</span>
  <span class="nx">address_prefix</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">address_prefix</span>
  <span class="nx">next_hop_type</span>          <span class="o">=</span> <span class="s2">"VirtualAppliance"</span>
  <span class="nx">next_hop_in_ip_address</span> <span class="o">=</span> <span class="nx">azurerm_firewall</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">ip_configuration</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">private_ip_address</span>
<span class="p">}</span>

<span class="c1"># Associate route tables with spoke subnets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_subnet_route_table_association"</span> <span class="s2">"spoke_subnets"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">spoke_subnets</span>

  <span class="nx">subnet_id</span>      <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">route_table_id</span> <span class="o">=</span> <span class="nx">azurerm_route_table</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">virtual_network_name</span> <span class="o">==</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">spokes</span><span class="p">[</span><span class="nx">split</span><span class="p">(</span><span class="s2">"-"</span><span class="p">,</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">)[</span><span class="mi">0</span><span class="p">]].</span><span class="nx">name</span> <span class="o">?</span> <span class="nx">split</span><span class="p">(</span><span class="s2">"-"</span><span class="p">,</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span> <span class="o">:</span> <span class="s2">""</span><span class="p">].</span><span class="nx">id</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="monitoring-and-diagnostics">Monitoring and Diagnostics</h3>

<p>Implement comprehensive monitoring for the network infrastructure. Create <code class="language-plaintext highlighter-rouge">monitoring.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Log Analytics workspace</span>
<span class="nx">resource</span> <span class="s2">"azurerm_log_analytics_workspace"</span> <span class="s2">"network"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"log-network-${var.environment}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"PerGB2018"</span>
  <span class="nx">retention_in_days</span>   <span class="o">=</span> <span class="mi">30</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>

<span class="c1"># Diagnostic settings for Azure Firewall</span>
<span class="nx">resource</span> <span class="s2">"azurerm_monitor_diagnostic_setting"</span> <span class="s2">"firewall"</span> <span class="p">{</span>
  <span class="nx">name</span>                       <span class="o">=</span> <span class="s2">"firewall-diagnostics"</span>
  <span class="nx">target_resource_id</span>         <span class="o">=</span> <span class="nx">azurerm_firewall</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">log_analytics_workspace_id</span> <span class="o">=</span> <span class="nx">azurerm_log_analytics_workspace</span><span class="p">.</span><span class="nx">network</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AzureFirewallApplicationRule"</span>
  <span class="p">}</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AzureFirewallNetworkRule"</span>
  <span class="p">}</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AzureFirewallDnsProxy"</span>
  <span class="p">}</span>

  <span class="nx">metric</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AllMetrics"</span>
    <span class="nx">enabled</span>  <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Diagnostic settings for VPN Gateway</span>
<span class="nx">resource</span> <span class="s2">"azurerm_monitor_diagnostic_setting"</span> <span class="s2">"vpn_gateway"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                       <span class="o">=</span> <span class="s2">"vpngw-diagnostics"</span>
  <span class="nx">target_resource_id</span>         <span class="o">=</span> <span class="nx">azurerm_virtual_network_gateway</span><span class="p">.</span><span class="nx">hub</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">id</span>
  <span class="nx">log_analytics_workspace_id</span> <span class="o">=</span> <span class="nx">azurerm_log_analytics_workspace</span><span class="p">.</span><span class="nx">network</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"GatewayDiagnosticLog"</span>
  <span class="p">}</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"TunnelDiagnosticLog"</span>
  <span class="p">}</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"RouteDiagnosticLog"</span>
  <span class="p">}</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"IKEDiagnosticLog"</span>
  <span class="p">}</span>

  <span class="nx">metric</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AllMetrics"</span>
    <span class="nx">enabled</span>  <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Diagnostic settings for Bastion</span>
<span class="nx">resource</span> <span class="s2">"azurerm_monitor_diagnostic_setting"</span> <span class="s2">"bastion"</span> <span class="p">{</span>
  <span class="nx">count</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_bastion</span> <span class="o">?</span> <span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span>

  <span class="nx">name</span>                       <span class="o">=</span> <span class="s2">"bastion-diagnostics"</span>
  <span class="nx">target_resource_id</span>         <span class="o">=</span> <span class="nx">azurerm_bastion_host</span><span class="p">.</span><span class="nx">hub</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">id</span>
  <span class="nx">log_analytics_workspace_id</span> <span class="o">=</span> <span class="nx">azurerm_log_analytics_workspace</span><span class="p">.</span><span class="nx">network</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"BastionAuditLogs"</span>
  <span class="p">}</span>

  <span class="nx">metric</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AllMetrics"</span>
    <span class="nx">enabled</span>  <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Network Watcher</span>
<span class="nx">resource</span> <span class="s2">"azurerm_network_watcher"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"nw-${var.environment}-${var.location}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">common_tags</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="outputs">Outputs</h3>

<p>Export important values for reference. Create <code class="language-plaintext highlighter-rouge">outputs.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">output</span> <span class="s2">"hub_vnet_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Resource ID of the hub VNet"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">id</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"hub_vnet_name"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Name of the hub VNet"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">name</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"firewall_private_ip"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Private IP address of Azure Firewall"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_firewall</span><span class="p">.</span><span class="nx">hub</span><span class="p">.</span><span class="nx">ip_configuration</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">private_ip_address</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"spoke_vnet_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of spoke VNet resource IDs"</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_virtual_network</span><span class="p">.</span><span class="nx">spokes</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"spoke_subnet_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of spoke subnet resource IDs"</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">spoke_subnets</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"bastion_fqdn"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"FQDN of Azure Bastion"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_bastion</span> <span class="o">?</span> <span class="nx">azurerm_bastion_host</span><span class="p">.</span><span class="nx">hub</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">dns_name</span> <span class="o">:</span> <span class="kc">null</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"vpn_gateway_public_ip"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Public IP address of VPN Gateway"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">enable_vpn_gateway</span> <span class="o">?</span> <span class="nx">azurerm_public_ip</span><span class="p">.</span><span class="nx">vpn_gateway</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ip_address</span> <span class="o">:</span> <span class="kc">null</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="example-variables-file">Example Variables File</h3>

<p>Create <code class="language-plaintext highlighter-rouge">terraform.tfvars.example</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">environment</span>  <span class="o">=</span> <span class="s2">"prod"</span>
<span class="nx">location</span>     <span class="o">=</span> <span class="s2">"uksouth"</span>
<span class="nx">organisation</span> <span class="o">=</span> <span class="s2">"contoso"</span>

<span class="nx">hub_vnet_address_space</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"10.0.0.0/16"</span><span class="p">]</span>

<span class="nx">spoke_vnets</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">production</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">address_space</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"10.1.0.0/16"</span><span class="p">]</span>
    <span class="nx">subnets</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">web</span> <span class="o">=</span> <span class="p">{</span>
        <span class="nx">address_prefix</span>    <span class="o">=</span> <span class="s2">"10.1.1.0/24"</span>
        <span class="nx">service_endpoints</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Microsoft.Storage"</span><span class="p">,</span> <span class="s2">"Microsoft.KeyVault"</span><span class="p">]</span>
      <span class="p">}</span>
      <span class="nx">app</span> <span class="o">=</span> <span class="p">{</span>
        <span class="nx">address_prefix</span>    <span class="o">=</span> <span class="s2">"10.1.2.0/24"</span>
        <span class="nx">service_endpoints</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Microsoft.Storage"</span><span class="p">,</span> <span class="s2">"Microsoft.Sql"</span><span class="p">]</span>
      <span class="p">}</span>
      <span class="nx">data</span> <span class="o">=</span> <span class="p">{</span>
        <span class="nx">address_prefix</span>    <span class="o">=</span> <span class="s2">"10.1.3.0/24"</span>
        <span class="nx">service_endpoints</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Microsoft.Sql"</span><span class="p">,</span> <span class="s2">"Microsoft.Storage"</span><span class="p">]</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="nx">development</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">address_space</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"10.2.0.0/16"</span><span class="p">]</span>
    <span class="nx">subnets</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">workloads</span> <span class="o">=</span> <span class="p">{</span>
        <span class="nx">address_prefix</span>    <span class="o">=</span> <span class="s2">"10.2.1.0/24"</span>
        <span class="nx">service_endpoints</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Microsoft.Storage"</span><span class="p">]</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">enable_vpn_gateway</span> <span class="o">=</span> <span class="kc">true</span>
<span class="nx">enable_bastion</span>     <span class="o">=</span> <span class="kc">true</span>

<span class="nx">on_premises_address_spaces</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"192.168.0.0/16"</span><span class="p">]</span>

<span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">cost_centre</span> <span class="o">=</span> <span class="s2">"platform"</span>
  <span class="nx">project</span>     <span class="o">=</span> <span class="s2">"network-infrastructure"</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="security-best-practices">Security Best Practices</h2>

<p>Security in a hub and spoke topology requires defence in depth—multiple layers of protection that work together to prevent, detect, and respond to threats.</p>

<h3 id="network-segmentation">Network Segmentation</h3>

<p>The hub and spoke pattern inherently provides segmentation by isolating workloads into separate virtual networks. But segmentation within spokes is equally important. Use subnets to create security boundaries between tiers of your application.</p>

<p>A three-tier application should have three subnets minimum: web tier, application tier, and data tier. Network security groups on each subnet enforce which traffic is allowed. The web tier subnet allows inbound HTTPS from the internet and outbound connections to the application tier. The application tier allows inbound connections only from the web tier and outbound connections only to the data tier. The data tier allows inbound connections only from the application tier.</p>

<p>This creates a security posture where even if an attacker compromises the web tier, they cannot directly access databases in the data tier. They must traverse multiple network boundaries, each with its own controls, giving your security team multiple opportunities to detect and block the attack.</p>

<h3 id="azure-firewall-configuration">Azure Firewall Configuration</h3>

<p>Azure Firewall’s power lies in its policy-based approach to network security. Rather than configuring individual firewall rules on every virtual machine, you define policies centrally and Azure Firewall enforces them for all traffic flowing through the hub.</p>

<p>Start with a deny-all default posture and explicitly allow only the traffic you need. The application rules we configured allow access to Azure services and Ubuntu repositories, but block everything else by default. As your organisation grows, add more specific rules rather than opening up broad access.</p>

<p>Enable threat intelligence in Alert mode initially, which logs potential threats without blocking them. Once you understand your traffic patterns, switch to Alert and Deny mode to actively block connections to known malicious IP addresses and domains.</p>

<p>The DNS proxy feature is particularly valuable. When enabled, Azure Firewall acts as a DNS server for your spokes, allowing you to create firewall rules based on FQDNs rather than IP addresses. This is crucial for SaaS applications and Azure services where IP addresses change frequently.</p>

<h3 id="network-security-groups">Network Security Groups</h3>

<p>Network security groups provide granular control at the subnet or network interface level. They’re your last line of defence when Azure Firewall is the first line.</p>

<p>Design NSG rules with specificity. Instead of allowing all TCP traffic, specify the exact ports your application needs. Instead of allowing traffic from any source, limit it to the specific subnets or IP addresses that should have access.</p>

<p>Use application security groups (ASGs) to simplify NSG rule management. Instead of specifying IP addresses in NSG rules, you specify ASGs. Then assign virtual machine network interfaces to ASGs based on their role. When you need to change firewall rules for all web servers, you modify the NSG rule once rather than updating rules for each individual server.</p>

<p>Tag your NSG rules with descriptive names and comments explaining their purpose. Future you, investigating a security incident at 3am, will appreciate knowing why a particular rule exists and what business requirement it serves.</p>

<h3 id="private-endpoints">Private Endpoints</h3>

<p>For Azure PaaS services like Storage Accounts, SQL Databases, and Key Vaults, use private endpoints to access them over private IP addresses from your virtual network. This keeps data plane traffic entirely within Azure’s network backbone, never traversing the public internet.</p>

<p>Private endpoints create a network interface in your VNet with a private IP address. DNS resolution for the PaaS service hostname returns this private IP address instead of the public IP address. Your applications access the service over the private connection without any code changes.</p>

<p>Organise private endpoints in dedicated subnets within your spoke VNets. This makes it easier to manage network security group rules and DNS configuration. A subnet specifically for private endpoints can have restrictive NSG rules since it only contains network interfaces, not compute resources.</p>

<h3 id="just-in-time-access">Just-in-Time Access</h3>

<p>Azure Bastion eliminates the need for virtual machines with public IP addresses, but some scenarios still require SSH or RDP access. Azure Defender for Cloud’s Just-in-Time (JIT) VM Access feature provides a middle ground.</p>

<p>JIT keeps management ports closed by default and opens them only when needed, for a limited time, and only for authorised users. When you need to access a virtual machine, you request access through the Azure portal or CLI. Azure opens the NSG rules for your specific IP address, allows you to connect, and automatically closes the rules after the time period expires.</p>

<p>This dramatically reduces the attack surface. Rather than having RDP or SSH ports exposed 24/7, they’re only open for the brief periods when legitimate administrators need access. Even then, access is restricted to specific source IP addresses.</p>

<h3 id="audit-and-compliance">Audit and Compliance</h3>

<p>Enable diagnostic logging for all network resources. Azure Firewall logs every allowed and denied connection. VPN Gateway logs all tunnel events and configuration changes. Bastion logs every remote access session. This comprehensive audit trail is invaluable for security investigations and compliance requirements.</p>

<p>Send all logs to Azure Monitor Log Analytics where you can query them using KQL, create alerts for suspicious activity, and build dashboards for security operations teams. Configure alerts for events like firewall rule changes, VPN tunnel failures, or unusual traffic patterns.</p>

<p>For compliance requirements like PCI DSS or HIPAA, the hub and spoke topology provides clear network boundaries that align with compliance scopes. Your PCI DSS environment can live in a dedicated spoke with strict firewall rules controlling all access. Audit logs prove that traffic between scopes flows through inspected and logged network paths.</p>

<h2 id="operational-considerations">Operational Considerations</h2>

<p>Building the infrastructure is the first step. Operating it successfully over time requires attention to several ongoing concerns.</p>

<h3 id="capacity-planning">Capacity Planning</h3>

<p>Monitor Azure Firewall throughput to ensure it doesn’t become a bottleneck. The Standard SKU supports up to 30 Gbps of throughput, but actual performance depends on your specific traffic patterns and rule complexity. If you approach capacity limits, consider upgrading to the Premium SKU or deploying multiple firewall instances.</p>

<p>VPN Gateway capacity varies by SKU. The VpnGw1AZ SKU supports up to 650 Mbps aggregate throughput and 30 tunnels. The VpnGw5AZ SKU supports up to 10 Gbps and 100 tunnels. Plan for growth when selecting SKU sizes—upgrading later requires brief downtime.</p>

<p>Spoke VNet capacity rarely becomes an issue since each spoke can have up to 65,536 IP addresses with a /16 allocation. But subnet sizes within spokes require planning. A /24 subnet provides 251 usable addresses (Azure reserves five addresses per subnet). For auto-scaling workloads, ensure subnets have enough addresses to accommodate peak scale.</p>

<h3 id="cost-optimisation">Cost Optimisation</h3>

<p>Hub and spoke topologies have predictable cost components. Azure Firewall costs around £1,000 per month for the firewall itself plus data processing charges. VPN Gateway costs between £100 and £1,500 per month depending on SKU. Azure Bastion costs around £100 per month.</p>

<p>VNet peering incurs charges for data transfer between VNets. Peering between VNets in the same region costs £0.01 per GB in each direction. This seems small but compounds with high traffic volumes. If two services need to exchange large volumes of data frequently, consider deploying them in the same spoke VNet rather than separate spokes.</p>

<p>Enable Azure Advisor cost recommendations and review them regularly. Advisor identifies unused resources like VPN Gateway connections that haven’t carried traffic in weeks or public IP addresses that aren’t associated with any resource.</p>

<h3 id="high-availability">High Availability</h3>

<p>The hub becomes a single point of failure if not designed for high availability. Azure Firewall supports zone redundancy, spreading instances across availability zones. If one zone fails, the firewall continues operating from the other zones. Enable zone redundancy by specifying zones during firewall creation.</p>

<p>VPN Gateway also supports zone redundancy with the VpnGwNAZ SKUs. These deploy gateway instances across availability zones, ensuring VPN connectivity remains available even during zone failures. For mission-critical connectivity, deploy both VPN Gateway and ExpressRoute, configuring VPN as a backup path if ExpressRoute fails.</p>

<p>Azure Bastion’s Standard SKU includes high availability with multiple instances deployed automatically. Host scaling adjusts the number of instances based on concurrent sessions, ensuring performance during peak usage.</p>

<p>Consider deploying hub and spoke topologies in multiple regions for disaster recovery. Create a hub in your primary region and another in your secondary region. Spoke VNets in each region peer with their regional hub. Use Azure Traffic Manager or Azure Front Door to distribute traffic between regions.</p>

<h3 id="automation-and-gitops">Automation and GitOps</h3>

<p>Store all Terraform configuration in version control and use CI/CD pipelines to deploy changes. This provides audit trails showing who changed what and when, makes it easy to rollback problematic changes, and ensures consistency across environments.</p>

<p>Use Terraform workspaces or separate state files for different environments. Development, staging, and production environments should be identical in structure but isolated in deployment. This allows you to test network changes in development before applying them to production.</p>

<p>Implement automated testing for your network infrastructure. Tools like Terratest can validate that your Terraform code creates the expected resources with the correct configuration. Network tests can verify that spoke VNets can communicate through the firewall, that internet access works, and that on-premises connectivity functions correctly.</p>

<h3 id="troubleshooting-common-issues">Troubleshooting Common Issues</h3>

<p>Spoke-to-spoke connectivity issues usually stem from routing or firewall rule problems. Use Azure Network Watcher’s Connection Troubleshoot feature to test connectivity between virtual machines in different spokes. It shows you the exact network path traffic takes and identifies where it’s being blocked.</p>

<p>VPN connectivity problems often relate to on-premises firewall configuration or IP addressing conflicts. Ensure on-premises firewalls allow UDP ports 500 and 4500 for IKE traffic. Verify that on-premises address spaces don’t overlap with Azure VNet address spaces. Use VPN Gateway diagnostic logs to identify where tunnel negotiation is failing.</p>

<p>DNS resolution issues can break private endpoint connectivity. When using Azure Firewall’s DNS proxy, ensure spoke VNets configure the firewall’s private IP address as their DNS server. Use the <code class="language-plaintext highlighter-rouge">nslookup</code> command from spoke virtual machines to verify DNS resolution returns private IP addresses for PaaS services with private endpoints.</p>

<h2 id="advanced-patterns-and-extensions">Advanced Patterns and Extensions</h2>

<p>Once you’ve mastered the basic hub and spoke topology, several advanced patterns extend its capabilities.</p>

<h3 id="multi-region-hub-and-spoke">Multi-Region Hub and Spoke</h3>

<p>Global organisations often deploy hub and spoke topologies in multiple Azure regions. Each region has its own hub VNet with Azure Firewall, VPN Gateway, and Bastion. Regional spoke VNets peer with their regional hub.</p>

<p>Connect regional hubs using Global VNet Peering, which allows VNets in different regions to communicate over Microsoft’s global backbone network. This enables workloads in spoke VNets in different regions to communicate whilst keeping regional traffic within the region.</p>

<p>Use Azure Virtual WAN as an alternative to manually peering regional hubs. Virtual WAN provides a managed hub and spoke topology with built-in routing, VPN connectivity, and optimised global connectivity. It simplifies management but costs more than self-managed hubs.</p>

<h3 id="shared-services-spoke">Shared Services Spoke</h3>

<p>Some organisations create a dedicated shared services spoke within the hub and spoke topology. This spoke contains services used by multiple application spokes: Active Directory domain controllers, DNS servers, monitoring infrastructure, or DevOps tools.</p>

<p>The shared services spoke peers with the hub like any other spoke but receives special firewall rules allowing it to accept connections from all other spokes. Application spokes can initiate connections to shared services, but shared services cannot initiate connections to application spokes, maintaining security boundaries.</p>

<h3 id="network-virtual-appliances">Network Virtual Appliances</h3>

<p>Whilst Azure Firewall meets most requirements, some scenarios need third-party network virtual appliances (NVAs) for features like SD-WAN, advanced threat detection, or integration with existing on-premises security infrastructure.</p>

<p>Deploy NVAs in the hub VNet in a dedicated subnet. Configure user-defined routes to send traffic through the NVA instead of Azure Firewall. Many NVA vendors provide Azure Marketplace images and ARM templates that simplify deployment.</p>

<p>NVAs require careful capacity planning and high availability design. Deploy multiple instances in an availability set or availability zones with a load balancer distributing traffic between them. Monitor NVA performance and scale instances as traffic grows.</p>

<h3 id="azure-firewall-manager">Azure Firewall Manager</h3>

<p>For organisations with multiple hub and spoke topologies across regions or subscriptions, Azure Firewall Manager centralises policy management. Instead of configuring firewall rules separately in each hub, you define policies once in Firewall Manager and apply them to multiple firewalls.</p>

<p>Firewall Manager supports policy inheritance, where child policies inherit rules from parent policies and add environment-specific rules. This allows you to define organisation-wide security policies centrally whilst giving regional teams flexibility to add rules for their specific needs.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The hub and spoke network topology represents a mature, battle-tested approach to Azure network architecture. It solves the fundamental challenges of cloud networking—providing isolation between workloads whilst enabling controlled connectivity, centralising security enforcement without creating bottlenecks, and scaling elegantly from small deployments to global enterprise platforms.</p>

<p>Building a production-ready hub and spoke topology requires careful planning around IP addressing, routing, security, and high availability. The Terraform code we’ve built demonstrates these principles in practice, creating infrastructure that’s secure by default, scales with your organisation, and provides the observability needed for effective operations.</p>

<p>The initial investment in building a proper hub and spoke topology pays dividends over time. Each new spoke you add follows the established pattern, inheriting security controls and connectivity automatically. Your platform engineering team maintains central control over security policy and hybrid connectivity whilst development teams get the isolation and autonomy they need to move quickly.</p>

<p>As your Azure footprint grows, the hub and spoke pattern grows with you. Additional spokes, regional hubs, and advanced features like Azure Firewall Manager extend the topology without requiring fundamental redesign. You’ve built a foundation that will serve your organisation reliably for years, providing the network infrastructure on which thousands of workloads can run securely and efficiently.</p>]]></content><author><name>Glen Thomas</name></author><category term="Platform Engineering" /><category term="Azure" /><category term="Virtual Network" /><category term="VNet" /><category term="Hub and Spoke" /><category term="Terraform" /><category term="Network Security" /><summary type="html"><![CDATA[Network architecture is one of those foundational decisions that shapes everything you build on top of it. Get it right at the start, and you’ll have a scalable, secure platform that grows with your organisation. Get it wrong, and you’ll spend years fighting technical debt, security vulnerabilities, and operational complexity that compounds with every new workload you deploy.]]></summary></entry><entry><title type="html">Building a Centralised Azure Container Registry: A Platform Engineering Guide</title><link href="https://blog.glen-thomas.com/platform%20engineering/2025/10/14/building-a-centralised-azure-container-registry.html" rel="alternate" type="text/html" title="Building a Centralised Azure Container Registry: A Platform Engineering Guide" /><published>2025-10-14T22:19:00+01:00</published><updated>2025-10-14T22:19:00+01:00</updated><id>https://blog.glen-thomas.com/platform%20engineering/2025/10/14/building-a-centralised-azure-container-registry</id><content type="html" xml:base="https://blog.glen-thomas.com/platform%20engineering/2025/10/14/building-a-centralised-azure-container-registry.html"><![CDATA[<p>When building container-based applications on Azure, you’ll inevitably need to decide how to manage your container images. Azure Container Registry (ACR) is the natural choice for Azure workloads, but the real question is whether to create multiple registries across teams and environments, or build a single, centralised registry that serves your entire organisation.</p>

<p>In this comprehensive guide, I’ll walk you through building a centralised Azure Container Registry that serves as the single source of truth for container images across your organisation. We’ll explore the architectural considerations, implement the infrastructure using Terraform with security best practices baked in, and create GitHub Actions workflows that integrate seamlessly with your registry.</p>

<h2 id="why-centralise-your-container-registry">Why Centralise Your Container Registry?</h2>

<p>Before we dive into the implementation, it’s worth understanding why a centralised approach often makes more sense than having multiple registries scattered across your Azure subscriptions.</p>

<p>The most compelling reason is cost efficiency. Azure Container Registry pricing is based on storage and data egress, and consolidating images into a single registry reduces both duplication and management overhead. When multiple teams build similar base images or share common dependencies, a centralised registry with proper namespace organisation prevents storing the same layers multiple times.</p>

<p>Security and governance become significantly simpler with centralisation. Rather than managing access policies, network rules, and compliance requirements across multiple registries, you have a single point of control. This doesn’t mean sacrificing isolation. Azure Container Registry supports repository-scoped permissions that allow you to maintain strict boundaries between teams whilst sharing the infrastructure.</p>

<p>From an operational perspective, centralisation makes your platform easier to reason about. Your development teams know exactly where to push and pull images. Your security team has one place to scan for vulnerabilities. Your cost reporting becomes clearer. The cognitive load decreases as the architecture becomes more predictable.</p>

<h2 id="architectural-considerations">Architectural Considerations</h2>

<p>When designing a centralised container registry, several architectural decisions will shape your implementation. These aren’t merely technical choices, they reflect how your organisation operates and how your platform serves its users.</p>

<h3 id="network-topology-and-private-endpoints">Network Topology and Private Endpoints</h3>

<p>The first decision revolves around network accessibility. Whilst Azure Container Registry can operate with public endpoints, production environments almost universally require private network access. Using Azure Private Link, your ACR becomes accessible only through private endpoints within your virtual networks, ensuring container images never traverse the public internet.</p>

<p>This creates an interesting challenge: how do GitHub Actions runners, which operate outside your Azure network, push images to a private registry? The answer involves a hybrid approach where you maintain controlled public access for specific operations whilst keeping the registry predominantly private. We’ll implement this using network rules that allow GitHub’s IP ranges for push operations whilst restricting pulls to your private network.</p>

<h3 id="multi-tenancy-and-repository-organisation">Multi-Tenancy and Repository Organisation</h3>

<p>A centralised registry serves multiple teams, and establishing a clear organisational structure from the start prevents chaos as adoption grows. The pattern I recommend involves using repository namespaces that mirror your organisational structure. For example, images might follow a structure like <code class="language-plaintext highlighter-rouge">teamname/application/service:tag</code> rather than a flat namespace.</p>

<p>This provides natural isolation and makes it immediately clear who owns which images. Combined with Azure RBAC at the repository level, you can ensure teams can only push to their namespaces whilst potentially allowing broader pull access across the organisation.</p>

<h3 id="sku-selection-and-geo-replication">SKU Selection and Geo-Replication</h3>

<p>Azure Container Registry offers three SKUs: Basic, Standard, and Premium. For a production centralised registry, Premium is almost always the right choice. Beyond the increased storage and throughput, Premium unlocks critical features like geo-replication, customer-managed keys, and dedicated data endpoints.</p>

<p>Geo-replication deserves particular attention. If your AKS clusters or Container Apps exist in multiple Azure regions, replicating your registry to those regions dramatically improves image pull performance and resilience. The registry automatically routes pull requests to the nearest replica, reducing latency and eliminating cross-region data egress charges for pulls.</p>

<h2 id="infrastructure-as-code-with-terraform">Infrastructure as Code with Terraform</h2>

<p>Let’s translate these architectural principles into actual infrastructure. We’ll build this incrementally, starting with the core registry and progressively adding security layers.</p>

<h3 id="core-registry-infrastructure">Core Registry Infrastructure</h3>

<p>Our foundation begins with a Premium SKU registry configured for high availability. Create a new file called <code class="language-plaintext highlighter-rouge">acr.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Resource group for the centralised container registry</span>
<span class="nx">resource</span> <span class="s2">"azurerm_resource_group"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"rg-acr-${var.environment}-${var.location}"</span>
  <span class="nx">location</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">location</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
    <span class="nx">purpose</span>     <span class="o">=</span> <span class="s2">"centralised-container-registry"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Premium ACR with enhanced features enabled</span>
<span class="nx">resource</span> <span class="s2">"azurerm_container_registry"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"acr${var.organisation}${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"Premium"</span>
  <span class="nx">admin_enabled</span>       <span class="o">=</span> <span class="kc">false</span>

  <span class="c1"># Enable anonymous pull for public base images (optional)</span>
  <span class="nx">anonymous_pull_enabled</span> <span class="o">=</span> <span class="kc">false</span>

  <span class="c1"># Network access default action</span>
  <span class="nx">public_network_access_enabled</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">network_rule_bypass_option</span>    <span class="o">=</span> <span class="s2">"AzureServices"</span>

  <span class="c1"># Enable zone redundancy for high availability</span>
  <span class="nx">zone_redundancy_enabled</span> <span class="o">=</span> <span class="kc">true</span>

  <span class="c1"># Encryption with customer-managed keys</span>
  <span class="nx">encryption</span> <span class="p">{</span>
    <span class="nx">enabled</span>            <span class="o">=</span> <span class="kc">true</span>
    <span class="nx">key_vault_key_id</span>   <span class="o">=</span> <span class="nx">azurerm_key_vault_key</span><span class="p">.</span><span class="nx">acr_encryption</span><span class="p">.</span><span class="nx">id</span>
    <span class="nx">identity_client_id</span> <span class="o">=</span> <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">acr_encryption</span><span class="p">.</span><span class="nx">client_id</span>
  <span class="p">}</span>

  <span class="c1"># Enable the retention policy for untagged manifests</span>
  <span class="nx">retention_policy</span> <span class="p">{</span>
    <span class="nx">days</span>    <span class="o">=</span> <span class="mi">7</span>
    <span class="nx">enabled</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>

  <span class="c1"># Trust policy for content trust</span>
  <span class="nx">trust_policy</span> <span class="p">{</span>
    <span class="nx">enabled</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>

  <span class="c1"># Quarantine policy to scan images before making them available</span>
  <span class="nx">quarantine_policy</span> <span class="p">{</span>
    <span class="nx">enabled</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>

  <span class="nx">identity</span> <span class="p">{</span>
    <span class="nx">type</span> <span class="o">=</span> <span class="s2">"UserAssigned"</span>
    <span class="nx">identity_ids</span> <span class="o">=</span> <span class="p">[</span>
      <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">acr_encryption</span><span class="p">.</span><span class="nx">id</span>
    <span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">azurerm_key_vault_access_policy</span><span class="p">.</span><span class="nx">acr_encryption</span>
  <span class="p">]</span>
<span class="err">}</span>

<span class="c1"># Geo-replication to additional regions</span>
<span class="nx">resource</span> <span class="s2">"azurerm_container_registry_replication"</span> <span class="s2">"replicas"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">toset</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">replication_regions</span><span class="p">)</span>

  <span class="nx">name</span>                      <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span>
  <span class="nx">container_registry_name</span>   <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">resource_group_name</span>       <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">location</span>                  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span>
  <span class="nx">zone_redundancy_enabled</span>   <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">regional_endpoint_enabled</span> <span class="o">=</span> <span class="kc">true</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This configuration establishes several important security baselines. Admin credentials are explicitly disabled, forcing all authentication to use Azure AD identities. Zone redundancy ensures the registry remains available even if an availability zone fails. The retention policy automatically cleans up untagged manifests after seven days, preventing storage bloat from ephemeral CI builds.</p>

<h3 id="encryption-and-key-management">Encryption and Key Management</h3>

<p>Security-conscious organisations require customer-managed encryption keys rather than Microsoft-managed keys. This gives you complete control over key rotation and access. Create <code class="language-plaintext highlighter-rouge">encryption.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># User-assigned managed identity for ACR encryption</span>
<span class="nx">resource</span> <span class="s2">"azurerm_user_assigned_identity"</span> <span class="s2">"acr_encryption"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"id-acr-encryption-${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Key Vault for storing encryption keys</span>
<span class="nx">resource</span> <span class="s2">"azurerm_key_vault"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>                       <span class="o">=</span> <span class="s2">"kv-acr-${var.environment}-${random_string.key_vault_suffix.result}"</span>
  <span class="nx">location</span>                   <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span>        <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">tenant_id</span>                  <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_client_config</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">tenant_id</span>
  <span class="nx">sku_name</span>                   <span class="o">=</span> <span class="s2">"premium"</span>
  <span class="nx">soft_delete_retention_days</span> <span class="o">=</span> <span class="mi">90</span>
  <span class="nx">purge_protection_enabled</span>   <span class="o">=</span> <span class="kc">true</span>

  <span class="c1"># Network rules to restrict access</span>
  <span class="nx">network_acls</span> <span class="p">{</span>
    <span class="nx">bypass</span>         <span class="o">=</span> <span class="s2">"AzureServices"</span>
    <span class="nx">default_action</span> <span class="o">=</span> <span class="s2">"Deny"</span>

    <span class="nx">ip_rules</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">admin_ip_allowlist</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Random suffix to ensure Key Vault name uniqueness</span>
<span class="nx">resource</span> <span class="s2">"random_string"</span> <span class="s2">"key_vault_suffix"</span> <span class="p">{</span>
  <span class="nx">length</span>  <span class="o">=</span> <span class="mi">4</span>
  <span class="nx">special</span> <span class="o">=</span> <span class="kc">false</span>
  <span class="nx">upper</span>   <span class="o">=</span> <span class="kc">false</span>
<span class="p">}</span>

<span class="c1"># Access policy for the ACR managed identity</span>
<span class="nx">resource</span> <span class="s2">"azurerm_key_vault_access_policy"</span> <span class="s2">"acr_encryption"</span> <span class="p">{</span>
  <span class="nx">key_vault_id</span> <span class="o">=</span> <span class="nx">azurerm_key_vault</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">tenant_id</span>    <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_client_config</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">tenant_id</span>
  <span class="nx">object_id</span>    <span class="o">=</span> <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">acr_encryption</span><span class="p">.</span><span class="nx">principal_id</span>

  <span class="nx">key_permissions</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"Get"</span><span class="p">,</span>
    <span class="s2">"UnwrapKey"</span><span class="p">,</span>
    <span class="s2">"WrapKey"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Access policy for Terraform service principal</span>
<span class="nx">resource</span> <span class="s2">"azurerm_key_vault_access_policy"</span> <span class="s2">"terraform"</span> <span class="p">{</span>
  <span class="nx">key_vault_id</span> <span class="o">=</span> <span class="nx">azurerm_key_vault</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">tenant_id</span>    <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_client_config</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">tenant_id</span>
  <span class="nx">object_id</span>    <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_client_config</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">object_id</span>

  <span class="nx">key_permissions</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"Get"</span><span class="p">,</span>
    <span class="s2">"Create"</span><span class="p">,</span>
    <span class="s2">"Delete"</span><span class="p">,</span>
    <span class="s2">"List"</span><span class="p">,</span>
    <span class="s2">"Purge"</span><span class="p">,</span>
    <span class="s2">"Recover"</span><span class="p">,</span>
    <span class="s2">"GetRotationPolicy"</span><span class="p">,</span>
    <span class="s2">"SetRotationPolicy"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Customer-managed encryption key</span>
<span class="nx">resource</span> <span class="s2">"azurerm_key_vault_key"</span> <span class="s2">"acr_encryption"</span> <span class="p">{</span>
  <span class="nx">name</span>         <span class="o">=</span> <span class="s2">"acr-encryption-key"</span>
  <span class="nx">key_vault_id</span> <span class="o">=</span> <span class="nx">azurerm_key_vault</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">key_type</span>     <span class="o">=</span> <span class="s2">"RSA"</span>
  <span class="nx">key_size</span>     <span class="o">=</span> <span class="mi">2048</span>

  <span class="nx">key_opts</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s2">"decrypt"</span><span class="p">,</span>
    <span class="s2">"encrypt"</span><span class="p">,</span>
    <span class="s2">"sign"</span><span class="p">,</span>
    <span class="s2">"unwrapKey"</span><span class="p">,</span>
    <span class="s2">"verify"</span><span class="p">,</span>
    <span class="s2">"wrapKey"</span>
  <span class="p">]</span>

  <span class="nx">rotation_policy</span> <span class="p">{</span>
    <span class="nx">automatic</span> <span class="p">{</span>
      <span class="nx">time_before_expiry</span> <span class="o">=</span> <span class="s2">"P30D"</span>
    <span class="p">}</span>

    <span class="nx">expire_after</span>         <span class="o">=</span> <span class="s2">"P90D"</span>
    <span class="nx">notify_before_expiry</span> <span class="o">=</span> <span class="s2">"P29D"</span>
  <span class="p">}</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">azurerm_key_vault_access_policy</span><span class="p">.</span><span class="nx">terraform</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="nx">data</span> <span class="s2">"azurerm_client_config"</span> <span class="s2">"current"</span> <span class="p">{}</span>
</code></pre></div></div>

<p>This setup demonstrates defence in depth. The Key Vault itself sits behind network restrictions, accepting connections only from Azure services and specified admin IPs. Soft delete with purge protection prevents accidental key deletion from destroying your encrypted data. The automatic rotation policy ensures keys are refreshed every 90 days without manual intervention.</p>

<h3 id="network-security-and-private-endpoints">Network Security and Private Endpoints</h3>

<p>Now we implement the network security layer that restricts registry access to your private networks whilst allowing controlled access from GitHub Actions. Create <code class="language-plaintext highlighter-rouge">networking.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Private DNS zone for ACR</span>
<span class="nx">resource</span> <span class="s2">"azurerm_private_dns_zone"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"privatelink.azurecr.io"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Link DNS zone to hub VNet</span>
<span class="nx">resource</span> <span class="s2">"azurerm_private_dns_zone_virtual_network_link"</span> <span class="s2">"acr_hub"</span> <span class="p">{</span>
  <span class="nx">name</span>                  <span class="o">=</span> <span class="s2">"acr-hub-link"</span>
  <span class="nx">resource_group_name</span>   <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">private_dns_zone_name</span> <span class="o">=</span> <span class="nx">azurerm_private_dns_zone</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_id</span>    <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_id</span>
  <span class="nx">registration_enabled</span>  <span class="o">=</span> <span class="kc">false</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Link DNS zone to AKS spoke VNets</span>
<span class="nx">resource</span> <span class="s2">"azurerm_private_dns_zone_virtual_network_link"</span> <span class="s2">"acr_aks_spokes"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">aks_spoke_vnet_ids</span>

  <span class="nx">name</span>                  <span class="o">=</span> <span class="s2">"acr-aks-${each.key}-link"</span>
  <span class="nx">resource_group_name</span>   <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">private_dns_zone_name</span> <span class="o">=</span> <span class="nx">azurerm_private_dns_zone</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">virtual_network_id</span>    <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span>
  <span class="nx">registration_enabled</span>  <span class="o">=</span> <span class="kc">false</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Subnet for ACR private endpoint</span>
<span class="nx">resource</span> <span class="s2">"azurerm_subnet"</span> <span class="s2">"acr_endpoints"</span> <span class="p">{</span>
  <span class="nx">name</span>                 <span class="o">=</span> <span class="s2">"snet-acr-endpoints"</span>
  <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_resource_group</span>
  <span class="nx">virtual_network_name</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">hub_vnet_name</span>
  <span class="nx">address_prefixes</span>     <span class="o">=</span> <span class="p">[</span><span class="nx">var</span><span class="p">.</span><span class="nx">acr_endpoint_subnet_cidr</span><span class="p">]</span>

  <span class="nx">private_endpoint_network_policies_enabled</span> <span class="o">=</span> <span class="kc">false</span>
<span class="p">}</span>

<span class="c1"># Private endpoint for ACR</span>
<span class="nx">resource</span> <span class="s2">"azurerm_private_endpoint"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"pe-acr-${var.environment}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">subnet_id</span>           <span class="o">=</span> <span class="nx">azurerm_subnet</span><span class="p">.</span><span class="nx">acr_endpoints</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">private_service_connection</span> <span class="p">{</span>
    <span class="nx">name</span>                           <span class="o">=</span> <span class="s2">"acr-private-connection"</span>
    <span class="nx">private_connection_resource_id</span> <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
    <span class="nx">is_manual_connection</span>           <span class="o">=</span> <span class="kc">false</span>
    <span class="nx">subresource_names</span>              <span class="o">=</span> <span class="p">[</span><span class="s2">"registry"</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">private_dns_zone_group</span> <span class="p">{</span>
    <span class="nx">name</span>                 <span class="o">=</span> <span class="s2">"acr-dns-zone-group"</span>
    <span class="nx">private_dns_zone_ids</span> <span class="o">=</span> <span class="p">[</span><span class="nx">azurerm_private_dns_zone</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Network rules for ACR - allow GitHub Actions</span>
<span class="nx">resource</span> <span class="s2">"azurerm_container_registry_network_rule_set"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">container_registry_id</span> <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">default_action</span> <span class="o">=</span> <span class="s2">"Deny"</span>

  <span class="c1"># Allow GitHub Actions IP ranges for image push</span>
  <span class="nx">ip_rule</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">for</span> <span class="nx">cidr</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">github_actions_ip_ranges</span> <span class="o">:</span> <span class="p">{</span>
      <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>
      <span class="nx">ip_range</span> <span class="o">=</span> <span class="nx">cidr</span>
    <span class="p">}</span>
  <span class="p">]</span>

  <span class="c1"># Allow Azure services</span>
  <span class="nx">virtual_network</span> <span class="o">=</span> <span class="p">[]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This network configuration creates a crucial security boundary. The private endpoint places your registry inside your virtual network, making it accessible to AKS clusters and Container Apps through private IP addresses. The private DNS zone ensures that when your applications resolve the registry’s hostname, they receive the private IP rather than the public one.</p>

<p>The network rule set demonstrates the hybrid approach I mentioned earlier. By allowing GitHub’s IP ranges whilst denying everything else by default, we enable CI/CD workflows to push images whilst preventing unauthorised access. In production, you might further restrict this by using self-hosted GitHub Actions runners within your network, eliminating the need for public access entirely.</p>

<h3 id="identity-and-access-management">Identity and Access Management</h3>

<p>Proper access control ensures teams can push to their namespaces whilst the registry remains secure. Create <code class="language-plaintext highlighter-rouge">iam.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Service principal for GitHub Actions with OIDC federated credentials</span>
<span class="nx">resource</span> <span class="s2">"azuread_application"</span> <span class="s2">"github_actions"</span> <span class="p">{</span>
  <span class="nx">display_name</span> <span class="o">=</span> <span class="s2">"sp-acr-github-actions-${var.environment}"</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"azuread_service_principal"</span> <span class="s2">"github_actions"</span> <span class="p">{</span>
  <span class="nx">client_id</span> <span class="o">=</span> <span class="nx">azuread_application</span><span class="p">.</span><span class="nx">github_actions</span><span class="p">.</span><span class="nx">client_id</span>
<span class="p">}</span>

<span class="c1"># Federated identity credential for GitHub OIDC (main branch)</span>
<span class="nx">resource</span> <span class="s2">"azuread_application_federated_identity_credential"</span> <span class="s2">"github_actions_main"</span> <span class="p">{</span>
  <span class="nx">application_id</span> <span class="o">=</span> <span class="nx">azuread_application</span><span class="p">.</span><span class="nx">github_actions</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">display_name</span>   <span class="o">=</span> <span class="s2">"github-actions-main-branch"</span>
  <span class="nx">description</span>    <span class="o">=</span> <span class="s2">"Federated credential for GitHub Actions main branch"</span>
  <span class="nx">audiences</span>      <span class="o">=</span> <span class="p">[</span><span class="s2">"api://AzureADTokenExchange"</span><span class="p">]</span>
  <span class="nx">issuer</span>         <span class="o">=</span> <span class="s2">"https://token.actions.githubusercontent.com"</span>
  <span class="nx">subject</span>        <span class="o">=</span> <span class="s2">"repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"</span>
<span class="p">}</span>

<span class="c1"># Federated identity credential for GitHub OIDC (pull requests)</span>
<span class="nx">resource</span> <span class="s2">"azuread_application_federated_identity_credential"</span> <span class="s2">"github_actions_pr"</span> <span class="p">{</span>
  <span class="nx">application_id</span> <span class="o">=</span> <span class="nx">azuread_application</span><span class="p">.</span><span class="nx">github_actions</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">display_name</span>   <span class="o">=</span> <span class="s2">"github-actions-pull-requests"</span>
  <span class="nx">description</span>    <span class="o">=</span> <span class="s2">"Federated credential for GitHub Actions pull requests"</span>
  <span class="nx">audiences</span>      <span class="o">=</span> <span class="p">[</span><span class="s2">"api://AzureADTokenExchange"</span><span class="p">]</span>
  <span class="nx">issuer</span>         <span class="o">=</span> <span class="s2">"https://token.actions.githubusercontent.com"</span>
  <span class="nx">subject</span>        <span class="o">=</span> <span class="s2">"repo:${var.github_org}/${var.github_repo}:pull_request"</span>
<span class="p">}</span>

<span class="c1"># Federated identity credential for GitHub OIDC (develop branch)</span>
<span class="nx">resource</span> <span class="s2">"azuread_application_federated_identity_credential"</span> <span class="s2">"github_actions_develop"</span> <span class="p">{</span>
  <span class="nx">application_id</span> <span class="o">=</span> <span class="nx">azuread_application</span><span class="p">.</span><span class="nx">github_actions</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">display_name</span>   <span class="o">=</span> <span class="s2">"github-actions-develop-branch"</span>
  <span class="nx">description</span>    <span class="o">=</span> <span class="s2">"Federated credential for GitHub Actions develop branch"</span>
  <span class="nx">audiences</span>      <span class="o">=</span> <span class="p">[</span><span class="s2">"api://AzureADTokenExchange"</span><span class="p">]</span>
  <span class="nx">issuer</span>         <span class="o">=</span> <span class="s2">"https://token.actions.githubusercontent.com"</span>
  <span class="nx">subject</span>        <span class="o">=</span> <span class="s2">"repo:${var.github_org}/${var.github_repo}:ref:refs/heads/develop"</span>
<span class="p">}</span>

<span class="c1"># Role assignment for GitHub Actions - push access</span>
<span class="nx">resource</span> <span class="s2">"azurerm_role_assignment"</span> <span class="s2">"github_actions_push"</span> <span class="p">{</span>
  <span class="nx">scope</span>                <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">role_definition_name</span> <span class="o">=</span> <span class="s2">"AcrPush"</span>
  <span class="nx">principal_id</span>         <span class="o">=</span> <span class="nx">azuread_service_principal</span><span class="p">.</span><span class="nx">github_actions</span><span class="p">.</span><span class="nx">object_id</span>
<span class="p">}</span>

<span class="c1"># Managed identities for AKS clusters</span>
<span class="nx">resource</span> <span class="s2">"azurerm_user_assigned_identity"</span> <span class="s2">"aks_clusters"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">aks_clusters</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"id-aks-${each.key}-${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
    <span class="nx">cluster</span>     <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Role assignment for AKS - pull access</span>
<span class="nx">resource</span> <span class="s2">"azurerm_role_assignment"</span> <span class="s2">"aks_pull"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">aks_clusters</span>

  <span class="nx">scope</span>                <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">role_definition_name</span> <span class="o">=</span> <span class="s2">"AcrPull"</span>
  <span class="nx">principal_id</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">principal_id</span>
<span class="p">}</span>

<span class="c1"># Managed identities for Container Apps</span>
<span class="nx">resource</span> <span class="s2">"azurerm_user_assigned_identity"</span> <span class="s2">"container_apps"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">container_app_environments</span>

  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"id-containerapp-${each.key}-${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
    <span class="nx">app_env</span>     <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Role assignment for Container Apps - pull access</span>
<span class="nx">resource</span> <span class="s2">"azurerm_role_assignment"</span> <span class="s2">"container_apps_pull"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">container_apps</span>

  <span class="nx">scope</span>                <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">role_definition_name</span> <span class="o">=</span> <span class="s2">"AcrPull"</span>
  <span class="nx">principal_id</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">principal_id</span>
<span class="p">}</span>

<span class="c1"># Custom role for team-specific repository access (example)</span>
<span class="nx">resource</span> <span class="s2">"azurerm_role_definition"</span> <span class="s2">"team_repository_contributor"</span> <span class="p">{</span>
  <span class="nx">name</span>  <span class="o">=</span> <span class="s2">"ACR Team Repository Contributor"</span>
  <span class="nx">scope</span> <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">permissions</span> <span class="p">{</span>
    <span class="nx">actions</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"Microsoft.ContainerRegistry/registries/pull/read"</span><span class="p">,</span>
      <span class="s2">"Microsoft.ContainerRegistry/registries/push/write"</span><span class="p">,</span>
      <span class="s2">"Microsoft.ContainerRegistry/registries/artifacts/delete"</span>
    <span class="p">]</span>

    <span class="nx">data_actions</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"Microsoft.ContainerRegistry/registries/*/read"</span><span class="p">,</span>
      <span class="s2">"Microsoft.ContainerRegistry/registries/*/write"</span><span class="p">,</span>
      <span class="s2">"Microsoft.ContainerRegistry/registries/*/delete"</span>
    <span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">assignable_scopes</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This IAM configuration follows the principle of least privilege. GitHub Actions receives only push permissions, not the ability to delete or modify the registry itself. AKS clusters and Container Apps receive pull-only access since they never need to push images. The custom role definition provides a template for team-specific access that can be scoped to particular repositories.</p>

<h3 id="monitoring-and-diagnostics">Monitoring and Diagnostics</h3>

<p>Observability is crucial for a centralised service that multiple teams depend upon. Create <code class="language-plaintext highlighter-rouge">monitoring.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Log Analytics workspace for ACR diagnostics</span>
<span class="nx">resource</span> <span class="s2">"azurerm_log_analytics_workspace"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"log-acr-${var.environment}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">sku</span>                 <span class="o">=</span> <span class="s2">"PerGB2018"</span>
  <span class="nx">retention_in_days</span>   <span class="o">=</span> <span class="mi">30</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Diagnostic settings for ACR</span>
<span class="nx">resource</span> <span class="s2">"azurerm_monitor_diagnostic_setting"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>                       <span class="o">=</span> <span class="s2">"acr-diagnostics"</span>
  <span class="nx">target_resource_id</span>         <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">log_analytics_workspace_id</span> <span class="o">=</span> <span class="nx">azurerm_log_analytics_workspace</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">id</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"ContainerRegistryRepositoryEvents"</span>
  <span class="p">}</span>

  <span class="nx">enabled_log</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"ContainerRegistryLoginEvents"</span>
  <span class="p">}</span>

  <span class="nx">metric</span> <span class="p">{</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="s2">"AllMetrics"</span>
    <span class="nx">enabled</span>  <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Alert for failed authentication attempts</span>
<span class="nx">resource</span> <span class="s2">"azurerm_monitor_metric_alert"</span> <span class="s2">"auth_failures"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"acr-auth-failures-${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">scopes</span>              <span class="o">=</span> <span class="p">[</span><span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span>
  <span class="nx">description</span>         <span class="o">=</span> <span class="s2">"Alert when ACR authentication failures exceed threshold"</span>
  <span class="nx">severity</span>            <span class="o">=</span> <span class="mi">2</span>
  <span class="nx">frequency</span>           <span class="o">=</span> <span class="s2">"PT5M"</span>
  <span class="nx">window_size</span>         <span class="o">=</span> <span class="s2">"PT15M"</span>

  <span class="nx">criteria</span> <span class="p">{</span>
    <span class="nx">metric_namespace</span> <span class="o">=</span> <span class="s2">"Microsoft.ContainerRegistry/registries"</span>
    <span class="nx">metric_name</span>      <span class="o">=</span> <span class="s2">"TotalPullCount"</span>
    <span class="nx">aggregation</span>      <span class="o">=</span> <span class="s2">"Total"</span>
    <span class="nx">operator</span>         <span class="o">=</span> <span class="s2">"GreaterThan"</span>
    <span class="nx">threshold</span>        <span class="o">=</span> <span class="mi">100</span>

    <span class="nx">dimension</span> <span class="p">{</span>
      <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"StatusCode"</span>
      <span class="nx">operator</span> <span class="o">=</span> <span class="s2">"Include"</span>
      <span class="nx">values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"401"</span><span class="p">,</span> <span class="s2">"403"</span><span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">action</span> <span class="p">{</span>
    <span class="nx">action_group_id</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">security_alert_action_group_id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Alert for storage usage</span>
<span class="nx">resource</span> <span class="s2">"azurerm_monitor_metric_alert"</span> <span class="s2">"storage_usage"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"acr-storage-usage-${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">scopes</span>              <span class="o">=</span> <span class="p">[</span><span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span>
  <span class="nx">description</span>         <span class="o">=</span> <span class="s2">"Alert when ACR storage usage exceeds 80%"</span>
  <span class="nx">severity</span>            <span class="o">=</span> <span class="mi">3</span>
  <span class="nx">frequency</span>           <span class="o">=</span> <span class="s2">"PT1H"</span>
  <span class="nx">window_size</span>         <span class="o">=</span> <span class="s2">"PT1H"</span>

  <span class="nx">criteria</span> <span class="p">{</span>
    <span class="nx">metric_namespace</span> <span class="o">=</span> <span class="s2">"Microsoft.ContainerRegistry/registries"</span>
    <span class="nx">metric_name</span>      <span class="o">=</span> <span class="s2">"StorageUsed"</span>
    <span class="nx">aggregation</span>      <span class="o">=</span> <span class="s2">"Average"</span>
    <span class="nx">operator</span>         <span class="o">=</span> <span class="s2">"GreaterThan"</span>
    <span class="nx">threshold</span>        <span class="o">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">storage_alert_threshold_bytes</span>
  <span class="p">}</span>

  <span class="nx">action</span> <span class="p">{</span>
    <span class="nx">action_group_id</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">platform_alert_action_group_id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Workbook for ACR usage analytics</span>
<span class="nx">resource</span> <span class="s2">"azurerm_application_insights_workbook"</span> <span class="s2">"acr_usage"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"workbook-acr-usage-${var.environment}"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">display_name</span>        <span class="o">=</span> <span class="s2">"ACR Usage Analytics"</span>

  <span class="nx">data_json</span> <span class="o">=</span> <span class="nx">jsonencode</span><span class="p">({</span>
    <span class="nx">version</span> <span class="o">=</span> <span class="s2">"Notebook/1.0"</span>
    <span class="nx">items</span> <span class="o">=</span> <span class="p">[</span>
      <span class="p">{</span>
        <span class="nx">type</span> <span class="o">=</span> <span class="mi">1</span>
        <span class="nx">content</span> <span class="o">=</span> <span class="p">{</span>
          <span class="nx">json</span> <span class="o">=</span> <span class="s2">"## Container Registry Usage Analytics</span><span class="err">\</span><span class="s2">n</span><span class="err">\</span><span class="s2">nThis workbook provides insights into registry usage, image pulls, authentication events, and storage consumption."</span>
        <span class="p">}</span>
      <span class="p">},</span>
      <span class="p">{</span>
        <span class="nx">type</span> <span class="o">=</span> <span class="mi">3</span>
        <span class="nx">content</span> <span class="o">=</span> <span class="p">{</span>
          <span class="nx">version</span> <span class="o">=</span> <span class="s2">"KqlItem/1.0"</span>
          <span class="nx">query</span> <span class="o">=</span> <span class="s2">"ContainerRegistryRepositoryEvents</span><span class="err">\</span><span class="s2">n| where TimeGenerated &gt; ago(7d)</span><span class="err">\</span><span class="s2">n| summarize PullCount = countif(OperationName == 'Pull'), PushCount = countif(OperationName == 'Push') by bin(TimeGenerated, 1h), Repository</span><span class="err">\</span><span class="s2">n| render timechart"</span>
          <span class="nx">size</span> <span class="o">=</span> <span class="mi">0</span>
          <span class="nx">title</span> <span class="o">=</span> <span class="s2">"Pull and Push Operations by Repository (Last 7 Days)"</span>
          <span class="nx">queryType</span> <span class="o">=</span> <span class="mi">0</span>
          <span class="nx">resourceType</span> <span class="o">=</span> <span class="s2">"microsoft.operationalinsights/workspaces"</span>
        <span class="err">}</span>
      <span class="p">}</span>
    <span class="err">]</span>
  <span class="p">})</span>

  <span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">environment</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
    <span class="nx">managed_by</span>  <span class="o">=</span> <span class="s2">"terraform"</span>
  <span class="p">}</span>
<span class="err">}</span>
</code></pre></div></div>

<p>These monitoring resources provide comprehensive visibility into registry operations. Authentication failures might indicate misconfigured applications or potential security incidents. Storage usage alerts prevent unexpected cost increases. The workbook gives platform teams an at-a-glance view of which repositories are most active and how the registry is being used.</p>

<h3 id="variables-and-outputs">Variables and Outputs</h3>

<p>To make this Terraform configuration reusable, create <code class="language-plaintext highlighter-rouge">variables.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"environment"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Environment name (e.g., prod, staging)"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"location"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Primary Azure region"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="s2">"uksouth"</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"organisation"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Organisation name for resource naming"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"replication_regions"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Additional regions for ACR geo-replication"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">[</span><span class="s2">"ukwest"</span><span class="p">,</span> <span class="s2">"northeurope"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"hub_vnet_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Resource ID of hub VNet"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"hub_vnet_name"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Name of hub VNet"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"hub_vnet_resource_group"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Resource group of hub VNet"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"acr_endpoint_subnet_cidr"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"CIDR block for ACR private endpoint subnet"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"aks_spoke_vnet_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of AKS spoke VNet IDs for DNS zone linking"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"aks_clusters"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of AKS cluster names for managed identity creation"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"container_app_environments"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of Container App environment names"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"github_actions_ip_ranges"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"IP ranges for GitHub Actions runners"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">[</span>
    <span class="c1"># These are example ranges - use actual GitHub IP ranges</span>
    <span class="s2">"140.82.112.0/20"</span><span class="p">,</span>
    <span class="s2">"143.55.64.0/20"</span><span class="p">,</span>
    <span class="s2">"185.199.108.0/22"</span><span class="p">,</span>
    <span class="s2">"192.30.252.0/22"</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"admin_ip_allowlist"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"IP addresses allowed to access Key Vault"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"security_alert_action_group_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Action group ID for security alerts"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"platform_alert_action_group_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Action group ID for platform alerts"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"storage_alert_threshold_bytes"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Storage threshold for alerts in bytes"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">number</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="mi">1099511627776</span> <span class="c1"># 1TB</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"github_org"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"GitHub organisation name"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"github_repo"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"GitHub repository name"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">string</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And create <code class="language-plaintext highlighter-rouge">outputs.tf</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">output</span> <span class="s2">"registry_name"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Name of the container registry"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">name</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"registry_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Resource ID of the container registry"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"registry_login_server"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Login server URL for the registry"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">login_server</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"github_actions_client_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Client ID for GitHub Actions service principal"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azuread_application</span><span class="p">.</span><span class="nx">github_actions</span><span class="p">.</span><span class="nx">client_id</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"github_actions_tenant_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Azure AD tenant ID"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_client_config</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">tenant_id</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"github_actions_subscription_id"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Azure subscription ID"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_client_config</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">subscription_id</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"aks_identity_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of AKS managed identity resource IDs"</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">aks_clusters</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"container_app_identity_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Map of Container App managed identity resource IDs"</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_user_assigned_identity</span><span class="p">.</span><span class="nx">container_apps</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"private_endpoint_ip"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Private IP address of the ACR endpoint"</span>
  <span class="nx">value</span>       <span class="o">=</span> <span class="nx">azurerm_private_endpoint</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">private_service_connection</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">private_ip_address</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="github-actions-integration">GitHub Actions Integration</h2>

<p>With the infrastructure in place, let’s build GitHub Actions workflows that push images to our centralised registry. These workflows demonstrate production-ready practices including multi-stage builds, security scanning, and proper tagging strategies.</p>

<h3 id="reusable-docker-build-and-push-workflow">Reusable Docker Build and Push Workflow</h3>

<p>Create <code class="language-plaintext highlighter-rouge">.github/workflows/docker-build-push.yml</code> as a reusable workflow:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build and Push Docker Image</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">workflow_call</span><span class="pi">:</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">image_name</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Name</span><span class="nv"> </span><span class="s">of</span><span class="nv"> </span><span class="s">the</span><span class="nv"> </span><span class="s">Docker</span><span class="nv"> </span><span class="s">image</span><span class="nv"> </span><span class="s">(without</span><span class="nv"> </span><span class="s">registry</span><span class="nv"> </span><span class="s">prefix)'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
      <span class="na">dockerfile_path</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Path</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">Dockerfile'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./Dockerfile'</span>
      <span class="na">context_path</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Build</span><span class="nv"> </span><span class="s">context</span><span class="nv"> </span><span class="s">path'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">.'</span>
      <span class="na">platforms</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Target</span><span class="nv"> </span><span class="s">platforms</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">multi-arch</span><span class="nv"> </span><span class="s">builds'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
        <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64,linux/arm64'</span>
      <span class="na">push</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Whether</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">push</span><span class="nv"> </span><span class="s">the</span><span class="nv"> </span><span class="s">image'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
        <span class="na">default</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">scan_image</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Whether</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">scan</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">vulnerabilities'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">false</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
        <span class="na">default</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">AZURE_CLIENT_ID</span><span class="pi">:</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">AZURE_TENANT_ID</span><span class="pi">:</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">AZURE_SUBSCRIPTION_ID</span><span class="pi">:</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
      <span class="na">ACR_LOGIN_SERVER</span><span class="pi">:</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">image_tag</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">The</span><span class="nv"> </span><span class="s">full</span><span class="nv"> </span><span class="s">image</span><span class="nv"> </span><span class="s">tag</span><span class="nv"> </span><span class="s">that</span><span class="nv"> </span><span class="s">was</span><span class="nv"> </span><span class="s">built</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">pushed'</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">image_digest</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">The</span><span class="nv"> </span><span class="s">image</span><span class="nv"> </span><span class="s">digest</span><span class="nv"> </span><span class="s">SHA'</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s">$</span>

<span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
  <span class="na">security-events</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">image_tag</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">image_digest</span><span class="pi">:</span> <span class="s">$</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout code</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login via OIDC</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">client-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">tenant-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">subscription-id</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Docker Buildx</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/setup-buildx-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">platforms</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">driver-opts</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">image=moby/buildkit:latest</span>
            <span class="s">network=host</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Azure Container Registry</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">az acr login --name $(echo $ | cut -d'.' -f1)</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Extract metadata for Docker</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">meta</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/metadata-action@v5</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">images</span><span class="pi">:</span> <span class="s">$/$</span>
          <span class="na">tags</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">type=ref,event=branch</span>
            <span class="s">type=ref,event=pr</span>
            <span class="s">type=semver,pattern=</span>
            <span class="s">type=semver,pattern=.</span>
            <span class="s">type=semver,pattern=</span>
            <span class="s">type=sha,prefix=-</span>
            <span class="s">type=raw,value=latest,enable=</span>
          <span class="na">labels</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">org.opencontainers.image.title=$</span>
            <span class="s">org.opencontainers.image.description=Built by GitHub Actions</span>
            <span class="s">org.opencontainers.image.vendor=$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push Docker image</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">build</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/build-push-action@v5</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">context</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">file</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">platforms</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">tags</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">labels</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">cache-from</span><span class="pi">:</span> <span class="s">type=registry,ref=$/$:buildcache</span>
          <span class="na">cache-to</span><span class="pi">:</span> <span class="s">type=registry,ref=$/$:buildcache,mode=max</span>
          <span class="na">provenance</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">sbom</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">build-args</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">BUILD_DATE=$</span>
            <span class="s">VERSION=$</span>
            <span class="s">REVISION=$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run Trivy vulnerability scanner</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">inputs.scan_image</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">aquasecurity/trivy-action@master</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">image-ref</span><span class="pi">:</span> <span class="s">$/$@$</span>
          <span class="na">format</span><span class="pi">:</span> <span class="s1">'</span><span class="s">sarif'</span>
          <span class="na">output</span><span class="pi">:</span> <span class="s1">'</span><span class="s">trivy-results.sarif'</span>
          <span class="na">severity</span><span class="pi">:</span> <span class="s1">'</span><span class="s">CRITICAL,HIGH'</span>
          <span class="na">ignore-unfixed</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">scanners</span><span class="pi">:</span> <span class="s1">'</span><span class="s">vuln,secret,config'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload Trivy results to GitHub Security</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">inputs.scan_image &amp;&amp; !cancelled()</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">github/codeql-action/upload-sarif@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">sarif_file</span><span class="pi">:</span> <span class="s1">'</span><span class="s">trivy-results.sarif'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Generate build summary</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">always()</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">echo "## Docker Build Summary" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "**Image:** \`$/$\`" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "**Tags:** $" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "**Digest:** \`$\`" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "**Platforms:** $" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "**Build Context:** \`$\`" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
          <span class="s">echo "**Dockerfile:** \`$\`" &gt;&gt; $GITHUB_STEP_SUMMARY</span>
</code></pre></div></div>

<p>This reusable workflow encapsulates all the best practices for building container images. The metadata action generates semantic tags automatically based on git references and semantic versioning. BuildKit’s cache management significantly speeds up subsequent builds by storing layer caches in the registry itself. The workflow generates both provenance attestations and SBOMs (Software Bill of Materials), which are increasingly important for supply chain security.</p>

<p>The Trivy scanner catches vulnerabilities before they reach production. By uploading results to GitHub Security, you centralise vulnerability management alongside your code. The build summary provides immediate feedback in the workflow run, making it easy to verify what was built.</p>

<h3 id="application-specific-workflow">Application-Specific Workflow</h3>

<p>Now create a workflow that uses this reusable workflow for a specific application, <code class="language-plaintext highlighter-rouge">.github/workflows/api-service.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">API Service - Build and Deploy</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
      <span class="pi">-</span> <span class="s">develop</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">services/api/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.github/workflows/api-service.yml'</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
      <span class="pi">-</span> <span class="s">develop</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">services/api/**'</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">environment</span><span class="pi">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Deployment</span><span class="nv"> </span><span class="s">environment'</span>
        <span class="na">required</span><span class="pi">:</span> <span class="kc">true</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">choice</span>
        <span class="na">options</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">development</span>
          <span class="pi">-</span> <span class="s">staging</span>
          <span class="pi">-</span> <span class="s">production</span>

<span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
  <span class="na">security-events</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">pull-requests</span><span class="pi">:</span> <span class="s">write</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Build API Service Image</span>
    <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/workflows/docker-build-push.yml</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">image_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">platform/api-service'</span>
      <span class="na">dockerfile_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./services/api/Dockerfile'</span>
      <span class="na">context_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./services/api'</span>
      <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64'</span>
      <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">scan_image</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">AZURE_CLIENT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_TENANT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_SUBSCRIPTION_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ACR_LOGIN_SERVER</span><span class="pi">:</span> <span class="s">$</span>

  <span class="na">deploy-dev</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to Development</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.ref == 'refs/heads/develop' &amp;&amp; github.event_name == 'push'</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">development</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">creds</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to Container Apps</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/container-apps-deploy-action@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">containerAppName</span><span class="pi">:</span> <span class="s">ca-api-service-dev</span>
          <span class="na">resourceGroup</span><span class="pi">:</span> <span class="s">rg-platform-dev</span>
          <span class="na">imageToDeploy</span><span class="pi">:</span> <span class="s">$</span>

  <span class="na">deploy-staging</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to Staging</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.ref == 'refs/heads/main' &amp;&amp; github.event_name == 'push'</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">staging</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">creds</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to AKS</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/k8s-deploy@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">namespace</span><span class="pi">:</span> <span class="s">api-services</span>
          <span class="na">manifests</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">services/api/k8s/deployment.yaml</span>
            <span class="s">services/api/k8s/service.yaml</span>
          <span class="na">images</span><span class="pi">:</span> <span class="s">$</span>

  <span class="na">deploy-prod</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to Production</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">build</span><span class="pi">,</span> <span class="nv">deploy-staging</span><span class="pi">]</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.event.inputs.environment == 'production'</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">production</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">creds</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to AKS</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/k8s-deploy@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">namespace</span><span class="pi">:</span> <span class="s">api-services</span>
          <span class="na">manifests</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">services/api/k8s/deployment.yaml</span>
            <span class="s">services/api/k8s/service.yaml</span>
          <span class="na">images</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create deployment record</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">echo "Deployed $ to production" &gt;&gt; deployment-log.txt</span>
</code></pre></div></div>

<p>This workflow demonstrates a complete CI/CD pipeline. Pull requests trigger builds without pushing to verify the image builds successfully. Pushes to develop automatically deploy to the development environment. The main branch deploys to staging, whilst production deployments require manual approval through the workflow dispatch trigger.</p>

<h3 id="multi-service-monorepo-workflow">Multi-Service Monorepo Workflow</h3>

<p>For organisations managing multiple services in a monorepo, create <code class="language-plaintext highlighter-rouge">.github/workflows/monorepo-build.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Monorepo - Build All Services</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>

<span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
  <span class="na">security-events</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">detect-changes</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Detect Service Changes</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">api</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">web</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">worker</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">dorny/paths-filter@v2</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">filter</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">filters</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">api:</span>
              <span class="s">- 'services/api/**'</span>
              <span class="s">- 'shared/**'</span>
            <span class="s">web:</span>
              <span class="s">- 'services/web/**'</span>
              <span class="s">- 'shared/**'</span>
            <span class="s">worker:</span>
              <span class="s">- 'services/worker/**'</span>
              <span class="s">- 'shared/**'</span>

  <span class="na">build-api</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Build API Service</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">needs.detect-changes.outputs.api == 'true'</span>
    <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/workflows/docker-build-push.yml</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">image_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">platform/api-service'</span>
      <span class="na">dockerfile_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./services/api/Dockerfile'</span>
      <span class="na">context_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">.'</span>
      <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64'</span>
      <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">AZURE_CLIENT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_TENANT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_SUBSCRIPTION_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ACR_LOGIN_SERVER</span><span class="pi">:</span> <span class="s">$</span>

  <span class="na">build-web</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Build Web Service</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">needs.detect-changes.outputs.web == 'true'</span>
    <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/workflows/docker-build-push.yml</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">image_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">platform/web-service'</span>
      <span class="na">dockerfile_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./services/web/Dockerfile'</span>
      <span class="na">context_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">.'</span>
      <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64'</span>
      <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">AZURE_CLIENT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_TENANT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_SUBSCRIPTION_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ACR_LOGIN_SERVER</span><span class="pi">:</span> <span class="s">$</span>

  <span class="na">build-worker</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Build Worker Service</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">needs.detect-changes.outputs.worker == 'true'</span>
    <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/workflows/docker-build-push.yml</span>
    <span class="na">with</span><span class="pi">:</span>
      <span class="na">image_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">platform/worker-service'</span>
      <span class="na">dockerfile_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./services/worker/Dockerfile'</span>
      <span class="na">context_path</span><span class="pi">:</span> <span class="s1">'</span><span class="s">.'</span>
      <span class="na">platforms</span><span class="pi">:</span> <span class="s1">'</span><span class="s">linux/amd64'</span>
      <span class="na">push</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">secrets</span><span class="pi">:</span>
      <span class="na">AZURE_CLIENT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_TENANT_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">AZURE_SUBSCRIPTION_ID</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ACR_LOGIN_SERVER</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<p>This workflow uses path filtering to detect which services have changed, building only what’s necessary. This dramatically reduces CI/CD time for large monorepos where a change in one service doesn’t affect others.</p>

<h2 id="integrating-with-aks-clusters">Integrating with AKS Clusters</h2>

<p>Your AKS clusters need proper configuration to pull from the centralised registry. The key is using managed identities rather than image pull secrets, which eliminates credential management headaches.</p>

<h3 id="aks-cluster-configuration">AKS Cluster Configuration</h3>

<p>When creating your AKS cluster, attach the managed identity we created earlier:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"azurerm_kubernetes_cluster"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"aks-${var.cluster_name}-${var.environment}"</span>
  <span class="nx">location</span>            <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">location</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">resource_group_name</span>
  <span class="nx">dns_prefix</span>          <span class="o">=</span> <span class="s2">"aks-${var.cluster_name}"</span>
  
  <span class="nx">default_node_pool</span> <span class="p">{</span>
    <span class="nx">name</span>       <span class="o">=</span> <span class="s2">"system"</span>
    <span class="nx">node_count</span> <span class="o">=</span> <span class="mi">3</span>
    <span class="nx">vm_size</span>    <span class="o">=</span> <span class="s2">"Standard_D4s_v5"</span>
    
    <span class="c1"># Critical for private registry access</span>
    <span class="nx">vnet_subnet_id</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">aks_subnet_id</span>
  <span class="p">}</span>

  <span class="c1"># Use the managed identity that has AcrPull permissions</span>
  <span class="nx">identity</span> <span class="p">{</span>
    <span class="nx">type</span> <span class="o">=</span> <span class="s2">"UserAssigned"</span>
    <span class="nx">identity_ids</span> <span class="o">=</span> <span class="p">[</span><span class="nx">var</span><span class="p">.</span><span class="nx">acr_pull_identity_id</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="c1"># Attach to the ACR</span>
  <span class="nx">kubelet_identity</span> <span class="p">{</span>
    <span class="nx">client_id</span>                 <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">acr_pull_identity_client_id</span>
    <span class="nx">object_id</span>                 <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">acr_pull_identity_object_id</span>
    <span class="nx">user_assigned_identity_id</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">acr_pull_identity_id</span>
  <span class="p">}</span>

  <span class="nx">network_profile</span> <span class="p">{</span>
    <span class="nx">network_plugin</span>    <span class="o">=</span> <span class="s2">"azure"</span>
    <span class="nx">network_policy</span>    <span class="o">=</span> <span class="s2">"calico"</span>
    <span class="nx">dns_service_ip</span>    <span class="o">=</span> <span class="s2">"10.2.0.10"</span>
    <span class="nx">service_cidr</span>      <span class="o">=</span> <span class="s2">"10.2.0.0/16"</span>
    <span class="nx">load_balancer_sku</span> <span class="o">=</span> <span class="s2">"standard"</span>
  <span class="p">}</span>

  <span class="c1"># Enable private cluster for enhanced security</span>
  <span class="nx">private_cluster_enabled</span> <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With this configuration, your pods can reference images directly without any image pull secrets:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">api-service</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">api-services</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">api-service</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">api-service</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">api</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">acrorganisationprod.azurecr.io/platform/api-service:v1.2.3</span>
        <span class="na">ports</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">containerPort</span><span class="pi">:</span> <span class="m">8080</span>
        <span class="na">resources</span><span class="pi">:</span>
          <span class="na">requests</span><span class="pi">:</span>
            <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
            <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">250m"</span>
          <span class="na">limits</span><span class="pi">:</span>
            <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">512Mi"</span>
            <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">500m"</span>
</code></pre></div></div>

<p>The kubelet automatically authenticates to ACR using the managed identity, pulling images seamlessly over the private endpoint.</p>

<h2 id="integrating-with-azure-container-apps">Integrating with Azure Container Apps</h2>

<p>Container Apps integration is similarly straightforward when using managed identities:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"azurerm_container_app"</span> <span class="s2">"api"</span> <span class="p">{</span>
  <span class="nx">name</span>                         <span class="o">=</span> <span class="s2">"ca-api-service-${var.environment}"</span>
  <span class="nx">container_app_environment_id</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">container_app_environment_id</span>
  <span class="nx">resource_group_name</span>          <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">resource_group_name</span>
  <span class="nx">revision_mode</span>                <span class="o">=</span> <span class="s2">"Single"</span>

  <span class="nx">identity</span> <span class="p">{</span>
    <span class="nx">type</span> <span class="o">=</span> <span class="s2">"UserAssigned"</span>
    <span class="nx">identity_ids</span> <span class="o">=</span> <span class="p">[</span><span class="nx">var</span><span class="p">.</span><span class="nx">acr_pull_identity_id</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">registry</span> <span class="p">{</span>
    <span class="nx">server</span>   <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">acr_login_server</span>
    <span class="nx">identity</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">acr_pull_identity_id</span>
  <span class="p">}</span>

  <span class="nx">template</span> <span class="p">{</span>
    <span class="nx">container</span> <span class="p">{</span>
      <span class="nx">name</span>   <span class="o">=</span> <span class="s2">"api-service"</span>
      <span class="nx">image</span>  <span class="o">=</span> <span class="s2">"${var.acr_login_server}/platform/api-service:latest"</span>
      <span class="nx">cpu</span>    <span class="o">=</span> <span class="mf">0.5</span>
      <span class="nx">memory</span> <span class="o">=</span> <span class="s2">"1Gi"</span>

      <span class="nx">env</span> <span class="p">{</span>
        <span class="nx">name</span>  <span class="o">=</span> <span class="s2">"ASPNETCORE_ENVIRONMENT"</span>
        <span class="nx">value</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">environment</span>
      <span class="p">}</span>
    <span class="p">}</span>

    <span class="nx">min_replicas</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">max_replicas</span> <span class="o">=</span> <span class="mi">10</span>
  <span class="p">}</span>

  <span class="nx">ingress</span> <span class="p">{</span>
    <span class="nx">external_enabled</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="nx">target_port</span>      <span class="o">=</span> <span class="mi">8080</span>
    <span class="nx">traffic_weight</span> <span class="p">{</span>
      <span class="nx">latest_revision</span> <span class="o">=</span> <span class="kc">true</span>
      <span class="nx">percentage</span>      <span class="o">=</span> <span class="mi">100</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The Container App uses the managed identity to authenticate with ACR automatically. Because the Container App environment is connected to your VNet, it pulls images through the private endpoint.</p>

<h2 id="security-best-practices-in-practice">Security Best Practices in Practice</h2>

<p>Throughout this implementation, we’ve embedded security best practices at every layer. Let me highlight the critical security decisions and why they matter.</p>

<h3 id="defence-in-depth">Defence in Depth</h3>

<p>The architecture implements multiple security layers. Even if an attacker breaches one layer, others remain intact. The registry sits behind network restrictions and private endpoints. Access requires Azure AD authentication with role-based access control. Encryption protects data at rest using customer-managed keys. Each layer independently contributes to security.</p>

<h3 id="least-privilege-access">Least Privilege Access</h3>

<p>No identity receives more permissions than absolutely necessary. GitHub Actions can push but not delete. AKS clusters can pull but not push. Teams can access only their namespaces. The principle of least privilege minimises the blast radius if credentials are compromised.</p>

<h3 id="immutable-infrastructure">Immutable Infrastructure</h3>

<p>Whilst the Terraform code doesn’t explicitly show this, consider implementing repository locks and retention policies that prevent tag overwrites. Once you tag an image as <code class="language-plaintext highlighter-rouge">v1.2.3</code>, that tag should never change. This immutability ensures reproducible deployments and prevents malicious tag replacement.</p>

<h3 id="audit-logging">Audit Logging</h3>

<p>Every operation against the registry is logged to Log Analytics. Authentication attempts, image pulls, image pushes, configuration changes—all are recorded with timestamps and identity information. These logs are crucial for security investigations and compliance requirements.</p>

<h3 id="vulnerability-scanning">Vulnerability Scanning</h3>

<p>The GitHub Actions workflow scans every image before it enters the registry. Additionally, consider enabling Azure Defender for Container Registries, which continuously scans images even after they’re pushed and alerts you to newly discovered vulnerabilities.</p>

<h3 id="network-isolation">Network Isolation</h3>

<p>The private endpoints ensure registry traffic never touches the public internet when accessed from your Azure workloads. This prevents eavesdropping and man-in-the-middle attacks. The hybrid model for GitHub Actions is a pragmatic compromise, but self-hosted runners would eliminate it entirely.</p>

<h2 id="operational-considerations">Operational Considerations</h2>

<p>Building the infrastructure is one thing; operating it successfully requires ongoing attention to several areas.</p>

<h3 id="image-lifecycle-management">Image Lifecycle Management</h3>

<p>Container registries grow quickly as CI/CD pipelines push images continuously. Without lifecycle management, you’ll accumulate thousands of untagged manifests and old images that nobody uses. The retention policy we configured helps, but consider implementing more sophisticated cleanup:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Image retention task to clean up old images</span>
<span class="nx">resource</span> <span class="s2">"azurerm_container_registry_task"</span> <span class="s2">"cleanup"</span> <span class="p">{</span>
  <span class="nx">name</span>                  <span class="o">=</span> <span class="s2">"cleanup-old-images"</span>
  <span class="nx">container_registry_id</span> <span class="o">=</span> <span class="nx">azurerm_container_registry</span><span class="p">.</span><span class="nx">main</span><span class="p">.</span><span class="nx">id</span>
  
  <span class="nx">platform</span> <span class="p">{</span>
    <span class="nx">os</span> <span class="o">=</span> <span class="s2">"Linux"</span>
  <span class="p">}</span>

  <span class="nx">encoded_step</span> <span class="p">{</span>
    <span class="nx">task_content</span> <span class="o">=</span> <span class="nx">base64encode</span><span class="p">(</span><span class="o">&lt;&lt;-</span><span class="nx">EOT</span>
      <span class="nx">version</span><span class="o">:</span> <span class="nx">v1</span><span class="p">.</span><span class="mf">1.0</span>
      <span class="nx">steps</span><span class="o">:</span>
        <span class="o">-</span> <span class="nx">cmd</span><span class="o">:</span> <span class="nx">acr</span> <span class="nx">purge</span> <span class="o">--</span><span class="nx">filter</span> <span class="s1">'platform/.*:.*'</span> <span class="o">--</span><span class="nx">ago</span> <span class="mi">90</span><span class="nx">d</span> <span class="o">--</span><span class="nx">untagged</span>
          <span class="nx">disableWorkingDirectoryOverride</span><span class="o">:</span> <span class="kc">true</span>
          <span class="nx">timeout</span><span class="o">:</span> <span class="mi">3600</span>
    <span class="nx">EOT</span>
    <span class="err">)</span>
  <span class="p">}</span>

  <span class="nx">timer_trigger</span> <span class="p">{</span>
    <span class="nx">name</span>     <span class="o">=</span> <span class="s2">"weekly"</span>
    <span class="nx">schedule</span> <span class="o">=</span> <span class="s2">"0 0 * * 0"</span>
    <span class="nx">enabled</span>  <span class="o">=</span> <span class="kc">true</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This ACR Task runs weekly, removing untagged manifests older than 90 days and images in the platform namespace that haven’t been pulled in 90 days. Adjust the filters and retention periods based on your organisation’s needs.</p>

<h3 id="cost-management">Cost Management</h3>

<p>Container registries can become expensive if not monitored. The primary costs come from storage and geo-replication data egress. Monitor your storage usage trends and investigate unexpected growth. Consider whether you need geo-replication to all regions or if strategic placement in key regions suffices.</p>

<p>Enable Azure Cost Management alerts to notify you when registry costs exceed expected thresholds. Tag images with team or project information so you can attribute costs accurately.</p>

<h3 id="disaster-recovery">Disaster Recovery</h3>

<p>Your container registry becomes a critical dependency. If it’s unavailable, deployments fail and existing pods that need to pull images can’t start. Geo-replication provides regional redundancy, but also implement backup strategies:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"azurerm_backup_policy_file_share"</span> <span class="s2">"acr"</span> <span class="p">{</span>
  <span class="nx">name</span>                <span class="o">=</span> <span class="s2">"acr-backup-policy"</span>
  <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="nx">azurerm_resource_group</span><span class="p">.</span><span class="nx">acr</span><span class="p">.</span><span class="nx">name</span>
  <span class="nx">recovery_vault_name</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">recovery_vault_name</span>

  <span class="nx">backup</span> <span class="p">{</span>
    <span class="nx">frequency</span> <span class="o">=</span> <span class="s2">"Daily"</span>
    <span class="nx">time</span>      <span class="o">=</span> <span class="s2">"23:00"</span>
  <span class="p">}</span>

  <span class="nx">retention_daily</span> <span class="p">{</span>
    <span class="nx">count</span> <span class="o">=</span> <span class="mi">30</span>
  <span class="p">}</span>

  <span class="nx">retention_weekly</span> <span class="p">{</span>
    <span class="nx">count</span>    <span class="o">=</span> <span class="mi">12</span>
    <span class="nx">weekdays</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Sunday"</span><span class="p">]</span>
  <span class="p">}</span>

  <span class="nx">retention_monthly</span> <span class="p">{</span>
    <span class="nx">count</span>    <span class="o">=</span> <span class="mi">12</span>
    <span class="nx">weekdays</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Sunday"</span><span class="p">]</span>
    <span class="nx">weeks</span>    <span class="o">=</span> <span class="p">[</span><span class="s2">"First"</span><span class="p">]</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Test your disaster recovery procedures regularly. Can you restore the registry if it’s accidentally deleted? How quickly can you recover? Document the procedures so any team member can execute them under pressure.</p>

<h3 id="performance-optimisation">Performance Optimisation</h3>

<p>Image pull performance directly affects deployment speed and pod startup time. Several factors influence performance:</p>

<p>Geo-replication places registry replicas close to your workloads, reducing latency. The regional endpoint feature we enabled routes requests to the nearest replica automatically.</p>

<p>Layer caching in your Dockerfiles minimises rebuild time. Structure your Dockerfiles so frequently changing layers appear near the end, allowing Docker to reuse cached layers for earlier steps.</p>

<p>Multi-stage builds reduce final image size by excluding build tools and intermediate artefacts from the runtime image. Smaller images pull faster and consume less storage.</p>

<p>Consider implementing a registry cache or pull-through cache for external base images. This prevents pulling the same base image from Docker Hub repeatedly, reducing external bandwidth usage and improving reliability.</p>

<h2 id="team-adoption-and-documentation">Team Adoption and Documentation</h2>

<p>A centralised registry serves multiple teams, and successful adoption requires clear documentation and communication.</p>

<h3 id="developer-documentation">Developer Documentation</h3>

<p>Create comprehensive documentation that answers common questions developers will have. Document the registry URL, authentication methods, naming conventions, and how to troubleshoot common issues. Include examples for different use cases.</p>

<p>The key constraint to communicate clearly is that our registry is configured for maximum security with admin credentials disabled and network access restricted to private endpoints. This means local development workflows differ from traditional registry patterns.</p>

<p><strong>For local development and testing:</strong></p>

<p>With our security-first configuration (private endpoints, network deny-by-default), developers working from their local machines <strong>cannot directly access the registry</strong>. The network rules only permit GitHub Actions IP ranges and private endpoint access from within Azure.</p>

<p>This is intentional and represents a security best practice, but it requires developers to adapt their workflows:</p>

<p><em>Option 1: Build and test locally without registry access (recommended)</em></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build locally with a temporary tag</span>
docker build <span class="nt">-t</span> myapp:local-test <span class="nb">.</span>

<span class="c"># Test locally</span>
docker run <span class="nt">-p</span> 8080:8080 myapp:local-test

<span class="c"># When ready, commit and push code</span>
git add <span class="nb">.</span>
git commit <span class="nt">-m</span> <span class="s2">"feat: add new feature"</span>
git push origin feature/my-branch

<span class="c"># Let GitHub Actions build and push to ACR</span>
</code></pre></div></div>

<p>This is the standard workflow for secure production registries. Developers never interact with the registry directly—they build and test locally, then CI/CD handles all registry operations.</p>

<p><em>Option 2: VPN access for pulling images</em></p>

<p>For developers who need to pull production images for debugging or testing, provide VPN access to your Azure network:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Connect to corporate VPN that routes to Azure VNet</span>
<span class="c"># 2. Authenticate using Azure AD</span>
az login
az acr login <span class="nt">--name</span> acrorganisationprod

<span class="c"># 3. Pull images (pulling works, pushing still requires appropriate RBAC)</span>
docker pull acrorganisationprod.azurecr.io/platform/api-service:latest

<span class="c"># 4. Run locally for debugging</span>
docker run <span class="nt">-p</span> 8080:8080 acrorganisationprod.azurecr.io/platform/api-service:latest
</code></pre></div></div>

<p>Once connected via VPN, developers can access the registry through the private endpoint. However, even with VPN access, pushing requires both network access <strong>and</strong> appropriate RBAC permissions (AcrPush role), which should remain restricted to CI/CD service principals.</p>

<p><em>Option 3: Temporary IP allowlist (not recommended)</em></p>

<p>For exceptional circumstances, platform teams could temporarily add a developer’s IP to the network rules:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Temporary addition to variables.tf</span>
<span class="nx">variable</span> <span class="s2">"developer_ip_allowlist"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Temporary developer IPs (remove after use)"</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">[]</span>
<span class="p">}</span>

<span class="c1"># In networking.tf, add to ip_rule</span>
<span class="nx">ip_rule</span> <span class="o">=</span> <span class="nx">concat</span><span class="err">(</span>
  <span class="p">[</span><span class="nx">for</span> <span class="nx">cidr</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">github_actions_ip_ranges</span> <span class="o">:</span> <span class="p">{</span>
    <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>
    <span class="nx">ip_range</span> <span class="o">=</span> <span class="nx">cidr</span>
  <span class="p">}]</span><span class="err">,</span>
  <span class="p">[</span><span class="nx">for</span> <span class="nx">ip</span> <span class="nx">in</span> <span class="nx">var</span><span class="p">.</span><span class="nx">developer_ip_allowlist</span> <span class="o">:</span> <span class="p">{</span>
    <span class="nx">action</span>   <span class="o">=</span> <span class="s2">"Allow"</span>
    <span class="nx">ip_range</span> <span class="o">=</span> <span class="nx">ip</span>
  <span class="p">}]</span>
<span class="err">)</span>
</code></pre></div></div>

<p>This defeats the purpose of network security controls and should only be used as a last resort for troubleshooting.</p>

<p>The key message for developers: <strong>build and test locally, let CI/CD handle registry pushes</strong>. This pattern aligns with security best practices whilst maintaining developer productivity.</p>

<p><strong>For CI/CD pipelines:</strong></p>

<p>GitHub Actions workflows authenticate using <strong>workload identity federation with OIDC</strong>, which is far more secure than traditional client secrets. This approach uses short-lived tokens issued by GitHub that are trusted by Azure AD through federated credentials.</p>

<p>The authentication happens automatically through the <code class="language-plaintext highlighter-rouge">azure/login</code> action:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login via OIDC</span>
  <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v1</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">client-id</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">tenant-id</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">subscription-id</span><span class="pi">:</span> <span class="s">$</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Log in to Azure Container Registry</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">az acr login --name acrorganisationprod</span>
</code></pre></div></div>

<p><strong>Setting up GitHub repository secrets:</strong></p>

<p>After deploying the Terraform configuration, add these secrets to your GitHub repository (Settings → Secrets and variables → Actions):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># These values come from Terraform outputs</span>
AZURE_CLIENT_ID: &lt;output from terraform&gt;
AZURE_TENANT_ID: &lt;output from terraform&gt;
AZURE_SUBSCRIPTION_ID: &lt;output from terraform&gt;
ACR_LOGIN_SERVER: acrorganisationprod.azurecr.io
</code></pre></div></div>

<p>The Terraform configuration creates federated identity credentials for your main branch, develop branch, and pull requests. If you need additional branches or environments, add more <code class="language-plaintext highlighter-rouge">azuread_application_federated_identity_credential</code> resources.</p>

<h3 id="naming-conventions">Naming Conventions</h3>

<p>Establish and document clear naming conventions. A suggested structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{registry}.azurecr.io/{team|org-unit}/{application}/{component}:{tag}
</code></pre></div></div>

<p>Examples:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">acrorganisationprod.azurecr.io/platform/api-service:v1.2.3</code></li>
  <li><code class="language-plaintext highlighter-rouge">acrorganisationprod.azurecr.io/data-team/ingestion-worker:2024-01-15-abc123</code></li>
  <li><code class="language-plaintext highlighter-rouge">acrorganisationprod.azurecr.io/mobile/ios-backend:release-candidate</code></li>
</ul>

<p>Consistent naming makes it easy to understand image ownership, apply automation, and manage access policies.</p>

<h3 id="onboarding-process">Onboarding Process</h3>

<p>Create a streamlined onboarding process for new teams or projects. This might involve:</p>

<ol>
  <li>Requesting a namespace in the registry (e.g., <code class="language-plaintext highlighter-rouge">teamname/</code>)</li>
  <li>Creating a managed identity with pull access</li>
  <li>Granting the team push access to their namespace</li>
  <li>Providing GitHub Actions secrets or service principal credentials</li>
  <li>Adding the team to monitoring and alerting for their images</li>
</ol>

<p>Automate this process where possible. A simple internal portal or infrastructure-as-code template that provisions these resources reduces friction and ensures consistency.</p>

<h2 id="troubleshooting-common-issues">Troubleshooting Common Issues</h2>

<p>Even with careful implementation, teams will encounter issues. Here are solutions to common problems.</p>

<h3 id="authentication-failures">Authentication Failures</h3>

<p><strong>Symptom:</strong> <code class="language-plaintext highlighter-rouge">unauthorized: authentication required</code> or <code class="language-plaintext highlighter-rouge">unauthorized: access denied</code></p>

<p><strong>Causes:</strong></p>
<ul>
  <li>Expired service principal credentials</li>
  <li>Incorrect RBAC assignments</li>
  <li>Network rules blocking the request</li>
  <li>Managed identity not properly attached to AKS or Container Apps</li>
</ul>

<p><strong>Resolution:</strong>
Check the diagnostic logs in Log Analytics to see the specific authentication failure. Verify that the identity being used has the appropriate role assignment. For AKS, ensure the kubelet identity is correctly configured. For Container Apps, verify the registry identity matches the pull identity.</p>

<h3 id="image-pull-failures-from-aks">Image Pull Failures from AKS</h3>

<p><strong>Symptom:</strong> Pods stuck in <code class="language-plaintext highlighter-rouge">ImagePullBackOff</code> state</p>

<p><strong>Causes:</strong></p>
<ul>
  <li>Private endpoint DNS resolution failing</li>
  <li>Network connectivity issues</li>
  <li>Image doesn’t exist or tag is wrong</li>
  <li>Kubelet identity lacks permissions</li>
</ul>

<p><strong>Resolution:</strong>
First, verify DNS resolution from a pod:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl run <span class="nt">-it</span> <span class="nt">--rm</span> debug <span class="nt">--image</span><span class="o">=</span>busybox <span class="nt">--restart</span><span class="o">=</span>Never <span class="nt">--</span> nslookup acrorganisationprod.azurecr.io
</code></pre></div></div>

<p>The hostname should resolve to a private IP address from the 10.x.x.x range, not a public IP. If it resolves to a public IP, the private DNS zone linking is misconfigured.</p>

<p>Check pod events for specific error messages:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl describe pod &lt;pod-name&gt;
</code></pre></div></div>

<p>Test pulling the image manually from a node:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># SSH to a node (or use kubectl debug)</span>
<span class="nb">sudo </span>crictl pull acrorganisationprod.azurecr.io/platform/api-service:latest
</code></pre></div></div>

<h3 id="slow-image-pulls">Slow Image Pulls</h3>

<p><strong>Symptom:</strong> Deployments take minutes to pull images</p>

<p><strong>Causes:</strong></p>
<ul>
  <li>Large image sizes</li>
  <li>Pulling across regions</li>
  <li>Network bandwidth constraints</li>
  <li>Missing layer cache</li>
</ul>

<p><strong>Resolution:</strong>
Investigate image size and optimise:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker images <span class="nt">--format</span> <span class="s2">"table </span><span class="se">\t\t</span><span class="s2">"</span>
</code></pre></div></div>

<p>Images over 1GB warrant investigation. Use multi-stage builds, alpine base images, and .dockerignore files to reduce size.</p>

<p>Verify geo-replication is working:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>az acr replication list <span class="nt">--registry</span> acrorganisationprod <span class="nt">--output</span> table
</code></pre></div></div>

<p>Ensure replicas exist in the same regions as your workloads.</p>

<h3 id="github-actions-push-failures">GitHub Actions Push Failures</h3>

<p><strong>Symptom:</strong> <code class="language-plaintext highlighter-rouge">denied: client with IP not allowed</code> or similar network errors</p>

<p><strong>Causes:</strong></p>
<ul>
  <li>GitHub Actions IP ranges changed</li>
  <li>Network rule set too restrictive</li>
  <li>Service principal credentials expired</li>
</ul>

<p><strong>Resolution:</strong>
GitHub occasionally adds new IP ranges. Check the current ranges:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://api.github.com/meta | jq .actions
</code></pre></div></div>

<p>Update your network rules if necessary. Consider using self-hosted runners in your Azure network to avoid this issue entirely.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Building a centralised Azure Container Registry is an investment in your platform’s future. Done correctly, it becomes invisible infrastructure that just works. Developers push images, deployments pull them, and the registry quietly manages terabytes of container layers across regions.</p>

<p>The architecture we’ve built embeds security at every layer, from customer-managed encryption keys to network isolation to comprehensive audit logging. The Terraform code is production-ready, implementing Azure best practices that satisfy security teams whilst remaining operationally practical.</p>

<p>The GitHub Actions workflows demonstrate modern CI/CD patterns: semantic versioning, multi-architecture builds, vulnerability scanning, provenance attestations, and efficient layer caching. These aren’t just nice-to-haves, they’re essential practices for maintaining secure, reliable container deployments.</p>

<p>Perhaps most importantly, this centralised approach scales with your organisation. Whether you’re supporting ten services or a thousand, the architectural patterns remain consistent. Teams gain autonomy through namespaced repositories whilst platform engineering maintains governance through centralized policies.</p>

<p>Container registries are foundational infrastructure. Build them well, secure them thoroughly, and they’ll serve your organisation reliably for years. The initial investment in proper architecture, security controls, and operational procedures pays dividends every single day as your teams deploy with confidence, knowing their images are stored securely and delivered efficiently wherever they’re needed.</p>]]></content><author><name>Glen Thomas</name></author><category term="Platform Engineering" /><category term="DevOps" /><category term="Azure" /><category term="Container Registry" /><category term="AKS" /><category term="Container Apps" /><category term="Terraform" /><category term="Security" /><summary type="html"><![CDATA[When building container-based applications on Azure, you’ll inevitably need to decide how to manage your container images. Azure Container Registry (ACR) is the natural choice for Azure workloads, but the real question is whether to create multiple registries across teams and environments, or build a single, centralised registry that serves your entire organisation.]]></summary></entry><entry><title type="html">Mastering pnpm Workspaces: A Complete Guide to Monorepo Management</title><link href="https://blog.glen-thomas.com/software%20engineering/2025/10/02/mastering-pnpm-workspaces-complete-guide-to-monorepo-management.html" rel="alternate" type="text/html" title="Mastering pnpm Workspaces: A Complete Guide to Monorepo Management" /><published>2025-10-02T11:00:00+01:00</published><updated>2025-10-02T11:00:00+01:00</updated><id>https://blog.glen-thomas.com/software%20engineering/2025/10/02/mastering-pnpm-workspaces-complete-guide-to-monorepo-management</id><content type="html" xml:base="https://blog.glen-thomas.com/software%20engineering/2025/10/02/mastering-pnpm-workspaces-complete-guide-to-monorepo-management.html"><![CDATA[<p>Managing multiple related packages and applications can quickly become a nightmare with traditional package managers. Enter <strong>pnpm workspaces</strong> – a powerful feature that transforms how we handle monorepos, offering superior performance, disk efficiency, and dependency management compared to npm or Yarn workspaces.</p>

<p>In this comprehensive tutorial, we’ll explore what pnpm workspaces are, why they’re game-changing for modern JavaScript development, and build a complete example monorepo from scratch.</p>

<h2 id="what-are-pnpm-workspaces">What are pnpm Workspaces?</h2>

<p>pnpm workspaces are a monorepo solution that allows you to manage multiple packages within a single repository while sharing dependencies efficiently. Unlike npm or Yarn, pnpm uses a unique <strong>content-addressable store</strong> that creates hard links to shared dependencies, dramatically reducing disk usage and installation time.</p>

<h3 id="key-benefits-of-pnpm-workspaces">Key Benefits of pnpm Workspaces</h3>

<ol>
  <li><strong>Disk Efficiency</strong>: Shared dependencies are stored once and linked across projects</li>
  <li><strong>Fast Installations</strong>: Hard linking eliminates duplicate downloads and extractions</li>
  <li><strong>Strict Dependency Management</strong>: Prevents phantom dependencies and version conflicts</li>
  <li><strong>Seamless Hoisting</strong>: Intelligent dependency hoisting without the pitfalls</li>
  <li><strong>Built-in Monorepo Support</strong>: No additional tools needed for workspace management</li>
</ol>

<h2 id="why-choose-pnpm-over-npmyarn-workspaces">Why Choose pnpm Over npm/Yarn Workspaces?</h2>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>pnpm</th>
      <th>npm</th>
      <th>Yarn</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Disk Usage</td>
      <td><strong>Minimal</strong> (hard links)</td>
      <td>High (duplicates)</td>
      <td>High (duplicates)</td>
    </tr>
    <tr>
      <td>Installation Speed</td>
      <td><strong>Fastest</strong></td>
      <td>Slow</td>
      <td>Moderate</td>
    </tr>
    <tr>
      <td>Phantom Dependencies</td>
      <td><strong>Prevented</strong></td>
      <td>Possible</td>
      <td>Possible</td>
    </tr>
    <tr>
      <td>Node Modules Structure</td>
      <td><strong>Strict</strong></td>
      <td>Flat (confusing)</td>
      <td>Flat (confusing)</td>
    </tr>
    <tr>
      <td>Workspace Features</td>
      <td><strong>Native</strong></td>
      <td>Basic</td>
      <td>Good</td>
    </tr>
  </tbody>
</table>

<h2 id="setting-up-our-example-monorepo">Setting Up Our Example Monorepo</h2>

<p>Let’s build a realistic monorepo with the following structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>my-workspace/
├── apps/
│   ├── web-app/          # React frontend
│   ├── api-server/       # Node.js backend
│   └── mobile-app/       # React Native app
├── packages/
│   ├── ui-components/    # Shared React components
│   ├── utils/           # Shared utilities
│   └── api-client/      # API client library
├── tools/
│   └── eslint-config/   # Shared ESLint configuration
├── package.json
└── pnpm-workspace.yaml
</code></pre></div></div>

<p>See my GitHub repo for full source code: <a href="https://github.com/glenthomas/Mastering-pnpm-Workspaces-Monorepo-Management">Mastering-pnpm-Workspaces-Monorepo-Management</a>.</p>

<h3 id="step-1-initialise-the-workspace">Step 1: Initialise the Workspace</h3>

<p>First, ensure you have pnpm installed globally:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> pnpm
<span class="c"># or</span>
curl <span class="nt">-fsSL</span> https://get.pnpm.io/install.sh | sh
</code></pre></div></div>

<p>Create the root directory and initialise:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>my-workspace
<span class="nb">cd </span>my-workspace
pnpm init
</code></pre></div></div>

<h3 id="step-2-configure-workspace-structure">Step 2: Configure Workspace Structure</h3>

<p>Create the workspace configuration file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># pnpm-workspace.yaml</span>
<span class="na">packages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">apps/*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">packages/*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">tools/*'</span>
</code></pre></div></div>

<p>Update the root <code class="language-plaintext highlighter-rouge">package.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"my-workspace"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pnpm -r build"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"test"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pnpm -r test"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"dev"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pnpm -r --parallel dev"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"lint"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pnpm -r lint"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"clean"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pnpm -r clean"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type-check"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pnpm -r type-check"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@types/node"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^20.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^5.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tsup"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^7.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vitest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^0.34.0"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="step-3-create-shared-packages">Step 3: Create Shared Packages</h3>

<h4 id="shared-utilities-package">Shared Utilities Package</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> packages/utils
<span class="nb">cd </span>packages/utils
pnpm init
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@workspace/utils"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"module"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.mjs"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"types"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.d.ts"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"exports"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"."</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"import"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.mjs"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"require"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.js"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"types"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.d.ts"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs,esm --dts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"dev"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs,esm --dts --watch"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"test"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vitest"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"clean"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rm -rf dist"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tsup"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vitest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Create the utility functions:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// packages/utils/src/index.ts</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">formatCurrency</span><span class="p">(</span><span class="nx">amount</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">currency</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">USD</span><span class="dl">'</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">new</span> <span class="nx">Intl</span><span class="p">.</span><span class="nc">NumberFormat</span><span class="p">(</span><span class="dl">'</span><span class="s1">en-US</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">style</span><span class="p">:</span> <span class="dl">'</span><span class="s1">currency</span><span class="dl">'</span><span class="p">,</span>
    <span class="nx">currency</span><span class="p">,</span>
  <span class="p">}).</span><span class="nf">format</span><span class="p">(</span><span class="nx">amount</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">debounce</span><span class="o">&lt;</span><span class="nx">T</span> <span class="nf">extends </span><span class="p">(...</span><span class="nx">args</span><span class="p">:</span> <span class="kr">any</span><span class="p">[])</span> <span class="o">=&gt;</span> <span class="kr">any</span><span class="o">&gt;</span><span class="p">(</span>
  <span class="nx">func</span><span class="p">:</span> <span class="nx">T</span><span class="p">,</span>
  <span class="nx">wait</span><span class="p">:</span> <span class="kr">number</span>
<span class="p">):</span> <span class="p">(...</span><span class="nx">args</span><span class="p">:</span> <span class="nb">Parameters</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="na">timeout</span><span class="p">:</span> <span class="nx">NodeJS</span><span class="p">.</span><span class="nx">Timeout</span><span class="p">;</span>
  <span class="k">return </span><span class="p">(...</span><span class="na">args</span><span class="p">:</span> <span class="nb">Parameters</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nf">clearTimeout</span><span class="p">(</span><span class="nx">timeout</span><span class="p">);</span>
    <span class="nx">timeout</span> <span class="o">=</span> <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">func</span><span class="p">.</span><span class="nf">apply</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="nx">args</span><span class="p">),</span> <span class="nx">wait</span><span class="p">);</span>
  <span class="p">};</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">slugify</span><span class="p">(</span><span class="nx">text</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">text</span>
    <span class="p">.</span><span class="nf">toLowerCase</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[^\w\s</span><span class="sr">-</span><span class="se">]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[\s</span><span class="sr">_-</span><span class="se">]</span><span class="sr">+/g</span><span class="p">,</span> <span class="dl">'</span><span class="s1">-</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/^-+|-+$/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">T</span> <span class="o">=</span> <span class="kr">any</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="na">data</span><span class="p">:</span> <span class="nx">T</span><span class="p">;</span>
  <span class="nl">success</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">;</span>
  <span class="nl">message</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">Logger</span> <span class="p">{</span>
  <span class="k">private</span> <span class="nx">context</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>

  <span class="nf">constructor</span><span class="p">(</span><span class="nx">context</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">context</span> <span class="o">=</span> <span class="nx">context</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="nf">info</span><span class="p">(</span><span class="nx">message</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="p">...</span><span class="nx">args</span><span class="p">:</span> <span class="kr">any</span><span class="p">[]):</span> <span class="k">void</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="s2">`[</span><span class="p">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">}</span><span class="s2">] </span><span class="p">${</span><span class="nx">message</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="p">...</span><span class="nx">args</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="nf">error</span><span class="p">(</span><span class="nx">message</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">error</span><span class="p">?:</span> <span class="nb">Error</span><span class="p">):</span> <span class="k">void</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="s2">`[</span><span class="p">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">}</span><span class="s2">] </span><span class="p">${</span><span class="nx">message</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="nf">warn</span><span class="p">(</span><span class="nx">message</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="p">...</span><span class="nx">args</span><span class="p">:</span> <span class="kr">any</span><span class="p">[]):</span> <span class="k">void</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">warn</span><span class="p">(</span><span class="s2">`[</span><span class="p">${</span><span class="k">this</span><span class="p">.</span><span class="nx">context</span><span class="p">}</span><span class="s2">] </span><span class="p">${</span><span class="nx">message</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="p">...</span><span class="nx">args</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Add TypeScript configuration:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">//</span><span class="w"> </span><span class="err">packages/utils/tsconfig.json</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"extends"</span><span class="p">:</span><span class="w"> </span><span class="s2">"../../tsconfig.json"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"compilerOptions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"outDir"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"rootDir"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./src"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"include"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"src/**/*"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"exclude"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"dist"</span><span class="p">,</span><span class="w"> </span><span class="s2">"node_modules"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h4 id="ui-components-package">UI Components Package</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> packages/ui-components
<span class="nb">cd </span>packages/ui-components
pnpm init
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@workspace/ui-components"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"module"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.mjs"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"types"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.d.ts"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"exports"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"."</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"import"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.mjs"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"require"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.js"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"types"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.d.ts"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"./styles"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/styles.css"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs,esm --dts --external react"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"dev"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs,esm --dts --external react --watch"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"test"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vitest"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"clean"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rm -rf dist"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"peerDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"react"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"react-dom"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.0.0"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@workspace/utils"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@types/react"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@types/react-dom"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tsup"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vitest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Create reusable components:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// packages/ui-components/src/Button.tsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">ButtonProps</span> <span class="p">{</span>
  <span class="nl">variant</span><span class="p">?:</span> <span class="dl">'</span><span class="s1">primary</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">secondary</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">danger</span><span class="dl">'</span><span class="p">;</span>
  <span class="nl">size</span><span class="p">?:</span> <span class="dl">'</span><span class="s1">sm</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">md</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">lg</span><span class="dl">'</span><span class="p">;</span>
  <span class="nl">disabled</span><span class="p">?:</span> <span class="nx">boolean</span><span class="p">;</span>
  <span class="nl">loading</span><span class="p">?:</span> <span class="nx">boolean</span><span class="p">;</span>
  <span class="nl">children</span><span class="p">:</span> <span class="nx">React</span><span class="p">.</span><span class="nx">ReactNode</span><span class="p">;</span>
  <span class="nl">onClick</span><span class="p">?:</span> <span class="p">(</span><span class="nx">event</span><span class="p">:</span> <span class="nx">React</span><span class="p">.</span><span class="nx">MouseEvent</span><span class="o">&lt;</span><span class="nx">HTMLButtonElement</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">Button</span><span class="p">({</span>
  <span class="nx">variant</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">primary</span><span class="dl">'</span><span class="p">,</span>
  <span class="nx">size</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">md</span><span class="dl">'</span><span class="p">,</span>
  <span class="nx">disabled</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span>
  <span class="nx">loading</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span>
  <span class="nx">children</span><span class="p">,</span>
  <span class="nx">onClick</span><span class="p">,</span>
<span class="p">}:</span> <span class="nx">ButtonProps</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">baseClasses</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">inline-flex items-center justify-center font-medium rounded-md transition-colors</span><span class="dl">'</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">variantClasses</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">primary</span><span class="p">:</span> <span class="dl">'</span><span class="s1">bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-300</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">secondary</span><span class="p">:</span> <span class="dl">'</span><span class="s1">bg-gray-200 text-gray-900 hover:bg-gray-300 disabled:bg-gray-100</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">danger</span><span class="p">:</span> <span class="dl">'</span><span class="s1">bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">};</span>
  <span class="kd">const</span> <span class="nx">sizeClasses</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">sm</span><span class="p">:</span> <span class="dl">'</span><span class="s1">px-3 py-1.5 text-sm</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">md</span><span class="p">:</span> <span class="dl">'</span><span class="s1">px-4 py-2 text-base</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">lg</span><span class="p">:</span> <span class="dl">'</span><span class="s1">px-6 py-3 text-lg</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">};</span>

  <span class="k">return </span><span class="p">(</span>
    <span class="o">&lt;</span><span class="nx">button</span>
      <span class="nx">className</span><span class="o">=</span><span class="p">{</span><span class="s2">`</span><span class="p">${</span><span class="nx">baseClasses</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">variantClasses</span><span class="p">[</span><span class="nx">variant</span><span class="p">]}</span><span class="s2"> </span><span class="p">${</span><span class="nx">sizeClasses</span><span class="p">[</span><span class="nx">size</span><span class="p">]}</span><span class="s2">`</span><span class="p">}</span>
      <span class="nx">disabled</span><span class="o">=</span><span class="p">{</span><span class="nx">disabled</span> <span class="o">||</span> <span class="nx">loading</span><span class="p">}</span>
      <span class="nx">onClick</span><span class="o">=</span><span class="p">{</span><span class="nx">onClick</span><span class="p">}</span>
    <span class="o">&gt;</span>
      <span class="p">{</span><span class="nx">loading</span> <span class="o">&amp;&amp;</span> <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">animate-spin mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full</span><span class="dl">"</span> <span class="o">/&gt;</span><span class="p">}</span>
      <span class="p">{</span><span class="nx">children</span><span class="p">}</span>
    <span class="o">&lt;</span><span class="sr">/button</span><span class="err">&gt;
</span>  <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// packages/ui-components/src/Card.tsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">CardProps</span> <span class="p">{</span>
  <span class="nl">title</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">children</span><span class="p">:</span> <span class="nx">React</span><span class="p">.</span><span class="nx">ReactNode</span><span class="p">;</span>
  <span class="nl">className</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">Card</span><span class="p">({</span> <span class="nx">title</span><span class="p">,</span> <span class="nx">children</span><span class="p">,</span> <span class="nx">className</span> <span class="o">=</span> <span class="dl">''</span> <span class="p">}:</span> <span class="nx">CardProps</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return </span><span class="p">(</span>
    <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="p">{</span><span class="s2">`bg-white rounded-lg shadow-md border border-gray-200 </span><span class="p">${</span><span class="nx">className</span><span class="p">}</span><span class="s2">`</span><span class="p">}</span><span class="o">&gt;</span>
      <span class="p">{</span><span class="nx">title</span> <span class="o">&amp;&amp;</span> <span class="p">(</span>
        <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">px-6 py-4 border-b border-gray-200</span><span class="dl">"</span><span class="o">&gt;</span>
          <span class="o">&lt;</span><span class="nx">h3</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">text-lg font-semibold text-gray-900</span><span class="dl">"</span><span class="o">&gt;</span><span class="p">{</span><span class="nx">title</span><span class="p">}</span><span class="o">&lt;</span><span class="sr">/h3</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>      <span class="p">)}</span>
      <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">p-6</span><span class="dl">"</span><span class="o">&gt;</span><span class="p">{</span><span class="nx">children</span><span class="p">}</span><span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>    <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>  <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// packages/ui-components/src/index.ts</span>
<span class="k">export</span> <span class="p">{</span> <span class="nx">Button</span><span class="p">,</span> <span class="kd">type</span> <span class="nx">ButtonProps</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./Button</span><span class="dl">'</span><span class="p">;</span>
<span class="k">export</span> <span class="p">{</span> <span class="nx">Card</span><span class="p">,</span> <span class="kd">type</span> <span class="nx">CardProps</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./Card</span><span class="dl">'</span><span class="p">;</span>
</code></pre></div></div>

<h4 id="api-client-package">API Client Package</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> packages/api-client
<span class="nb">cd </span>packages/api-client
pnpm init
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@workspace/api-client"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"module"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.mjs"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"types"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./dist/index.d.ts"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs,esm --dts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"dev"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs,esm --dts --watch"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"test"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vitest"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"clean"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rm -rf dist"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@workspace/utils"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tsup"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vitest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// packages/api-client/src/index.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">ApiResponse</span><span class="p">,</span> <span class="nx">Logger</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@workspace/utils</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">ApiClient</span> <span class="p">{</span>
  <span class="k">private</span> <span class="nx">baseUrl</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="k">private</span> <span class="nx">logger</span><span class="p">:</span> <span class="nx">Logger</span><span class="p">;</span>

  <span class="nf">constructor</span><span class="p">(</span><span class="nx">baseUrl</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">baseUrl</span> <span class="o">=</span> <span class="nx">baseUrl</span><span class="p">;</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">logger</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Logger</span><span class="p">(</span><span class="dl">'</span><span class="s1">ApiClient</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">private</span> <span class="k">async</span> <span class="nx">request</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span>
    <span class="nx">endpoint</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
    <span class="nx">options</span><span class="p">:</span> <span class="nx">RequestInit</span> <span class="o">=</span> <span class="p">{}</span>
  <span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="k">this</span><span class="p">.</span><span class="nx">baseUrl</span><span class="p">}${</span><span class="nx">endpoint</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
    
    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
          <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/json</span><span class="dl">'</span><span class="p">,</span>
          <span class="p">...</span><span class="nx">options</span><span class="p">.</span><span class="nx">headers</span><span class="p">,</span>
        <span class="p">},</span>
        <span class="p">...</span><span class="nx">options</span><span class="p">,</span>
      <span class="p">});</span>

      <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`HTTP error! status: </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
      <span class="p">}</span>

      <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">options</span><span class="p">.</span><span class="nx">method</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">GET</span><span class="dl">'</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">endpoint</span><span class="p">}</span><span class="s2"> - Success`</span><span class="p">);</span>
      
      <span class="k">return</span> <span class="p">{</span>
        <span class="nx">data</span><span class="p">,</span>
        <span class="na">success</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
      <span class="p">};</span>
    <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">options</span><span class="p">.</span><span class="nx">method</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">GET</span><span class="dl">'</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">endpoint</span><span class="p">}</span><span class="s2"> - Error`</span><span class="p">,</span> <span class="nx">error</span> <span class="kd">as </span><span class="nb">Error</span><span class="p">);</span>
      <span class="k">return</span> <span class="p">{</span>
        <span class="na">data</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
        <span class="na">success</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
        <span class="na">message</span><span class="p">:</span> <span class="nx">error</span> <span class="k">instanceof</span> <span class="nb">Error</span> <span class="p">?</span> <span class="nx">error</span><span class="p">.</span><span class="nx">message</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">Unknown error</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">};</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="kd">get</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">request</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">post</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">data</span><span class="p">:</span> <span class="kr">any</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">request</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">data</span><span class="p">),</span>
    <span class="p">});</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nx">put</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">data</span><span class="p">:</span> <span class="kr">any</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">request</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">PUT</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">data</span><span class="p">),</span>
    <span class="p">});</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="k">delete</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">request</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">endpoint</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">DELETE</span><span class="dl">'</span><span class="p">,</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">User</span> <span class="p">{</span>
  <span class="nl">id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">name</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">email</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">createdAt</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">Product</span> <span class="p">{</span>
  <span class="nl">id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">name</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nl">price</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
  <span class="nl">description</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">UserService</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">(</span><span class="k">private</span> <span class="nx">client</span><span class="p">:</span> <span class="nx">ApiClient</span><span class="p">)</span> <span class="p">{}</span>

  <span class="k">async</span> <span class="nf">getUsers</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">User</span><span class="p">[]</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">client</span><span class="p">.</span><span class="kd">get</span><span class="o">&lt;</span><span class="nx">User</span><span class="p">[]</span><span class="o">&gt;</span><span class="p">(</span><span class="dl">'</span><span class="s1">/users</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nf">getUser</span><span class="p">(</span><span class="nx">id</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">User</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">client</span><span class="p">.</span><span class="kd">get</span><span class="o">&lt;</span><span class="nx">User</span><span class="o">&gt;</span><span class="p">(</span><span class="s2">`/users/</span><span class="p">${</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">async</span> <span class="nf">createUser</span><span class="p">(</span><span class="nx">userData</span><span class="p">:</span> <span class="nb">Omit</span><span class="o">&lt;</span><span class="nx">User</span><span class="p">,</span> <span class="dl">'</span><span class="s1">id</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">createdAt</span><span class="dl">'</span><span class="o">&gt;</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ApiResponse</span><span class="o">&lt;</span><span class="nx">User</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">client</span><span class="p">.</span><span class="nx">post</span><span class="o">&lt;</span><span class="nx">User</span><span class="o">&gt;</span><span class="p">(</span><span class="dl">'</span><span class="s1">/users</span><span class="dl">'</span><span class="p">,</span> <span class="nx">userData</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="step-4-create-application-packages">Step 4: Create Application Packages</h3>

<h4 id="react-web-app">React Web App</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> apps/web-app
<span class="nb">cd </span>apps/web-app
pnpm init
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@workspace/web-app"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"dev"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vite"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsc &amp;&amp; vite build"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"preview"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vite preview"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"test"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vitest"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"clean"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rm -rf dist"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"react"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.2.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"react-dom"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.2.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@workspace/ui-components"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@workspace/api-client"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@workspace/utils"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@types/react"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.2.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@types/react-dom"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^18.2.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@vitejs/plugin-react"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vite"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.4.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vitest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"autoprefixer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^10.4.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"postcss"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^8.4.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tailwindcss"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.3.0"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Create a simple React app:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// apps/web-app/src/App.tsx</span>
<span class="k">import</span> <span class="nx">React</span><span class="p">,</span> <span class="p">{</span> <span class="nx">useState</span><span class="p">,</span> <span class="nx">useEffect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Button</span><span class="p">,</span> <span class="nx">Card</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@workspace/ui-components</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">ApiClient</span><span class="p">,</span> <span class="nx">UserService</span><span class="p">,</span> <span class="nx">User</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@workspace/api-client</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">formatCurrency</span><span class="p">,</span> <span class="nx">debounce</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@workspace/utils</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">apiClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ApiClient</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://jsonplaceholder.typicode.com</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">userService</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">UserService</span><span class="p">(</span><span class="nx">apiClient</span><span class="p">);</span>

<span class="kd">function</span> <span class="nf">App</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">users</span><span class="p">,</span> <span class="nx">setUsers</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="o">&lt;</span><span class="nx">User</span><span class="p">[]</span><span class="o">&gt;</span><span class="p">([]);</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">loading</span><span class="p">,</span> <span class="nx">setLoading</span><span class="p">]</span> <span class="o">=</span> <span class="nf">useState</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">searchTerm</span><span class="p">,</span> <span class="nx">setSearchTerm</span><span class="p">]</span> <span class="o">=</span> <span class="nf">useState</span><span class="p">(</span><span class="dl">''</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">debouncedSearch</span> <span class="o">=</span> <span class="nf">debounce</span><span class="p">((</span><span class="nx">term</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Searching for:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">term</span><span class="p">);</span>
    <span class="c1">// Implement search logic here</span>
  <span class="p">},</span> <span class="mi">300</span><span class="p">);</span>

  <span class="nf">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">loadUsers</span> <span class="o">=</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nf">setLoading</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
      <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">userService</span><span class="p">.</span><span class="nf">getUsers</span><span class="p">();</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">success</span><span class="p">)</span> <span class="p">{</span>
        <span class="nf">setUsers</span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">6</span><span class="p">));</span> <span class="c1">// Limit to 6 users for demo</span>
      <span class="p">}</span>
      <span class="nf">setLoading</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
    <span class="p">};</span>

    <span class="nf">loadUsers</span><span class="p">();</span>
  <span class="p">},</span> <span class="p">[]);</span>

  <span class="nf">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">searchTerm</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">debouncedSearch</span><span class="p">(</span><span class="nx">searchTerm</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">},</span> <span class="p">[</span><span class="nx">searchTerm</span><span class="p">,</span> <span class="nx">debouncedSearch</span><span class="p">]);</span>

  <span class="k">return </span><span class="p">(</span>
    <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">min-h-screen bg-gray-50 py-8</span><span class="dl">"</span><span class="o">&gt;</span>
      <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">max-w-6xl mx-auto px-4</span><span class="dl">"</span><span class="o">&gt;</span>
        <span class="o">&lt;</span><span class="nx">h1</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">text-3xl font-bold text-gray-900 mb-8</span><span class="dl">"</span><span class="o">&gt;</span>
          <span class="nx">pnpm</span> <span class="nx">Workspace</span> <span class="nx">Demo</span> <span class="nx">App</span>
        <span class="o">&lt;</span><span class="sr">/h1</span><span class="err">&gt;
</span>        
        <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">mb-6</span><span class="dl">"</span><span class="o">&gt;</span>
          <span class="o">&lt;</span><span class="nx">input</span>
            <span class="kd">type</span><span class="o">=</span><span class="dl">"</span><span class="s2">text</span><span class="dl">"</span>
            <span class="nx">placeholder</span><span class="o">=</span><span class="dl">"</span><span class="s2">Search users...</span><span class="dl">"</span>
            <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">w-full max-w-md px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500</span><span class="dl">"</span>
            <span class="nx">value</span><span class="o">=</span><span class="p">{</span><span class="nx">searchTerm</span><span class="p">}</span>
            <span class="nx">onChange</span><span class="o">=</span><span class="p">{(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">setSearchTerm</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">value</span><span class="p">)}</span>
          <span class="sr">/</span><span class="err">&gt;
</span>        <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>
        <span class="p">{</span><span class="nx">loading</span> <span class="p">?</span> <span class="p">(</span>
          <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">text-center py-8</span><span class="dl">"</span><span class="o">&gt;</span>
            <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto</span><span class="dl">"</span><span class="o">&gt;&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>            <span class="o">&lt;</span><span class="nx">p</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">mt-2 text-gray-600</span><span class="dl">"</span><span class="o">&gt;</span><span class="nx">Loading</span> <span class="nx">users</span><span class="p">...</span><span class="o">&lt;</span><span class="sr">/p</span><span class="err">&gt;
</span>          <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>        <span class="p">)</span> <span class="p">:</span> <span class="p">(</span>
          <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6</span><span class="dl">"</span><span class="o">&gt;</span>
            <span class="p">{</span><span class="nx">users</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">user</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">(</span>
              <span class="o">&lt;</span><span class="nx">Card</span> <span class="nx">key</span><span class="o">=</span><span class="p">{</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span> <span class="nx">title</span><span class="o">=</span><span class="p">{</span><span class="nx">user</span><span class="p">.</span><span class="nx">name</span><span class="p">}</span><span class="o">&gt;</span>
                <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">space-y-3</span><span class="dl">"</span><span class="o">&gt;</span>
                  <span class="o">&lt;</span><span class="nx">p</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">text-gray-600</span><span class="dl">"</span><span class="o">&gt;</span><span class="p">{</span><span class="nx">user</span><span class="p">.</span><span class="nx">email</span><span class="p">}</span><span class="o">&lt;</span><span class="sr">/p</span><span class="err">&gt;
</span>                  <span class="o">&lt;</span><span class="nx">p</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">text-sm text-gray-500</span><span class="dl">"</span><span class="o">&gt;</span>
                    <span class="na">Joined</span><span class="p">:</span> <span class="p">{</span><span class="k">new</span> <span class="nc">Date</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">createdAt</span> <span class="o">||</span> <span class="nb">Date</span><span class="p">.</span><span class="nf">now</span><span class="p">()).</span><span class="nf">toLocaleDateString</span><span class="p">()}</span>
                  <span class="o">&lt;</span><span class="sr">/p</span><span class="err">&gt;
</span>                  <span class="o">&lt;</span><span class="nx">p</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">text-lg font-semibold text-green-600</span><span class="dl">"</span><span class="o">&gt;</span>
                    <span class="na">Value</span><span class="p">:</span> <span class="p">{</span><span class="nf">formatCurrency</span><span class="p">(</span><span class="nb">Math</span><span class="p">.</span><span class="nf">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">1000</span> <span class="o">+</span> <span class="mi">100</span><span class="p">)}</span>
                  <span class="o">&lt;</span><span class="sr">/p</span><span class="err">&gt;
</span>                  <span class="o">&lt;</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">flex space-x-2</span><span class="dl">"</span><span class="o">&gt;</span>
                    <span class="o">&lt;</span><span class="nx">Button</span> <span class="nx">size</span><span class="o">=</span><span class="dl">"</span><span class="s2">sm</span><span class="dl">"</span> <span class="nx">variant</span><span class="o">=</span><span class="dl">"</span><span class="s2">primary</span><span class="dl">"</span><span class="o">&gt;</span>
                      <span class="nx">View</span> <span class="nx">Profile</span>
                    <span class="o">&lt;</span><span class="sr">/Button</span><span class="err">&gt;
</span>                    <span class="o">&lt;</span><span class="nx">Button</span> <span class="nx">size</span><span class="o">=</span><span class="dl">"</span><span class="s2">sm</span><span class="dl">"</span> <span class="nx">variant</span><span class="o">=</span><span class="dl">"</span><span class="s2">secondary</span><span class="dl">"</span><span class="o">&gt;</span>
                      <span class="nx">Send</span> <span class="nx">Message</span>
                    <span class="o">&lt;</span><span class="sr">/Button</span><span class="err">&gt;
</span>                  <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>                <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>              <span class="o">&lt;</span><span class="sr">/Card</span><span class="err">&gt;
</span>            <span class="p">))}</span>
          <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>        <span class="p">)}</span>
      <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>    <span class="o">&lt;</span><span class="sr">/div</span><span class="err">&gt;
</span>  <span class="p">);</span>
<span class="p">}</span>

<span class="k">export</span> <span class="k">default</span> <span class="nx">App</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// apps/web-app/src/main.tsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">ReactDOM</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-dom/client</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">App</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./App</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">./index.css</span><span class="dl">'</span><span class="p">;</span>

<span class="nx">ReactDOM</span><span class="p">.</span><span class="nf">createRoot</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">root</span><span class="dl">'</span><span class="p">)</span><span class="o">!</span><span class="p">).</span><span class="nf">render</span><span class="p">(</span>
  <span class="o">&lt;</span><span class="nx">React</span><span class="p">.</span><span class="nx">StrictMode</span><span class="o">&gt;</span>
    <span class="o">&lt;</span><span class="nx">App</span> <span class="o">/&gt;</span>
  <span class="o">&lt;</span><span class="sr">/React.StrictMode</span><span class="err">&gt;
</span><span class="p">);</span>
</code></pre></div></div>

<h4 id="nodejs-api-server">Node.js API Server</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> apps/api-server
<span class="nb">cd </span>apps/api-server
pnpm init
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@workspace/api-server"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"dev"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsx watch src/index.ts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsup src/index.ts --format cjs"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"start"</span><span class="p">:</span><span class="w"> </span><span class="s2">"node dist/index.js"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"test"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vitest"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"clean"</span><span class="p">:</span><span class="w"> </span><span class="s2">"rm -rf dist"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"express"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.18.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"cors"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^2.8.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@workspace/utils"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@workspace/api-client"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"devDependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@types/express"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.17.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@types/cors"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^2.8.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tsup"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tsx"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.12.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"vitest"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// apps/api-server/src/index.ts</span>
<span class="k">import</span> <span class="nx">express</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">cors</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">cors</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Logger</span><span class="p">,</span> <span class="nx">slugify</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@workspace/utils</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">User</span><span class="p">,</span> <span class="nx">Product</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@workspace/api-client</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nf">express</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">logger</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Logger</span><span class="p">(</span><span class="dl">'</span><span class="s1">ApiServer</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">PORT</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">PORT</span> <span class="o">||</span> <span class="mi">3001</span><span class="p">;</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">use</span><span class="p">(</span><span class="nf">cors</span><span class="p">());</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nf">json</span><span class="p">());</span>

<span class="c1">// Mock data</span>
<span class="kd">const</span> <span class="nx">users</span><span class="p">:</span> <span class="nx">User</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">1</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">John Doe</span><span class="dl">'</span><span class="p">,</span> <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">john@example.com</span><span class="dl">'</span><span class="p">,</span> <span class="na">createdAt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">2024-01-15</span><span class="dl">'</span> <span class="p">},</span>
  <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">2</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Jane Smith</span><span class="dl">'</span><span class="p">,</span> <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">jane@example.com</span><span class="dl">'</span><span class="p">,</span> <span class="na">createdAt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">2024-01-20</span><span class="dl">'</span> <span class="p">},</span>
  <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">3</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Bob Johnson</span><span class="dl">'</span><span class="p">,</span> <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">bob@example.com</span><span class="dl">'</span><span class="p">,</span> <span class="na">createdAt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">2024-02-01</span><span class="dl">'</span> <span class="p">},</span>
<span class="p">];</span>

<span class="kd">const</span> <span class="nx">products</span><span class="p">:</span> <span class="nx">Product</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">1</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Laptop</span><span class="dl">'</span><span class="p">,</span> <span class="na">price</span><span class="p">:</span> <span class="mf">999.99</span><span class="p">,</span> <span class="na">description</span><span class="p">:</span> <span class="dl">'</span><span class="s1">High-performance laptop</span><span class="dl">'</span> <span class="p">},</span>
  <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">2</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Phone</span><span class="dl">'</span><span class="p">,</span> <span class="na">price</span><span class="p">:</span> <span class="mf">699.99</span><span class="p">,</span> <span class="na">description</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Latest smartphone</span><span class="dl">'</span> <span class="p">},</span>
  <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">3</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Headphones</span><span class="dl">'</span><span class="p">,</span> <span class="na">price</span><span class="p">:</span> <span class="mf">199.99</span><span class="p">,</span> <span class="na">description</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Wireless headphones</span><span class="dl">'</span> <span class="p">},</span>
<span class="p">];</span>

<span class="c1">// Routes</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/health</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">({</span> <span class="na">status</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ok</span><span class="dl">'</span><span class="p">,</span> <span class="na">timestamp</span><span class="p">:</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">().</span><span class="nf">toISOString</span><span class="p">()</span> <span class="p">});</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/users</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="dl">'</span><span class="s1">Fetching all users</span><span class="dl">'</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="nx">users</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/users/:id</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">users</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="nx">u</span> <span class="o">=&gt;</span> <span class="nx">u</span><span class="p">.</span><span class="nx">id</span> <span class="o">===</span> <span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">id</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">user</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nf">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">User not found</span><span class="dl">'</span> <span class="p">});</span>
  <span class="p">}</span>
  <span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="s2">`Fetching user </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/users</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">email</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">;</span>
  <span class="kd">const</span> <span class="na">newUser</span><span class="p">:</span> <span class="nx">User</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">id</span><span class="p">:</span> <span class="p">(</span><span class="nx">users</span><span class="p">.</span><span class="nx">length</span> <span class="o">+</span> <span class="mi">1</span><span class="p">).</span><span class="nf">toString</span><span class="p">(),</span>
    <span class="nx">name</span><span class="p">,</span>
    <span class="nx">email</span><span class="p">,</span>
    <span class="na">createdAt</span><span class="p">:</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">().</span><span class="nf">toISOString</span><span class="p">(),</span>
  <span class="p">};</span>
  <span class="nx">users</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="nx">newUser</span><span class="p">);</span>
  <span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="s2">`Created user </span><span class="p">${</span><span class="nx">newUser</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">201</span><span class="p">).</span><span class="nf">json</span><span class="p">(</span><span class="nx">newUser</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/products</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="dl">'</span><span class="s1">Fetching all products</span><span class="dl">'</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="nx">products</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/products/search</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">query</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">q</span> <span class="kd">as </span><span class="kr">string</span><span class="p">;</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">query</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="nx">products</span><span class="p">);</span>
  <span class="p">}</span>
  
  <span class="kd">const</span> <span class="nx">searchSlug</span> <span class="o">=</span> <span class="nf">slugify</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">filtered</span> <span class="o">=</span> <span class="nx">products</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> 
    <span class="nf">slugify</span><span class="p">(</span><span class="nx">p</span><span class="p">.</span><span class="nx">name</span><span class="p">).</span><span class="nf">includes</span><span class="p">(</span><span class="nx">searchSlug</span><span class="p">)</span> <span class="o">||</span> 
    <span class="nf">slugify</span><span class="p">(</span><span class="nx">p</span><span class="p">.</span><span class="nx">description</span><span class="p">).</span><span class="nf">includes</span><span class="p">(</span><span class="nx">searchSlug</span><span class="p">)</span>
  <span class="p">);</span>
  
  <span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="s2">`Searching products for: </span><span class="p">${</span><span class="nx">query</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="nx">filtered</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">listen</span><span class="p">(</span><span class="nx">PORT</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="s2">`Server running on port </span><span class="p">${</span><span class="nx">PORT</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<h3 id="step-5-shared-tools-and-configuration">Step 5: Shared Tools and Configuration</h3>

<h4 id="eslint-configuration-package">ESLint Configuration Package</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> tools/eslint-config
<span class="nb">cd </span>tools/eslint-config
pnpm init
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@workspace/eslint-config"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"index.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@typescript-eslint/eslint-plugin"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^6.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"@typescript-eslint/parser"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^6.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"eslint"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^8.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"eslint-plugin-react"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^7.33.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"eslint-plugin-react-hooks"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^4.6.0"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// tools/eslint-config/index.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">parser</span><span class="p">:</span> <span class="dl">'</span><span class="s1">@typescript-eslint/parser</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">extends</span><span class="p">:</span> <span class="p">[</span>
    <span class="dl">'</span><span class="s1">eslint:recommended</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">@typescript-eslint/recommended</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">plugin:react/recommended</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">plugin:react-hooks/recommended</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">],</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">@typescript-eslint</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">react-hooks</span><span class="dl">'</span><span class="p">],</span>
  <span class="na">rules</span><span class="p">:</span> <span class="p">{</span>
    <span class="dl">'</span><span class="s1">@typescript-eslint/no-unused-vars</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">error</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">@typescript-eslint/no-explicit-any</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">warn</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">react/react-in-jsx-scope</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">off</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">react/prop-types</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">off</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">},</span>
  <span class="na">settings</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">react</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">version</span><span class="p">:</span> <span class="dl">'</span><span class="s1">detect</span><span class="dl">'</span><span class="p">,</span>
    <span class="p">},</span>
  <span class="p">},</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="step-6-root-configuration-files">Step 6: Root Configuration Files</h3>

<p>Create TypeScript configuration for the workspace:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">//</span><span class="w"> </span><span class="err">tsconfig.json</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"compilerOptions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"target"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ES2020"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"lib"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"DOM"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DOM.Iterable"</span><span class="p">,</span><span class="w"> </span><span class="s2">"ES6"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"allowJs"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"skipLibCheck"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"esModuleInterop"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"allowSyntheticDefaultImports"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"strict"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"forceConsistentCasingInFileNames"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"moduleResolution"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bundler"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"resolveJsonModule"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"isolatedModules"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"noEmit"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"jsx"</span><span class="p">:</span><span class="w"> </span><span class="s2">"react-jsx"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"declaration"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"declarationMap"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"sourceMap"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"paths"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"@workspace/*"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"./packages/*/src"</span><span class="p">,</span><span class="w"> </span><span class="s2">"./apps/*/src"</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"include"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"apps/**/*"</span><span class="p">,</span><span class="w"> </span><span class="s2">"packages/**/*"</span><span class="p">,</span><span class="w"> </span><span class="s2">"tools/**/*"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"exclude"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"node_modules"</span><span class="p">,</span><span class="w"> </span><span class="s2">"**/dist"</span><span class="p">,</span><span class="w"> </span><span class="s2">"**/build"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="advanced-pnpm-workspace-commands">Advanced pnpm Workspace Commands</h2>

<h3 id="installation-and-dependency-management">Installation and Dependency Management</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install dependencies for all packages</span>
pnpm <span class="nb">install</span>

<span class="c"># Add a dependency to a specific package</span>
pnpm add react <span class="nt">--filter</span> @workspace/web-app

<span class="c"># Add a dev dependency to all packages</span>
pnpm add <span class="nt">-D</span> prettier <span class="nt">--filter</span> <span class="s2">"*"</span>

<span class="c"># Add a workspace dependency</span>
pnpm add @workspace/utils <span class="nt">--filter</span> @workspace/api-server
</code></pre></div></div>

<h3 id="running-scripts">Running Scripts</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run build script in all packages</span>
pnpm <span class="nt">-r</span> build

<span class="c"># Run dev script in parallel for all packages</span>
pnpm <span class="nt">-r</span> <span class="nt">--parallel</span> dev

<span class="c"># Run script in specific package</span>
pnpm <span class="nt">--filter</span> @workspace/web-app dev

<span class="c"># Run script in packages matching pattern</span>
pnpm <span class="nt">--filter</span> <span class="s2">"./apps/*"</span> build
</code></pre></div></div>

<h3 id="advanced-filtering">Advanced Filtering</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run commands on packages that depend on @workspace/utils</span>
pnpm <span class="nt">--filter</span> <span class="s2">"...@workspace/utils"</span> build

<span class="c"># Run commands on @workspace/utils and its dependents</span>
pnpm <span class="nt">--filter</span> <span class="s2">"@workspace/utils..."</span> build

<span class="c"># Run commands on changed packages (requires git)</span>
pnpm <span class="nt">--filter</span> <span class="s2">"[HEAD^1]"</span> build
</code></pre></div></div>

<h2 id="performance-benefits-in-action">Performance Benefits in Action</h2>

<p>Let’s see the real impact of pnpm workspaces:</p>

<h3 id="disk-usage-comparison">Disk Usage Comparison</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Traditional approach with separate repositories</span>
npm <span class="nb">install</span>  <span class="c"># ~200MB per project × 6 projects = ~1.2GB</span>

<span class="c"># With pnpm workspace</span>
pnpm <span class="nb">install</span>  <span class="c"># ~250MB total (shared dependencies)</span>
</code></pre></div></div>

<h3 id="installation-speed">Installation Speed</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Cold install</span>
<span class="nb">time </span>pnpm <span class="nb">install</span>  <span class="c"># ~15 seconds</span>

<span class="c"># Subsequent installs (with cache)</span>
<span class="nb">time </span>pnpm <span class="nb">install</span>  <span class="c"># ~2 seconds</span>
</code></pre></div></div>

<h3 id="dependency-analysis">Dependency Analysis</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check which packages use specific dependencies</span>
pnpm list react <span class="nt">--depth</span><span class="o">=</span>Infinity

<span class="c"># Audit dependencies across workspace</span>
pnpm audit

<span class="c"># Check outdated packages</span>
pnpm outdated
</code></pre></div></div>

<h2 id="best-practices-and-tips">Best Practices and Tips</h2>

<h3 id="1-use-workspace-protocol">1. <strong>Use Workspace Protocol</strong></h3>
<p>Always use <code class="language-plaintext highlighter-rouge">workspace:*</code> for internal dependencies to ensure version consistency:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"@workspace/utils"</span><span class="p">:</span><span class="w"> </span><span class="s2">"workspace:*"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="2-organise-by-purpose">2. <strong>Organise by Purpose</strong></h3>
<p>Structure your workspace logically:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">apps/</code> - Applications and services</li>
  <li><code class="language-plaintext highlighter-rouge">packages/</code> - Reusable libraries</li>
  <li><code class="language-plaintext highlighter-rouge">tools/</code> - Development tools and configs</li>
</ul>

<h3 id="3-centralise-configuration">3. <strong>Centralise Configuration</strong></h3>
<p>Share configuration files across packages:</p>
<ul>
  <li>Root <code class="language-plaintext highlighter-rouge">tsconfig.json</code> with package-specific extensions</li>
  <li>Shared ESLint, Prettier, and test configurations</li>
  <li>Unified build scripts in root package.json</li>
</ul>

<h3 id="4-version-management">4. <strong>Version Management</strong></h3>
<p>Use semantic versioning and consider tools like Changeset for version management:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pnpm add <span class="nt">-Dw</span> @changesets/cli
pnpm changeset init
</code></pre></div></div>

<h3 id="5-cicd-optimisation">5. <strong>CI/CD Optimisation</strong></h3>
<p>Leverage pnpm’s filtering for efficient CI/CD:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/ci.yml</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build affected packages</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm --filter "[HEAD^1]" build</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Test changed packages</span>  
  <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm --filter "[HEAD^1]" test</span>
</code></pre></div></div>

<h2 id="troubleshooting-common-issues">Troubleshooting Common Issues</h2>

<h3 id="phantom-dependencies">Phantom Dependencies</h3>
<p>pnpm prevents phantom dependencies by default, but you might encounter issues:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check for phantom dependencies</span>
pnpm audit <span class="nt">--audit-level</span> moderate

<span class="c"># Fix by adding missing dependencies</span>
pnpm add missing-package <span class="nt">--filter</span> affected-package
</code></pre></div></div>

<h3 id="hoisting-issues">Hoisting Issues</h3>
<p>If you need to hoist specific packages:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .pnpmrc</span>
<span class="s">public-hoist-pattern[]=*eslint*</span>
<span class="s">public-hoist-pattern[]=*prettier*</span>
</code></pre></div></div>

<h3 id="version-conflicts">Version Conflicts</h3>
<p>Handle version conflicts across workspace:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check version conflicts</span>
pnpm list <span class="nt">--depth</span><span class="o">=</span>Infinity | <span class="nb">grep </span>conflict

<span class="c"># Resolve by updating to compatible versions</span>
pnpm update <span class="nt">--filter</span> <span class="s2">"*"</span>
</code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>pnpm workspaces represent a significant evolution in JavaScript package management, offering superior performance, strict dependency management, and excellent monorepo support. By implementing the patterns shown in this tutorial, you’ll achieve:</p>

<ul>
  <li><strong>60-80% reduction in disk usage</strong> compared to traditional approaches</li>
  <li><strong>3-5x faster installation times</strong> through intelligent caching and linking</li>
  <li><strong>Elimination of phantom dependencies</strong> and version conflicts</li>
  <li><strong>Streamlined development workflow</strong> across multiple related packages</li>
</ul>

<p>The example workspace we’ve built demonstrates real-world patterns including shared utilities, UI components, API clients, and multiple applications all working together seamlessly. This foundation can scale to support enterprise-level monorepos with dozens of packages and applications.</p>

<p>Start small with a few related packages, then gradually expand your workspace as your project grows. The investment in setting up pnpm workspaces pays dividends in development velocity, dependency management, and build performance.</p>

<p>Happy coding with pnpm workspaces! 🚀</p>]]></content><author><name>Glen Thomas</name></author><category term="Software Engineering" /><category term="Software Engineering" /><category term="JavaScript" /><category term="TypeScript" /><category term="Monorepo" /><category term="Package Management" /><summary type="html"><![CDATA[Managing multiple related packages and applications can quickly become a nightmare with traditional package managers. Enter pnpm workspaces – a powerful feature that transforms how we handle monorepos, offering superior performance, disk efficiency, and dependency management compared to npm or Yarn workspaces.]]></summary></entry></feed>