[{"content":"If you run your own blog or portfolio site on your own, this post is for you. Every deploy pipeline has to decide what blocks a release and what just warns. I worked that out the hard way, on this site.\nI rebuilt it with Claude last month, moving the site to Hugo. Once it was live, I checked what the rebuild broke, by hand. A link checker script first, then a pass through the real W3C HTML checker, page by page. It found two dead internal links, five broken image references, and one page shipping invalid HTML. Fixing those was quick. The real work was making sure I never had to go looking for them again.\nHere\u0026rsquo;s what I ended up building:\nflowchart TD Build[Build the site] --\u0026gt; Links[Check links and images] Links --\u0026gt; Redirects[Check legacy redirects] Redirects --\u0026gt; Tags[Check tag registry] Tags -.-\u0026gt; HTML[Validate HTML] HTML --\u0026gt; A11y[Accessibility check] A11y --\u0026gt; Deploy[Deploy] Four checks can stop a release outright. One only warns. A sixth, external to all of this, runs on its own schedule and never touches the gate at all.\nWhy this had to be CI #CI, continuous integration, means a fixed set of checks runs automatically on every change, before any of it ships.\nA manual pass catches whatever I remember to check, the day I remember to check it. It catches nothing on the next change unless I repeat it, by hand, forever. The real failure mode isn\u0026rsquo;t missing something once. It\u0026rsquo;s having nothing in place to stop me from missing it again.\nCI runs the same checks, the same way, on every push, whether I\u0026rsquo;m paying attention or not. It doesn\u0026rsquo;t get tired at 11pm, and it doesn\u0026rsquo;t skip a step because the change \u0026ldquo;looked small.\u0026rdquo; That consistency is the whole value. A check I only run when I remember to isn\u0026rsquo;t a check. It\u0026rsquo;s a to-do item I\u0026rsquo;m already failing.\nNone of this is specific to a blog. It\u0026rsquo;s the same reasoning behind any CI/CD pipeline, whether you\u0026rsquo;re shipping a Lambda function or a set of Kubernetes manifests. Catch the regression before a human has to, and catch it the same way every time, regardless of who\u0026rsquo;s pushing or how confident they feel about the change.\nThe GitHub part #The pipeline is GitHub Actions, the same one from From Ansible to GitHub Actions + Terraform. A push to main triggers a workflow that runs a single job on one of GitHub\u0026rsquo;s hosted runners, five steps start to finish:\nflowchart TD Checkout[Check out the repo] --\u0026gt; Build[Build the site] Build --\u0026gt; QA[Run each QA check] QA --\u0026gt; Sync[Sync to S3] Sync --\u0026gt; Invalidate[Invalidate the CDN cache] The gating isn\u0026rsquo;t a feature I turned on. It\u0026rsquo;s how a job already works. Each step has to succeed before the next one runs. A failing check stops the job right there, and the deploy step never runs. The broken version never reaches S3. I didn\u0026rsquo;t bolt a gatekeeping system onto the pipeline. I just wrote a deploy.yml and configured the steps I needed, in order. Each check is a small Python script that exits non-zero when something\u0026rsquo;s wrong. Here\u0026rsquo;s an example, with the real bucket names and paths swapped out:\nname: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Build site run: hugo --minify - name: Check links and images run: python3 check_links.py public - name: Check redirects run: python3 check_redirects.py public - name: Check tags run: python3 check_tags.py content continue-on-error: true - name: Validate HTML run: html5validator --root public - name: Accessibility check run: python3 check_accessibility.py public - name: Deploy run: ./deploy.sh Every step after Build site is just a command that exits non-zero when something\u0026rsquo;s wrong. That\u0026rsquo;s the whole trick.\nGitHub shows me that same stop point without any extra tooling, too. A red X on the commit, a failed run in the Actions tab, a job summary that shows exactly which step broke. I don\u0026rsquo;t have to go looking for why a deploy didn\u0026rsquo;t happen. GitHub already tells me.\nThat\u0026rsquo;s the part worth taking even if you\u0026rsquo;re not running a static site, or not on GitHub at all. You probably don\u0026rsquo;t need a dedicated quality gate product. You need your CI job to run the right commands and treat a non-zero exit code as the failure it is. Every CI platform I\u0026rsquo;ve used already works this way. Gating isn\u0026rsquo;t a premium feature anyone has to buy.\nVerify the check actually works #The three bugs took minutes to fix. What mattered was turning the two checks that found them into a permanent step, so the next version of the same bug fails a build instead of reaching a reader.\nA portfolio site\u0026rsquo;s whole job is to make a good first impression. Dead links and broken images undercut that immediately, and they\u0026rsquo;re the kind of thing a visitor notices even if they can\u0026rsquo;t say why the page feels off. Invalid HTML isn\u0026rsquo;t just a technicality either. Browsers patch over malformed markup in inconsistent, undocumented ways, so a page can look fine in whatever browser I tested it in and break somewhere else, for someone I\u0026rsquo;ll never hear from.\nOne script walks every page the build produces and confirms every internal link and image reference resolves to something real, not just to a page that technically exists but renders nothing. The other runs the same open-source validator behind the public W3C HTML checker, packaged to run locally instead of hitting someone else\u0026rsquo;s server for every page.\nNeither went live until I proved it worked. I broke a link on purpose and confirmed the checker caught it, then reintroduced the exact invalid HTML and confirmed the validator caught that too. That step is easy to skip, since a check that imports cleanly and runs without errors looks finished. It isn\u0026rsquo;t. A check that\u0026rsquo;s never been shown to catch the bug it was written for isn\u0026rsquo;t a check. It\u0026rsquo;s a guess that happens to run in CI.\nWhen to warn and when to block #Once those two were in place, I kept asking what else a real QA pass would catch that I\u0026rsquo;d just gotten lucky on. That turned into several more checks, and the harder question wasn\u0026rsquo;t what to check. It was what should actually block a deploy.\nA broken link or invalid HTML is real breakage. A legacy URL losing its redirect target is real breakage too, just quieter. Someone\u0026rsquo;s old bookmark goes from a working redirect to a dead page, with nothing to notice. Both block.\nAn unregistered content tag is a different kind of problem. Nothing breaks. The page ships, and a piece of metadata falls back to a default instead of its intended value. That\u0026rsquo;s worth a warning, not a blocked deploy, so the check reports and moves on.\nMatching the response to the actual blast radius, not just the existence of a problem, is the part that\u0026rsquo;s easy to skip and worth doing anyway.\nA check that can\u0026rsquo;t run in CI isn\u0026rsquo;t protecting anything #Screen readers, keyboard navigation, anyone who isn\u0026rsquo;t browsing with a mouse and a monitor set up exactly like mine, all depend on markup that actually describes what\u0026rsquo;s on the page. An image with no accessible name is invisible to that entire category of visitor, and nothing about how the page looks warns me it\u0026rsquo;s there. That\u0026rsquo;s the bug class this check exists for.\nThe accessibility check seemed done the moment it passed on my laptop, right up until it hit CI and failed outright on the first two real deploys. A GitHub-hosted runner isn\u0026rsquo;t the same environment as a laptop. Different user permissions, no display, a browser launched with none of the defaults my own machine quietly provides. A check that depends on a headless browser can hit that difference in ways that have nothing to do with what it\u0026rsquo;s actually testing. Getting it running in CI at all took real debugging, separate from whatever it would eventually find once it did.\nA check that only works where you happen to be testing isn\u0026rsquo;t a gate. It\u0026rsquo;s a demo. CI is the environment that matters, it\u0026rsquo;s the one standing between a change and production, and it\u0026rsquo;s the one I\u0026rsquo;d never actually watched the check run in until I made myself watch it fail there first.\nDon\u0026rsquo;t gate your own release on someone else\u0026rsquo;s uptime #The last check walks every external link on the site. It doesn\u0026rsquo;t run on deploy. It runs on its own weekly schedule and never fails a build, on purpose. A dead link on someone else\u0026rsquo;s page has nothing to do with anything I shipped, and holding my deploy hostage to a third party\u0026rsquo;s uptime trades a real cost for no real benefit. Keeping that check from crying wolf on the same handful of flaky domains every week is its own, longer story. That\u0026rsquo;s for later.\nFind it once, fix for good #Two dead internal links. Five broken image references. One page shipping invalid HTML. An accessibility check that passed on my laptop and failed twice in CI before I found out why. That\u0026rsquo;s the actual list of bugs this pipeline has caught, and it\u0026rsquo;s not long. Every item on it would still be live right now if I\u0026rsquo;d only found it once, by hand, and never checked again.\nThat\u0026rsquo;s the real lesson, more than any single check. Find a bug once, then build something that keeps finding it, DRY (don\u0026rsquo;t repeat yourself) applied to QA instead of code. The specific tools don\u0026rsquo;t matter if you\u0026rsquo;re not running a static site. The decision behind each one does: block on real breakage, warn on cosmetic drift, verify a check actually runs where it matters, never let your release depend on someone else\u0026rsquo;s uptime.\nIf you maintain anything on your own, start with the one thing that\u0026rsquo;s already bitten you. Write a check for it, verify the check catches it, then put it in whatever CI you\u0026rsquo;re already running.\nNone of this came from a best-practices guide or a framework\u0026rsquo;s defaults. Every decision here came from a real bug that actually happened.\n","date":"October 3, 2026","permalink":"https://danphillips.cloud/posts/ci-qa-gate/","section":"Posts","summary":"","title":"CI Quality Gates on GitHub Actions: What to Block and When to Warn"},{"content":"","date":null,"permalink":"https://danphillips.cloud/","section":"Dan Phillips - Enterprise Technology Consultant \u0026 Cloud Architecture Specialist","summary":"","title":"Dan Phillips - Enterprise Technology Consultant \u0026 Cloud Architecture Specialist"},{"content":"","date":null,"permalink":"https://danphillips.cloud/posts/","section":"Posts","summary":"","title":"Posts"},{"content":"This post covers how I connected a local Ollama model to both Cline and Claude Code so each tool can delegate routine work to it.\nPart 1 sets up Cline in VS Code pointing at a local Ollama model. Part 2 adds an MCP server so Claude Code can delegate to it too. Local models have gotten good enough to handle real coding tasks. Running one costs nothing per token. Use it for the routine work and reach for the Claude API when the task actually needs it. Less spend, same output.\nPrerequisites #I\u0026rsquo;m on a MacBook Pro (M4 Pro, 24GB, macOS Sequoia 15.7.7).\nCommands use $HOME instead of a hardcoded username. It maps to your home directory automatically. Confirm with echo $HOME.\nOllama installed and running Cline installed in VS Code (search \u0026ldquo;Cline\u0026rdquo; in Extensions, install the one by Cline Bot Inc.) Claude Code CLI installed Python 3.12+ Part 1 — Cline + Local Ollama #Cline is an open-source coding agent for VS Code. It reads files, runs commands, and iterates on the output. Not just autocomplete. It also has a native Ollama provider, which means it connects to the local model directly rather than through a generic adapter. That matters for context window control, which is what Step 2 covers.\nStep 1 — Clone the repo and pull the model #Clone the local-llm-mcp repo first. Everything in Part 1 and Part 2 lives here. $HOME/dev/local-llm-mcp is the path used throughout. Change it to wherever you keep projects and update CLAUDE.md in Part 2 Step 2 to match:\ngit clone https://github.com/danphillips-cloud/local-llm-mcp.git $HOME/dev/local-llm-mcp cd $HOME/dev/local-llm-mcp Pull your model. I\u0026rsquo;m using qwen3-coder:30b-a3b-q4_K_M but any Ollama model works:\nollama pull qwen3-coder:30b-a3b-q4_K_M Run ollama list and copy the exact name from the NAME column, tag included. The tag matters. qwen3-coder:30b-a3b-q4_K_M and qwen3-coder are different entries. A mismatched tag is the most common reason a model silently fails to load.\nStep 2 — Bump the context window to 16k #A Modelfile is the blueprint Ollama uses to build and configure a model variant. The repo includes one already. It points at qwen3-coder:30b-a3b-q4_K_M and sets num_ctx to 16384. If you\u0026rsquo;re using a different model, open Modelfile and update the FROM line before running the next command.\nOllama\u0026rsquo;s default context window is 2048 tokens. That\u0026rsquo;s not enough for real coding tasks. File content gets truncated and the model loses earlier context mid-task. 16384 covers most real files and is well within what 24GB handles at 30B Q4.\nRun this to build the model variant:\nollama create qwen3-coder-16k -f Modelfile This builds qwen3-coder-16k:latest as a new layer on top of the existing weights. No re-download. From here on, Cline and the MCP server both use this model.\nOn 16GB unified memory, a 30B Q4 model likely won\u0026rsquo;t load at all. The weights alone need around 16-18GB before macOS overhead. Dropping num_ctx won\u0026rsquo;t help if the model can\u0026rsquo;t fit in RAM. The qwen3-coder family only comes in 30B and 480B, so 16GB users need a different model family. qwen2.5-coder:14b is a solid alternative. Update the FROM line in the Modelfile and OLLAMA_MODEL in .env to match.\nStep 3 — Configure Cline #Open VS Code and click the Cline icon in the left sidebar. Click the settings icon (⚙️) in the top right of the Cline panel to open API Configuration.\nCline supports cloud providers (Anthropic, OpenAI, OpenRouter) and local models via Ollama or LM Studio. This guide uses Ollama. Fill in the fields below.\nField Value API Provider Ollama Use custom base URL checked (set to http://localhost:11434) Ollama API Key leave empty Model qwen3-coder-16k:latest Model Context Window 16384 Request Timeout (ms) 120000 The default 30-second timeout isn\u0026rsquo;t enough for a 30B model on a real task. Set it to 120000.\nClick Done to return to the main Cline view.\nWarnings after selecting Ollama: You may see \u0026ldquo;Does not support MCP and Focus Chain\u0026rdquo; and \u0026ldquo;Less capable models may not\u0026hellip;\u0026rdquo;. Both are safe to ignore. The first is about Cline\u0026rsquo;s internal features, not Part 2\u0026rsquo;s MCP server. The second is a blanket disclaimer for any non-Claude model.\nOne thing that trips people up: leave the Context Window field empty and Cline silently defaults to 32768, overriding the value you set in the Modelfile. Always set it explicitly to match.\nNote: Cline\u0026rsquo;s UI and configuration options change frequently. If these steps look different, check the Cline docs for the latest setup instructions.\nStep 4 — Verify Cline reads file context #The repo includes utils.py with a slugify function. I\u0026rsquo;m using it to confirm Cline is reading actual code, not just answering prompts. In your own project, swap in any file.\nClick utils.py in the Explorer to open it. In the Cline panel you\u0026rsquo;ll see \u0026ldquo;What can I do for you?\u0026rdquo; with a \u0026ldquo;Type your task here\u0026hellip;\u0026rdquo; input at the bottom.\nType @ in the input to open the context menu. From here you can attach files, folders, terminal output, git commits, and more. \"Add File\" lets you browse your project. Select utils.py to add it to the prompt. Add the file as context by typing @ and selecting utils.py from the list, or clicking the + icon in the bottom bar. Then type:\n@utils.py Write a failing pytest test for the `slugify` function that checks it strips leading and trailing whitespace before slugifying. In your own project, this prompt structure works for any function: @yourfile.py Write a failing test for [function name] that checks [behaviour].\nNote: Make sure Act is selected in the bottom bar, not Plan. Plan mode only describes what Cline would do. Act mode actually does it.\nHit send. Cline reads the file first to understand the current implementation before writing anything.\nCline reads the file before writing anything. That's what makes the output accurate rather than generic. Then it checks the environment. If pytest isn\u0026rsquo;t installed, it asks for approval before running anything.\nCline asks before installing anything. Click Resume Task to continue. Click Resume Task. Cline installs pytest, writes the tests, and runs them.\n2 passed. The local model wrote tests that cover normal cases and edge cases. Cline summarizes what it changed and offers to walk you through the diff. It didn\u0026rsquo;t just write the test. It spotted that the original implementation was incomplete and fixed it. That\u0026rsquo;s the part I didn\u0026rsquo;t expect.\nIf the output references functions or imports that don\u0026rsquo;t exist in your file, Cline isn\u0026rsquo;t reading it. Make sure you added it via @ or + and that the context window is large enough (Step 2).\nPart 2 — Claude Code MCP Server #Part 1 got Cline talking to the local model directly. Part 2 lets Claude Code do the same thing. The difference is Claude Code stays in control. It decides when to call the local model, reviews the output, and integrates it into the broader task.\nKey difference: The local model never sees the full conversation. It only gets the specific file and instruction passed to it.\nThe server has three tools:\nTool What it does generate_code Write new functions, classes, or files from a description write_tests Generate tests for a specific function or method refactor Targeted rewrites: extract, rename, simplify, add types Step 1 — Set up the Python environment #The repo is already cloned from Part 1. Set up the venv:\ncd $HOME/dev/local-llm-mcp python3.12 -m venv .venv source .venv/bin/activate pip install -r requirements.txt Package note: requirements.txt pins two packages: mcp\u0026gt;=1.2.0 (Anthropic\u0026rsquo;s Python SDK, which bundles FastMCP) and httpx\u0026gt;=0.27.0 for async HTTP to Ollama. There\u0026rsquo;s a separate fastmcp package that uses a different import path. Don\u0026rsquo;t mix the two.\nStep 2 — Check CLAUDE.md #CLAUDE.md is the file Claude Code reads at the start of every session. It\u0026rsquo;s already in the repo. If you cloned to a different path, update the paths inside.\nStep 3 — Configure the model #The repo includes .env.example. Duplicate it in VS Code and rename the copy to .env. The defaults work as-is. The server calls Ollama\u0026rsquo;s /api/chat endpoint and passes num_ctx on every request:\nOLLAMA_HOST=http://localhost:11434 OLLAMA_MODEL=qwen3-coder:30b-a3b-q4_K_M OLLAMA_NUM_CTX=16384 OLLAMA_TIMEOUT=120 To swap models, change OLLAMA_MODEL and restart the Claude Code session. No code changes needed.\nStep 4 — Register with Claude Code #Run this once. It writes the server into your user-global config so it\u0026rsquo;s available in every Claude Code session. (Claude Code MCP docs)\nFirst confirm the venv Python exists. If it\u0026rsquo;s missing, the command registers a broken path with no warning:\nls $HOME/dev/local-llm-mcp/.venv/bin/python Then register:\nclaude mcp add --transport stdio ollama-coder \\ --scope user \\ -- $HOME/dev/local-llm-mcp/.venv/bin/python \\ $HOME/dev/local-llm-mcp/server.py Claude Code stores the expanded path, so the output will show /Users/yourname/... rather than $HOME. That\u0026rsquo;s expected.\nSuccessful registration:\nAdded stdio MCP server ollama-coder to user config File modified: /Users/yourname/.claude.json Claude Code stores MCP servers in ~/.claude.json. Not ~/.claude/mcp.json. Not ~/.claude/settings.json. Some older guides get this wrong. The official MCP docs have the correct scope table.\nIf you rename or move the local-llm-mcp directory, re-run the claude mcp add command. The path in ~/.claude.json won\u0026rsquo;t update automatically.\nVerify registration with claude mcp list, then open a Claude Code session and run /mcp to confirm it\u0026rsquo;s connected:\nollama-coder shows as connected with 3 tools. Any other MCPs you have registered will also appear here. VS Code popup: opening the repo in VS Code may trigger a notification about .env terminal injection being disabled. Dismiss it. The server reads .env directly at startup.\nStep 5 — Set up auto-start on Mac (launchd) #The MCP server starts on-demand when Claude Code needs it. What has to be running is ollama serve, and ideally the model is already loaded. Cold-loading a 30B model can take long enough to hit the timeout on the first call.\nA launchd agent handles both at login. Per-user agents live in ~/Library/LaunchAgents/.\nFirst, confirm where Ollama is installed:\nwhich ollama On Apple Silicon it\u0026rsquo;s /opt/homebrew/bin/ollama. On Intel it\u0026rsquo;s /usr/local/bin/ollama. The plist in the repo is already set for Apple Silicon. If you\u0026rsquo;re on Intel, open $HOME/dev/local-llm-mcp/launchd/com.local.ollama-warmup.plist and update both path references.\nInstall and load the agent:\ncp $HOME/dev/local-llm-mcp/launchd/com.local.ollama-warmup.plist \\ ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/com.local.ollama-warmup.plist Verify:\nlaunchctl list | grep ollama-warmup A working result:\n15611 0 com.local.ollama-warmup The PID will differ. 0 means the agent ran without errors. A - means it hasn\u0026rsquo;t run yet and will at next login. Non-zero means something failed: check /tmp/ollama-warmup.err.log. Common causes are a wrong Ollama path (re-run which ollama) or incorrect line endings if the plist was edited on Windows.\nTo remove:\nlaunchctl unload ~/Library/LaunchAgents/com.local.ollama-warmup.plist rm ~/Library/LaunchAgents/com.local.ollama-warmup.plist Step 6 — Test from a Claude Code session #utils.py from Part 1 is still in the repo. This time Claude Code reads the file and passes it to the local model via the MCP server. Swap in your own file and function for real project work.\nStart a Claude Code session:\ncd $HOME/dev/local-llm-mcp claude Then:\nUse the ollama-coder write_tests tool to generate a pytest test for the `slugify` function in utils.py. The same structure works for the other tools:\nUse the ollama-coder generate_code tool to write a Python function that [description] in [filename]. Use the ollama-coder refactor tool to [specific change] in [filename]. Claude Code reads the file first, then calls the local tool. The local model only sees what it needs. Both tests pass. The local model did the work. Claude Code read the file, called the local tool, got the output, and flagged an edge case to revisit. No API call for the test generation itself. Point it at any function in any project and the same flow applies.\nIf something goes wrong, the server returns specific error messages:\nCould not reach Ollama: ollama serve isn\u0026rsquo;t running Check that model exists: run ollama list and fix OLLAMA_MODEL in .env timed out after 120s: model is cold-loading. Check /tmp/ollama-warmup.out.log or run ollama run qwen3-coder:30b-a3b-q4_K_M \u0026quot;\u0026quot; manually to pre-load it. Troubleshooting #Cline shows empty responses #Context window is too small for the file. Confirm qwen3-coder-16k is selected and Context Window is set to 16384.\nollama list doesn\u0026rsquo;t show qwen3-coder-16k #The ollama create step didn\u0026rsquo;t run or failed. Re-run Part 1 Step 2. The base model has to be present first.\nMCP tools don\u0026rsquo;t appear in /mcp #If Claude Code opens with \u0026ldquo;1 setup issue: MCP\u0026rdquo;, run /doctor. It identifies the cause and walks you through fixing it. Stale paths in ~/.claude.json are the most common culprit.\n/doctor caught a stale path from an old registration and proposed the fix. The MCP servers section pinpoints exactly which server is not connected and why. Otherwise, run claude mcp list first. If it shows disconnected, run the server manually to see the error:\n$HOME/dev/local-llm-mcp/.venv/bin/python $HOME/dev/local-llm-mcp/server.py First tool call times out #The model is still loading. Check /tmp/ollama-warmup.out.log, or run ollama run qwen3-coder:30b-a3b-q4_K_M \u0026quot;\u0026quot; to pre-load it manually.\n","date":"June 22, 2026","permalink":"https://danphillips.cloud/posts/ollama-coder/","section":"Posts","summary":"","title":"Running a Local AI Coding Agent with Ollama, Cline, and Claude Code"},{"content":"I set up a Raspberry Pi 4 as a Kubernetes node running k3s. This covers everything from hardware to a working cluster with remote kubectl access.\nIf you are studying for the KCNA or CKA, or just want hands-on time outside of a managed service, this is a good way to do it. Running it yourself on real hardware shows you what a managed cluster handles for you. If you want a structured lab to go alongside this, I put one together: Studying for KCNA by Actually Building Things.\nHardware #The 4GB or 8GB Pi 4 works well as a Kubernetes node. Skip the 1GB and 2GB models. Kubernetes takes up memory before your workloads run, and you will constantly be fighting resource pressure on a 2GB board.\nSD Card #SD card performance matters more than people expect. Kubernetes is constantly reading and writing small files: etcd state, container layers, kubelet checkpoints, logs. A slow card causes cascading failures that are genuinely confusing to debug. Look for at least A1 class and do a bit of research on current recommended brands before buying.\nLonger term, booting from a USB SSD is the better call. SD cards have limited write endurance and k8s workloads are write-heavy.\nOperating System #Use Ubuntu Server 24.04 LTS (64-bit ARM). A few reasons:\n64-bit is required. Most k8s tooling does not support 32-bit. LTS means 5 years of support (until 2029). Non-LTS releases have a 9-month window and force disruptive upgrades on cluster nodes. No desktop environment. All resources go to workloads. Most Kubernetes documentation and community guides assume Ubuntu. That matters when you are debugging at 11pm. How you flash the image depends on your OS. The Raspberry Pi Imager is the easiest option and handles most of it for you, but there are other tools depending on what you are working with. Worth looking up the right approach for your setup.\nWhy k3s Instead of Full Kubernetes #Full Kubernetes with kubeadm is too heavy for a Pi. etcd alone is 200-300MB. The control plane components together are 1GB+ at idle. On a 4GB board that leaves very little room for actual workloads.\nk3s is a CNCF-certified distribution built for exactly this. It replaces etcd with SQLite, bundles everything into a single binary, and idles at around 500-600MB. It is fully API-compatible with upstream Kubernetes, so everything you learn here applies to a production cluster.\nFirst Boot #Give it a couple minutes. Ubuntu Server runs cloud-init on first boot and it takes a moment. Then:\nssh yourusername@yourhostname.local One thing that bit me: if you use a terminal like Ghostty, the remote shell might not recognize your $TERM value and TUI apps like nano will break. Fix it:\necho \u0026#39;export TERM=xterm-256color\u0026#39; \u0026gt;\u0026gt; ~/.bashrc \u0026amp;\u0026amp; source ~/.bashrc Then update and reboot:\nsudo apt update \u0026amp;\u0026amp; sudo apt upgrade -y sudo reboot Networking #Use ethernet. Node IP addresses must not change or k8s will treat instability as node failure and start evicting pods.\nWiFi works for a home lab, but any connectivity hiccup shows up as a node failure. That can send you chasing the wrong problem.\nUbuntu 24.04 uses Netplan for network config. Edit /etc/netplan/50-cloud-init.yaml:\nnetwork: version: 2 ethernets: eth0: dhcp4: false addresses: - 192.168.1.x/24 routes: - to: default via: 192.168.1.1 nameservers: addresses: - 192.168.1.1 - 8.8.8.8 sudo netplan apply sudo reboot After reboot, verify the static assignment:\nip a show eth0 # Look for: valid_lft forever (no \u0026#34;dynamic\u0026#34;) YAML is whitespace-sensitive and paste artifacts will corrupt the file. Write it from scratch rather than trying to fix a corrupted version.\nKubernetes Prerequisites #These steps configure the Linux kernel and runtime environment that Kubernetes depends on. Do all four before installing k3s. The official reference for all of this is the Kubernetes container runtimes doc.\n1. Enable cgroups v2 #Control groups (cgroups) are how the Linux kernel enforces resource limits on processes: CPU, memory, I/O. Kubernetes relies on them to enforce the limits you set on containers. On a Raspberry Pi, memory cgroups are not enabled by default. Without them, the kubelet either fails to start or runs in degraded mode, and container memory limits are not enforced. That means nodes can run out of memory (OOM) and start killing processes without Kubernetes being aware. The k3s docs call this out explicitly for Raspberry Pi.\nsudo sed -i \u0026#39;$ s/$/ cgroup_enable=memory cgroup_memory=1/\u0026#39; /boot/firmware/cmdline.txt Verify:\ncat /boot/firmware/cmdline.txt # cgroup_enable=memory cgroup_memory=1 should appear at the end of the line 2. Disable Swap #By default, the kubelet will not start on a node that has swap enabled. The scheduler makes decisions based on available memory. Swap lets the OS silently use disk as memory, which makes resource accounting unreliable. The Kubernetes swap documentation covers the details. Swap support is in beta for cgroup v2 nodes as of 1.28, but disabling it is still the recommended default and what k3s assumes.\n# Disable immediately sudo swapoff -a # Persist across reboots sudo sed -i \u0026#39;/ swap / s/^/#/\u0026#39; /etc/fstab # Verify (swap row should show 0B 0B 0B) free -h 3. Load Required Kernel Modules #Two modules are required. Both are called out in the Kubernetes container runtimes prerequisites.\noverlay is the filesystem driver containerd uses to manage container layers efficiently. Instead of copying files for each container, it stacks layers on top of each other: the image is read-only at the bottom, and a thin writable layer sits on top for that container\u0026rsquo;s changes.\nbr_netfilter allows iptables rules to see traffic crossing Linux bridge interfaces. Without it, pod-to-pod traffic crosses the bridge and bypasses iptables entirely, which breaks kube-proxy and network policies.\ncat \u0026lt;\u0026lt;EOF | sudo tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfilter 4. Configure Sysctl Parameters #Also from the Kubernetes container runtimes prerequisites:\ncat \u0026lt;\u0026lt;EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sudo sysctl --system What these do:\nnet.bridge.bridge-nf-call-iptables = 1: passes bridged IPv4 traffic through iptables. Required for kube-proxy and network policies. The ip6tables line does the same for IPv6. net.ipv4.ip_forward = 1: lets the node forward packets between interfaces. Required for pod-to-pod traffic across nodes. Container Runtime #Kubernetes does not run containers directly. It talks to a container runtime via the Container Runtime Interface (CRI). containerd is the standard.\nsudo apt install -y containerd The default config needs two changes for Kubernetes:\nsudo mkdir -p /etc/containerd containerd config default | sudo tee /etc/containerd/config.toml sudo sed -i \u0026#39;s/SystemdCgroup = false/SystemdCgroup = true/\u0026#39; /etc/containerd/config.toml sudo systemctl restart containerd sudo systemctl enable containerd The SystemdCgroup = true setting matters. Ubuntu uses systemd as its init system, and systemd owns the cgroup hierarchy for every service it runs. If containerd uses a different driver, you end up with two processes managing the same resource hierarchy. The containerd docs call this a violation of the \u0026ldquo;single-writer\u0026rdquo; rule. The Kubernetes docs are direct about the result: two cgroup managers produce two different views of available resources, which causes instability under load.\nsudo systemctl status containerd # Look for: Active: active (running) Installing k3s #curl -sfL https://get.k3s.io | sh - One command. It detects ARM64, pulls the right binary, creates a systemd service, sets up kubectl and crictl symlinks, and writes a kubeconfig to /etc/rancher/k3s/k3s.yaml.\nsudo systemctl status k3s sudo k3s kubectl get nodes Expected output:\nNAME STATUS ROLES AGE VERSION yourhostname Ready control-plane Xm v1.35.x+k3s1 Two-node cluster, both Ready. Yes, I name my machines after X-Men. What k3s Installs Out of the Box #Unlike a bare kubeadm install, k3s ships with a full working stack. After installation you will find these already running in kube-system:\nkubectl get pods -n kube-system flowchart TD classDef external fill:#718096,stroke:#4a5568,color:#fff classDef traffic fill:#4a90e2,stroke:#2c6fad,color:#fff classDef support fill:#48bb78,stroke:#38a169,color:#fff classDef pod fill:#ed8936,stroke:#dd6b20,color:#fff REQ([\u0026#34;External Request\u0026#34;]):::external subgraph sys[\u0026#34;kube-system\u0026#34;] SLB[\u0026#34;ServiceLB\\ngives Traefik a real IP\u0026#34;]:::traffic TRF[\u0026#34;Traefik\\nroutes by host / path\u0026#34;]:::traffic DNS[\u0026#34;CoreDNS\\nresolves service names\u0026#34;]:::support MS[\u0026#34;metrics-server\\nCPU + memory stats\u0026#34;]:::support LPP[\u0026#34;local-path-provisioner\\nPersistentVolumes on node disk\u0026#34;]:::support end SVC[\u0026#34;ClusterIP Service\u0026#34;]:::traffic POD(\u0026#34;Your Pod\u0026#34;):::pod REQ --\u0026gt; SLB --\u0026gt; TRF --\u0026gt; SVC --\u0026gt; POD DNS -. \u0026#34;name → IP\u0026#34; .-\u0026gt; SVC MS -. \u0026#34;monitors\u0026#34; .-\u0026gt; POD LPP -. \u0026#34;mounts PV\u0026#34; .-\u0026gt; POD CoreDNS is the cluster\u0026rsquo;s internal DNS server. Every pod and service gets a DNS name. When a pod says \u0026ldquo;connect to my-service\u0026rdquo;, CoreDNS resolves it to an IP. Without it pods can only talk by IP, which breaks almost everything.\nTraefik is the default ingress controller. Listens on 80 and 443, routes HTTP traffic to services by hostname.\nServiceLB is k3s\u0026rsquo;s bare-metal load balancer. In a cloud environment a LoadBalancer service provisions a cloud LB automatically. On bare metal there is no cloud provider. ServiceLB fills that gap by running a pod on each node that binds to the host network and forwards traffic.\nmetrics-server: collects CPU and memory usage from every node and pod. Required for kubectl top and horizontal pod autoscaling.\nlocal-path-provisioner: creates PersistentVolumes as directories on the node\u0026rsquo;s local filesystem. Fine for a single node. For multi-node clusters, look at Longhorn instead.\nRemote kubectl Access #Install kubectl on your daily driver using the official install guide. The kubeconfig on the Pi is owned by root, so copy it to the user\u0026rsquo;s home directory first before pulling it down.\nOn the Pi:\nsudo cp /etc/rancher/k3s/k3s.yaml ~/k3s.yaml sudo chown yourusername:yourusername ~/k3s.yaml On your local machine:\nmkdir -p ~/.kube scp yourusername@pi-ip:~/k3s.yaml ~/.kube/config # k3s sets the server address to 127.0.0.1 by default. Fix it. sed -i \u0026#39;\u0026#39; \u0026#39;s/127.0.0.1/your-pi-static-ip/g\u0026#39; ~/.kube/config kubectl get nodes Then clean up the temp file on the Pi:\nrm ~/k3s.yaml k9s is worth installing once things are running. It is a terminal UI for cluster management and makes navigating pods, logs, and events a lot faster than raw kubectl commands.\nIf you use Colima on macOS, it manages its own kubeconfig context separately. The k3s context and the Colima context coexist without conflict. Use kubectl config get-contexts to see all available contexts and kubectl config use-context to switch between them.\nAdding Worker Nodes #Get the join token from the control plane:\nsudo cat /var/lib/rancher/k3s/server/node-token On the worker node, after completing all the prerequisites above:\ncurl -sfL https://get.k3s.io | K3S_URL=https://your-control-plane-ip:6443 K3S_TOKEN=your-node-token sh - kubectl get nodes # New node shows role: none initially, then worker ARM64 and Multi-Architecture Clusters #The Pi 4 is ARM64. Mix it with x86_64 nodes and you have a multi-architecture cluster. Most images on Docker Hub are multi-arch, but some are x86_64 only and will fail to schedule on ARM nodes without any obvious error.\nkubectl label node your-node-name kubernetes.io/arch=arm64 Worth knowing. Real-world clusters increasingly include ARM nodes like AWS Graviton. If a pod won\u0026rsquo;t schedule and you can\u0026rsquo;t figure out why, architecture mismatch is on the list.\nCommon Issues #Node shows NotReady after reboot #cat /boot/firmware/cmdline.txt # verify cgroups are still set sudo systemctl status containerd # containerd running? sudo journalctl -u k3s -f # k3s logs High memory pressure #free -h kubectl top nodes # requires metrics-server k3s idles at 500-600MB. On a 4GB Pi that leaves about 3.4GB for workloads. If things are tight, check whether your pods have resource limits set.\nSD card degrading over time #sudo iostat -x 1 If performance is dropping, move to USB SSD boot. It is the right call for anything you want to keep running reliably.\nPod networking issues #lsmod | grep br_netfilter sysctl net.bridge.bridge-nf-call-iptables sudo k3s kubectl logs -n kube-system -l app=flannel Deploying Your First Workload #Once the cluster is up, deploy nginx and verify the full networking stack works end to end.\nkubectl create deployment nginx --image=nginx kubectl get pods -w # Wait for STATUS: Running Expose it as a service:\nkubectl expose deployment nginx --port=80 --type=ClusterIP A ClusterIP service gives the deployment a stable internal IP and a DNS name inside the cluster. The service name becomes its DNS entry, so nginx resolves to the service IP from anywhere inside the cluster.\nSpin up a temporary pod and curl the nginx service by name:\nkubectl run curl --image=curlimages/curl --rm -it --restart=Never -- curl http://nginx You should get the nginx welcome page back. The --rm flag deletes the pod when it exits. That one command hits pod scheduling, container runtime, service networking, DNS resolution, and pod-to-pod communication. If it works, the cluster is healthy.\nWhere to Go From Here #A working single-node cluster is a starting point. Here is what to build toward next.\nExpose workloads via Traefik Ingress #A ClusterIP service is only reachable inside the cluster. To access a workload from your browser, you need an Ingress resource. Traefik is already running on 80 and 443, so you just define the routing rules: deploy, expose as ClusterIP, write an Ingress. The Traefik docs cover the rest.\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx annotations: traefik.ingress.kubernetes.io/router.entrypoints: web spec: rules: - host: nginx.local http: paths: - path: / pathType: Prefix backend: service: name: nginx port: number: 80 kubectl apply -f nginx-ingress.yaml Add 192.168.1.x nginx.local to /etc/hosts on your workstation and open it in a browser. Traefik handles the rest.\nAdd a worker node #A second node gives Kubernetes something to actually schedule across. Any machine running Ubuntu Server works: another Pi, an old laptop, a mini PC.\nDeploy something real #Replace the toy workloads with something you would actually use. These all run well on ARM64:\nUptime Kuma: service monitoring with alerts. Good intro to PersistentVolumes. Pi-hole: network-wide DNS ad blocking. Requires host networking, teaches DaemonSets. Gitea: self-hosted Git. Multi-component app, teaches multi-service deployments. Homer: home lab dashboard. Simple stateless app, good for practicing Ingress. If you are working toward the KCNA, my lab Studying for KCNA by Actually Building Things pairs well with a cluster like this one.\nVersion Reference # Component Version Hardware Raspberry Pi 4 Model B 4GB OS Ubuntu Server 24.04 LTS (64-bit ARM) Kernel 6.8.x containerd via apt (Ubuntu 24.04 repos) k3s v1.35.5+k3s1 (at time of writing) Kubernetes API v1.35.5 (at time of writing) ","date":"May 27, 2026","permalink":"https://danphillips.cloud/posts/rpi4-k3s/","section":"Posts","summary":"","title":"Running k3s on a Raspberry Pi 4"},{"content":"I shipped two infrastructure migrations in a single push last week. GitHub Actions for deployments, Terraform for infrastructure. The site never went down.\nHere\u0026rsquo;s what changed, why, and what bit me along the way.\nBefore: the manual deploy problem #The stack before this work wasn\u0026rsquo;t bad. It just had friction.\nInfrastructure was defined in CloudFormation templates: S3, CloudFront, Route53, RUM, Cognito, IAM. Deployments were triggered manually via Ansible playbooks. There was no CI/CD. Merging to main did exactly nothing.\nEvery deploy looked like this:\naws login ansible-playbook upload.yml ansible-playbook deploy.yml aws login handled the session and Ansible picked it up automatically. The credentials weren\u0026rsquo;t the friction. Everything else was. The process was tightly coupled to my machine, my memory, my manual trigger. Forget to run the playbook after a merge? The site is stale. Want someone else to deploy? They\u0026rsquo;d need to set up the same tooling from scratch.\nIt worked, but it was the kind of thing that breaks the moment you stop babysitting it.\nMigration 1: GitHub Actions for deployment #The fix is a single workflow file. Push to main, site deploys. Done.\non: push: branches: - main permissions: id-token: write # OIDC JWT contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - uses: aws-actions/configure-aws-credentials@v6.1.0 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ca-central-1 - uses: actions/setup-python@v6.2.0 with: python-version: \u0026#34;3.12\u0026#34; cache: \u0026#34;pip\u0026#34; - run: pip install -r backend/requirements.txt - run: python3 backend/build_blog.py - run: aws s3 sync frontend/public/ s3://${{ env.S3_BUCKET }}/ --delete - run: aws cloudfront create-invalidation --distribution-id ${{ env.CLOUDFRONT_DISTRIBUTION_ID }} --paths \u0026#34;/*\u0026#34; Checkout, authenticate, build, sync, invalidate. About 60 seconds end to end.\nOIDC: no stored credentials #The authentication step is the part worth understanding. The workflow authenticates to AWS using OIDC. GitHub generates a short-lived JWT at runtime, AWS exchanges it for temporary credentials. There are no long-lived IAM access keys stored anywhere. No secrets to rotate. No keys to leak.\nGetting this working required three things:\nAn OIDC identity provider registered in IAM (token.actions.githubusercontent.com) An IAM role with a trust policy scoped to the specific repo and branch pattern The role ARN stored as a GitHub Actions secret The trust policy condition is the piece that actually enforces the scope: repo:your-org/your-repo:ref:refs/heads/*. Only branches in that repo can assume the role. If you use a wildcard on the org or leave the repo condition off, you\u0026rsquo;ve opened this up to every repo in the account. That\u0026rsquo;s a problem.\nLeast-privilege IAM #The role the workflow assumes has exactly what the deploy steps need and nothing else:\ns3:PutObject, s3:DeleteObject, s3:ListBucket, s3:GetObject (scoped to the www bucket) cloudfront:CreateInvalidation (scoped to the www distribution) Because the policy references Terraform resource attributes (aws_s3_bucket.www.arn, aws_cloudfront_distribution.www.arn), it stays in sync automatically if those resources ever change.\nGotcha: Node.js version warnings #The standard action versions (actions/checkout@v4, etc.) target Node 20, which GitHub deprecated. I went through two attempts:\nFirst I set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true as a workflow env var. Actions ran on Node 24, but the warning was still there.\nThe actual fix was upgrading to action versions that natively declare node24 as their runtime:\nactions/checkout@v6.0.2 actions/setup-python@v6.2.0 aws-actions/configure-aws-credentials@v6.1.0 Warning gone.\nMigration 2: CloudFormation → Terraform #CloudFormation worked. I moved off it anyway. Two reasons.\nVerbosity. A CloudFront distribution in CloudFormation YAML is hundreds of lines. The same resource in Terraform HCL is significantly shorter and actually readable. This matters when you\u0026rsquo;re reviewing an infrastructure change. You want to understand what changed, not parse through YAML boilerplate.\nDrift detection. CloudFormation doesn\u0026rsquo;t tell you when a resource has been changed outside the stack. Terraform\u0026rsquo;s plan output makes drift visible immediately. Run terraform plan and you\u0026rsquo;ll see exactly what AWS has versus what your config says.\nThe practical argument is also there: Terraform is more widely used in the industry. Worth having a live example of it.\nWhat\u0026rsquo;s under management #Everything was imported, nothing was recreated. All resources stayed live throughout.\nFour files:\nfrontend.tf: S3 buckets, public access blocks, bucket policies, website config, CloudFront OAC, both CloudFront distributions, Route53 A records rum.tf: Cognito identity pool, IAM role and policy, CloudWatch RUM app monitor iam.tf: GitHub Actions OIDC provider, IAM role, inline deploy policy main.tf: backend config, provider Two things intentionally left out: the ACM certificate and the Route53 hosted zone. Both pre-exist, and neither is safe to let Terraform recreate. They\u0026rsquo;re referenced via data sources.\nRemote state #Remote state lives in a dedicated S3 bucket in ca-central-1.\nI used Terraform 1.10+ native S3 locking (use_lockfile = true). Worth calling out because older guides still tell you to create a DynamoDB table for locking. You no longer need that. See S3 native state locking in the Terraform docs.\nThe import process #The import loop was straightforward but tedious:\nWrite the resource block Run terraform import \u0026lt;resource_address\u0026gt; \u0026lt;aws_resource_id\u0026gt; Run terraform plan If it shows changes, align the config with actual AWS state Repeat until plan is clean Order matters. You have to import dependencies before the resources that reference them.\nThe hard part was CloudFront. The distribution config has a lot of optional fields, and Terraform\u0026rsquo;s defaults don\u0026rsquo;t always match what AWS actually has set. I went through several rounds of plan, adjust, plan before it was clean. It wasn\u0026rsquo;t broken, just annoying.\nCleanup after import #Once the plan was clean, I did a pass to make it readable:\nExtracted CloudFront managed policy UUIDs into named locals (readable names instead of raw GUIDs in the config) Replaced hardcoded ARNs with Terraform resource references Used var.domain_name in the RUM config instead of a hardcoded string Used the website_endpoint attribute for the apex bucket origin instead of a hardcoded regional endpoint The imports get you to \u0026ldquo;working.\u0026rdquo; The cleanup pass gets you to \u0026ldquo;maintainable.\u0026rdquo;\nWhat deploy looks like now #Merge to main. That\u0026rsquo;s it.\nGitHub Actions picks it up, authenticates via OIDC, builds the blog, syncs to S3, invalidates CloudFront. Site is live in about 60 seconds. No local tooling required. No credential setup. No playbooks to remember.\nInfrastructure changes go through terraform plan locally, reviewed before terraform apply. Changes are visible, reviewable, and tracked.\nOne honest regression worth mentioning: with Ansible, aws login was enough and the session just worked. Terraform doesn\u0026rsquo;t go through the AWS CLI credential chain the same way, so running it locally requires an extra step first:\neval $(aws configure export-credentials --format env) terraform apply An alias handles this in practice. But it\u0026rsquo;s a real extra step that didn\u0026rsquo;t exist before, and it\u0026rsquo;s worth knowing going in.\nThe Ansible playbooks are still in the repo under infrastructure/playbooks/ for reference. But they\u0026rsquo;re not part of any workflow anymore.\nThe before state wasn\u0026rsquo;t broken. It just required me to babysit it. This doesn\u0026rsquo;t.\n","date":"May 3, 2026","permalink":"https://danphillips.cloud/posts/github-actions-terraform/","section":"Posts","summary":"","title":"From Ansible to GitHub Actions + Terraform"},{"content":"Most \u0026ldquo;host a website on AWS\u0026rdquo; tutorials start with an EC2 instance. You set up a VPC, configure security groups, SSH in, install a web server. It works, but it\u0026rsquo;s a lot of overhead for a static site.\nThere\u0026rsquo;s a simpler approach. I built my resume site using the Cloud Resume Challenge stack: five managed AWS services, no servers, no VPC, and a total cost of $0.50/month for DNS. Here\u0026rsquo;s how it works and what I learned building it.\nHow the architecture works #The architecture splits into two parts: serving the site, and handling the visitor counter.\nServing the site: S3 + CloudFront + Route 53 #Static files live in S3. CloudFront sits in front of it as a CDN, handles HTTPS via ACM, caches files at edge locations globally, and keeps the S3 bucket private through Origin Access Control. Nobody hits S3 directly. Route 53 maps your custom domain to the CloudFront distribution.\nThe visitor counter: API Gateway + Lambda + DynamoDB #When the page loads, a JavaScript fetch call hits an API Gateway endpoint, which triggers a Lambda function, which increments a counter in DynamoDB and returns the value. Lambda runs only when called so there\u0026rsquo;s no idle compute cost. DynamoDB is overkill for a single counter but it\u0026rsquo;s free tier eligible and scales to whatever traffic you throw at it.\nNothing here runs in a VPC. Lambda runs in AWS\u0026rsquo;s managed environment by default, which is free tier eligible and removes a whole layer of networking overhead. Most tutorials put Lambda inside a VPC. For this use case, you don\u0026rsquo;t need to.\nInfra as code and deployment #The stack is defined in CloudFormation with AWS SAM handling the Lambda and API Gateway pieces. Ansible playbooks automate deployment and file sync, including CloudFront cache invalidation on every upload. No manual console steps. The full repo is at danphillips-cloud/cloud-resume-challenge.\nAWS vs GCP: same project, different experience #I deployed the same site on GCP using Terraform, Cloud Functions, Firestore, and Cloudflare for CDN. Same architecture, completely different experience. GCP IAM is genuinely complex with organization policies, project-level restrictions, and service-specific permissions stacked together. I was hitting permission walls constantly and had to grant Firestore access to the Cloud Function service account manually because org policy blocked Terraform from touching that binding. AWS IAM felt simple by comparison.\nHaving CloudFormation and Terraform side by side in the same repo was the point. Multi-cloud means understanding that every provider works differently, and you only really learn that by actually building on them.\nWhat bit me #CORS lives in two places. API Gateway handles the OPTIONS preflight. Lambda still has to return CORS headers on every response, including errors. Misconfigure either one and the failures look unrelated to CORS.\nRegional vs Edge endpoints matter in CloudFormation. Regional and Edge API Gateway endpoints use different CloudFormation attributes. Use DistributionDomainName instead of RegionalDomainName, or CertificateArn instead of RegionalCertificateArn, and your deployment breaks in non-obvious ways.\nNever manually delete CloudFormation resources. I did this once. The stack drifted and subsequent deployments failed. The only fix was deleting the whole stack and redeploying. CloudFormation manages its own state \u0026ndash; don\u0026rsquo;t touch resources outside of it.\nCheck .gitignore before your first commit. I committed vault files with credentials. Had to scrub them from git history. Won\u0026rsquo;t happen again.\nWhat I\u0026rsquo;d do differently #Automate CloudFront cache invalidation from the first deploy, not as an afterthought. Use OAC over OAI from day one since OAI is the legacy option. And set WWW as the primary domain upfront \u0026ndash; it keeps your bucket structure clean and leaves the apex free for subdomains.\nWorth doing? #Yes. One project that touches storage, CDN, serverless compute, a NoSQL database, DNS, IaC, and deployment automation is genuinely useful for understanding how AWS services connect in a real architecture. For a job search it\u0026rsquo;s something concrete you built and can walk through, not just another cert.\nFull project on GitHub with AWS and GCP implementations, CloudFormation templates, Terraform config, and Ansible playbooks: danphillips-cloud/cloud-resume-challenge\n","date":"April 18, 2026","permalink":"https://danphillips.cloud/posts/crc-aws-stack/","section":"Posts","summary":"","title":"How I built a serverless website on AWS without EC2 or a VPC"},{"content":"The Cloud Resume Challenge spec calls for a visitor counter. A JavaScript fetch to a Lambda-backed API, increments a DynamoDB value, displays the number on the page. I built it. It works.\nIt\u0026rsquo;s also completely useless.\nIf you\u0026rsquo;re doing the CRC and you want to go beyond the spec, skip the vanity metric and set up CloudWatch RUM instead. Here\u0026rsquo;s exactly how I did it, what broke, and what I\u0026rsquo;d do differently.\nThe metrics tab: aggregated Web Vitals, error rates, and HTTP failures at a glance. What is RUM? #Real User Monitoring (RUM) is a category of observability tooling that captures performance and error data directly from your users\u0026rsquo; browsers. Not synthetic tests run from a lab. Not server-side metrics. Actual telemetry from actual people loading your actual site.\nCloudWatch already covers infrastructure metrics, logs, and distributed tracing via X-Ray. RUM is the missing piece: the client side. You inject a small JavaScript snippet into your pages, and it starts capturing data immediately.\nTL;DR # CloudWatch RUM gives you real browser telemetry: page speed, errors, and failed requests Cognito handles credentials securely so no IAM keys end up in your HTML Two gotchas worth knowing: the cwr.js script host is us-east-1 only, and domain matching is strict Setup takes about an hour. Worth it. The problem with a visitor counter #Hit F5 a hundred times and you have a hundred visitors. Let a bot scrape the page and you have thousands. There\u0026rsquo;s no session deduplication, no filtering of your own dev traffic, no way to know if a real person actually read anything, or if the page even loaded without errors.\nIt\u0026rsquo;s a vanity metric dressed up as observability.\nWhat I actually want to know:\nIs my site fast for real users on real networks? Are there JavaScript errors I don\u0026rsquo;t know about? How does the page perform on mobile vs desktop? A counter answers none of those. CloudWatch RUM answers all of them.\nWhat RUM gives you #CloudWatch RUM captures telemetry from every visitor\u0026rsquo;s browser:\nWeb Vitals: how fast the page loads, how quickly it responds to clicks, and how much the layout shifts while loading. Real numbers from real browsers. JavaScript errors: unhandled exceptions surfaced immediately, not when a user complains. HTTP monitoring: request timing and failures across every fetch and API call on the page. Session data: navigation patterns, browser and OS breakdown. Data is retained for 30 days. Cost at current traffic levels: ~$0/month, well within the 1M events/month free tier.\nThe performance tab: page load timing from real browsers, not a synthetic test. The user journey tab: how people actually move through the site. Session counts. The numbers are real. The audience is mostly me. Architecture #The tricky part is getting AWS credentials into the browser securely. The SDK needs credentials to POST events to the RUM dataplane, but embedding IAM keys in client-side HTML is a security disaster. Anyone can view source.\nThe solution is an unauthenticated Cognito Identity Pool. This is a standard AWS pattern for vending short-lived, scoped credentials to untrusted clients. Mobile apps use it, IoT devices use it, and it works equally well here. At runtime, the SDK calls Cognito, gets back temporary credentials scoped to rum:PutRumEvents on this app monitor only, and uses those to send events. No login. No secrets in the HTML. Least-privilege IAM by design.\nThree resources, all deployed via CloudFormation:\nCognito Identity Pool: vends temporary credentials to anonymous browser sessions IAM Role: scoped to rum:PutRumEvents on this app monitor only RUM App Monitor: configured for www.danphillips.cloud, captures errors, performance, and HTTP telemetry The flow:\ngraph LR A[Browser] --\u0026gt;|1. load cwr.js| B[SDK Script Host us-east-1] A --\u0026gt;|2. get credentials| C[Cognito Identity Pool ca-central-1] C --\u0026gt;|3. scoped creds| A A --\u0026gt;|4. POST events| D[RUM Dataplane ca-central-1] D --\u0026gt; E[CloudWatch RUM App Monitor] Two things that will catch you out #The AWS docs are straightforward and worth reading before you start. That said, two things tripped me up that the docs don\u0026rsquo;t make obvious.\nThe cwr.js script host: us-east-1, always — or maybe not #404 on SDK load\nThe SDK failed to load with a 404 on client.rum.ca-central-1.amazonaws.com. I assumed the script host would follow the same pattern as the dataplane: region-specific. So I tried us-east-1, it worked, and I moved on.\nHere is where I have to be honest: the AWS docs show the script URL pattern as client.rum.{region}.amazonaws.com, which implies it is region-specific. But the console-generated snippet for my ca-central-1 App Monitor used us-east-1. And the ca-central-1 URL gave me a 404.\nI am not sure whether ca-central-1 just does not have a CDN endpoint for the script, or whether the docs are describing a pattern that does not apply to all regions yet, or whether something else was going on. The docs are not clear on this and I could not find a definitive answer.\nWhat I did: use whatever URL the AWS console generates when you set up the App Monitor. Do not assume a regional pattern. If you are in ca-central-1, that means us-east-1 for the script host and ca-central-1 for the dataplane endpoint — at least for now.\nIf us-east-1 has a bad day, your RUM snippet stops loading regardless of where your infrastructure runs. For a portfolio site that is an acceptable risk. For production, worth thinking about.\nThe App Monitor domain must match your canonical URL #HTTP 400 on every event\nEverything was loading. Cognito credentials came back fine. But every event POST returned HTTP 400.\nI had configured the App Monitor against danphillips.cloud (apex), forgetting that the apex domain redirects to www.danphillips.cloud. The RUM dataplane does a strict hostname match. No wildcards, exact string only. I had the redirect documented right there in the CloudFormation templates and still got it wrong.\nFix: One line in rum.yaml. Configure against the URL the browser actually lands on, after any redirects. And always check the response body in DevTools, not just the status code. The 400 body told me exactly what was wrong in one line.\nBefore you start #The payoff is immediate. Once it\u0026rsquo;s working you have page speed data, error tracking, and HTTP monitoring on every page load. No dashboards to configure, no agents to install.\nFollow the AWS getting started guide and get your snippet from the console. It gives you the correct, current format for your specific setup.\nKnow your canonical URL before you configure the App Monitor. After redirects. What the browser actually lands on, not what you think it is.\nRead the response body. Not just the status code. The RUM dataplane error messages are specific and tell you exactly what to fix.\nRUM has an ingestion delay. Events can take up to 15 minutes to appear. Confirm requests are getting 200s in the Network tab before assuming something is broken.\nThe snippet #Get this from the CloudWatch RUM console for your own app monitor ID and region.\n\u0026lt;script\u0026gt; (function(n,i,v,r,s,c,u,x,z){x=window.AwsRumClient={q:[],n:n,i:i,v:v,r:r,c:c,u:u}; window[n]=function(c,p){x.q.push({c:c,p:p})}; z=document.createElement(\u0026#39;script\u0026#39;);z.async=true;z.src=s; document.head.insertBefore(z,document.getElementsByTagName(\u0026#39;script\u0026#39;)[0]); })(\u0026#39;cwr\u0026#39;, \u0026#39;YOUR_APP_MONITOR_ID\u0026#39;, \u0026#39;1.0.0\u0026#39;, \u0026#39;YOUR_REGION\u0026#39;, \u0026#39;https://client.rum.us-east-1.amazonaws.com/1.20.0/cwr.js\u0026#39;, { sessionSampleRate: 1, guestRoleArn: \u0026#34;arn:aws:iam::YOUR_ACCOUNT:role/YOUR_UNAUTH_ROLE\u0026#34;, identityPoolId: \u0026#34;YOUR_REGION:YOUR_IDENTITY_POOL_ID\u0026#34;, endpoint: \u0026#34;https://dataplane.rum.YOUR_REGION.amazonaws.com\u0026#34;, telemetries: [\u0026#34;performance\u0026#34;,\u0026#34;errors\u0026#34;,\u0026#34;http\u0026#34;], allowCookies: true, enableXRay: false }); \u0026lt;/script\u0026gt; The snippet is static, baked into the HTML at deploy time. If you need to update it, regenerate from the CloudWatch RUM console and redeploy.\nCloudWatch already gives you metrics, logs, and traces. RUM adds the one signal you can\u0026rsquo;t get from the server side: what it actually felt like to load your site. Worth the extra hour.\n","date":"April 5, 2026","permalink":"https://danphillips.cloud/posts/cloudwatch-rum/","section":"Posts","summary":"","title":"CloudWatch RUM on AWS: Real Observability Beyond the Visitor Counter"},{"content":"I\u0026rsquo;ve been down this road before. Buy the course, read the docs, watch the videos, take the practice exams. Pass the test. Forget most of it in three months.\nFor KCNA I decided to do it differently. Instead of studying Kubernetes, I\u0026rsquo;d build with it.\nWhat is KCNA? #The Kubernetes and Cloud Native Associate (KCNA) is a foundational certification from the CNCF. It covers Kubernetes fundamentals, container orchestration, cloud native application delivery (GitOps, CI/CD, Helm), and cloud native architecture including observability and service meshes. It\u0026rsquo;s not a hands-on exam, but hands-on is still the best way to learn it.\nThe lab #I built an 8-phase lab on my M-series Mac using kind (Kubernetes in Docker) and Colima as a lightweight Docker runtime. No Docker Desktop, no cloud costs, no waiting for cluster provisioning.\nEach phase produced something that actually ran. That was the rule: no phase was complete until I could verify it in the terminal. I started with a basic kind cluster and kubectl, then built up through the core objects, ConfigMaps and Secrets, Ingress, namespaces and RBAC, a full end-to-end application, a Helm chart deployed with Argo CD, and finally Prometheus and Grafana for cluster observability.\nThe full lab is in the GitHub repo with manifests for every phase and a Kubernetes cheatsheet that maps everything back to AWS equivalents.\nThe AWS brain #I\u0026rsquo;ve been working in AWS for years. ECS, Lambda, CloudFormation. That\u0026rsquo;s my daily context. Kubernetes has its own vocabulary and I found the fastest way to learn it was to map everything to something I already knew.\nThe core objects map cleanly: a Pod is an ECS Task, a Deployment is an ECS Service, a Service is an ALB Target Group. ConfigMaps are Parameter Store, Secrets are Secrets Manager, ServiceAccounts with RBAC are IAM roles for workloads. Helm charts are CloudFormation templates and Argo CD is CodePipeline. Once I had that mental model, Kubernetes clicked. The concepts aren\u0026rsquo;t foreign. The names just changed.\nWhat surprised me #Labels are everything. In AWS, things connect via ARNs and resource IDs. In Kubernetes, things connect via labels. A Deployment finds its Pods because the selector.matchLabels matches template.metadata.labels. A Service finds its Pods the same way. Get the labels wrong and nothing connects, with no useful error message. This tripped me up early and often.\nRBAC transfers directly. If you understand IAM policies, Kubernetes RBAC is immediately readable. Role equals policy, RoleBinding equals attaching a policy to a principal, ServiceAccount equals an IAM role for a workload. The mental model is the same. The syntax is different.\nGitOps is satisfying. Pushing a change to GitHub and watching Argo CD reconcile it without touching kubectl feels like the right way to run things. Phase 7 was my favourite.\nObservability is one command. The kube-prometheus-stack installs Prometheus, Grafana, Alertmanager, and pre-built dashboards in one helm install. You go from zero to full cluster metrics in minutes. It\u0026rsquo;s resource-heavy on a local cluster, but it works.\nExam is coming up. I\u0026rsquo;ll post the result.\n","date":"April 5, 2026","permalink":"https://danphillips.cloud/posts/kcna-lab/","section":"Posts","summary":"","title":"Studying for KCNA by Actually Building Things"},{"content":"","date":"January 1, 1","permalink":"https://danphillips.cloud/certifications/","section":"Dan Phillips - Enterprise Technology Consultant \u0026 Cloud Architecture Specialist","summary":"","title":"Dan Phillips - Certifications"},{"content":"","date":"January 1, 1","permalink":"https://danphillips.cloud/projects/","section":"Dan Phillips - Enterprise Technology Consultant \u0026 Cloud Architecture Specialist","summary":"","title":"Dan Phillips - Projects"},{"content":"","date":"January 1, 1","permalink":"https://danphillips.cloud/resume/","section":"Dan Phillips - Enterprise Technology Consultant \u0026 Cloud Architecture Specialist","summary":"","title":"Dan Phillips - Resume"}]