<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://edition-x.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://edition-x.github.io/" rel="alternate" type="text/html" /><updated>2026-06-02T10:12:38+00:00</updated><id>https://edition-x.github.io/feed.xml</id><title type="html">Edition-X</title><subtitle>A technical blog by Daniel Kelly — DevOps, platform engineering, cloud infrastructure, and the systems that power modern software.</subtitle><author><name>Daniel Kelly</name></author><entry><title type="html">Safe Infrastructure Changes With a Team of One</title><link href="https://edition-x.github.io/2026/04/06/safe-infra-changes-team-of-one.html" rel="alternate" type="text/html" title="Safe Infrastructure Changes With a Team of One" /><published>2026-04-06T00:00:00+00:00</published><updated>2026-04-06T00:00:00+00:00</updated><id>https://edition-x.github.io/2026/04/06/safe-infra-changes-team-of-one</id><content type="html" xml:base="https://edition-x.github.io/2026/04/06/safe-infra-changes-team-of-one.html"><![CDATA[<p>Throughout this series I’ve been describing a system — self-hosted GitHub Actions runners with custom AMIs, autoscaling, and a Python runtime — without really addressing the elephant in the room. There’s one person managing all of this. Me.</p>

<p>There’s no platform team. No infrastructure review board. No second pair of eyes on a Terraform apply. When I push a change to the runner infrastructure, I’m the one who wrote it, reviewed it, approved it, and deployed it. If something goes wrong in production, I’m the one who gets the Slack notification and I’m the one who fixes it.</p>

<p>This post is about the practices and guardrails that make that work without constant anxiety. None of this is groundbreaking — it’s mostly just discipline and good defaults. But the specific combination matters, and I think it’s useful for anyone else running infrastructure as a solo operator or a very small team.</p>

<h2 id="separate-everything">Separate Everything</h2>

<p>The most important decision was treating dev and prod as completely separate environments from the start, not just logically but in every layer of the stack.</p>

<p>Terraform workspaces keep the state files isolated. The dev workspace and the prod workspace each have their own state, their own resource naming, and their own configuration. A <code class="language-plaintext highlighter-rouge">terraform destroy</code> in dev can’t touch prod resources because they don’t exist in the same state file.</p>

<p>Each environment has its own tracked tfvars file with explicit configuration. Dev uses a <code class="language-plaintext highlighter-rouge">t3.xlarge</code> with a 100GB root volume. Prod uses an <code class="language-plaintext highlighter-rouge">m7i.xlarge</code> with the default 300GB. Dev runners have the label <code class="language-plaintext highlighter-rouge">sunrise-dev</code>. Prod runners have the label <code class="language-plaintext highlighter-rouge">sunrise</code>. This means a dev job physically cannot be picked up by a prod runner, and vice versa. The labels are the isolation boundary at the GitHub level, and the Terraform workspaces are the isolation boundary at the AWS level.</p>

<p>Even the AMIs are separated. Dev bakes from the dev instance and writes to a dev SSM parameter. Prod reads from a different SSM parameter that only gets updated through an explicit promotion step. There is no path where a dev AMI accidentally ends up in production without someone intentionally promoting it.</p>

<p>I also have a third Terraform workspace — <code class="language-plaintext highlighter-rouge">ci-iam</code> — that owns the account-global resources like the GitHub OIDC provider and the CI IAM role. These are resources that both dev and prod depend on, but that shouldn’t live in either workspace. Having them in their own workspace means I can update IAM policies without touching runner infrastructure, and I never risk accidentally duplicating the OIDC provider across environments.</p>

<h2 id="no-long-lived-credentials">No Long-Lived Credentials</h2>

<p>The CI pipeline authenticates to AWS using GitHub’s OIDC integration. There are no AWS access keys stored in GitHub secrets. The GitHub Actions runner assumes an IAM role via OIDC, and the trust policy is scoped to this specific repository.</p>

<p>This matters for a solo operator because credential rotation is one of those tasks that’s easy to forget about and catastrophic to get wrong. With OIDC, there’s nothing to rotate. The trust relationship is between GitHub’s identity provider and the AWS IAM role, and it’s verified on every run.</p>

<p>The GitHub PAT that the runners use to register with GitHub is stored in SSM Parameter Store as a SecureString. It’s the one long-lived credential in the system, and it’s the only thing I’d need to rotate manually. Everything else is ephemeral.</p>

<h2 id="the-promotion-gate">The Promotion Gate</h2>

<p>The weekly pipeline runs end-to-end on Sunday: bake the dev AMI, promote it to prod, apply Terraform against prod, roll the runner instances, and notify Slack. But the key design choice is that each stage is a separate job with an explicit dependency on the previous one succeeding.</p>

<p>If the dev bake fails, promotion doesn’t happen. If promotion fails, prod apply doesn’t happen. If the apply fails, the runners don’t get rolled. At no point can a downstream stage execute if an upstream stage failed. This sounds obvious, but it’s easy to get wrong with GitHub Actions — the default behaviour for dependent jobs is to run unless you explicitly check the outcome of the dependency.</p>

<p>The manual fallback is there too. If something needs to go to prod outside the Sunday cycle, I can trigger the <code class="language-plaintext highlighter-rouge">promote_ami</code> action from the prod workflow UI, then run an apply. But the default path is the automated pipeline, which means prod changes are consistent, predictable, and always preceded by a week of dev validation.</p>

<p>This has caught real issues. There was a week where the dev bake succeeded but the AMI had a subtle problem with the LFS cleanup that only showed up under specific repository configurations. Because dev was running that AMI for a week before promotion, we caught it during normal dev CI usage. If I’d been baking directly into prod, that would have been a production incident.</p>

<h2 id="zero-downtime-rollout">Zero-Downtime Rollout</h2>

<p>Not every change can be deployed through AMI promotion. When the runner bootstrap code changes — the Python runtime that gets embedded in the EC2 user-data — the fix lives in the launch template. An AMI promotion won’t pick it up because the existing instances are already running. You need to replace the instances.</p>

<p>The rollout module handles this automatically after a prod apply. It surges the ASG by one instance, waits for the new instance to be fully ready — InService in the ASG, SSM agent online, runner services active in systemd, and registered with GitHub — and only then drains and terminates the old instance. If the drain fails for any reason, it leaves the extra instance running. Availability over perfect cleanup.</p>

<p>This was one of the later additions to the system. Before this, a bootstrap code change in prod meant manually terminating the instance and waiting for the ASG to replace it. That’s a few minutes of downtime where no runner is available, which is fine at 2am but not great if someone triggers it during working hours. The automated rollout means I can merge to main, trigger a prod apply, and the instance replacement happens safely without me babysitting it.</p>

<h2 id="least-privilege-iam">Least-Privilege IAM</h2>

<p>The CI IAM role has three managed policies, each scoped to exactly what it needs. The S3 backend policy can read and write the Terraform state bucket. The AMI cleanup policy can deregister and delete snapshots, but only for resources tagged with the dev environment — it physically cannot delete a prod AMI. The workload policy covers EC2, ASG, Lambda, and the other resources the runner infrastructure needs, with ARN patterns scoped to the project’s naming convention.</p>

<p>I won’t pretend this was easy to set up. The initial deployment involved a lot of “apply, see what fails, add the missing permission, apply again.” And the policies had to be refactored when they started hitting the AWS managed policy size limit. But the result is a CI role that can only affect the resources it’s supposed to affect, which is important when that CI role is the primary deployment mechanism and there’s nobody reviewing the applies in real time.</p>

<p>One specific decision that paid off: the AMI cleanup policy is explicitly restricted to dev-tagged resources. The safety cap in the cleanup code (from <a href="/2026/03/26/ami-baking-pipeline.html">Part 2</a>) is the application-level guard against deleting too many AMIs. The IAM policy is the infrastructure-level guard against deleting the wrong ones. Two layers of protection for the same destructive operation.</p>

<h2 id="reusable-workflows">Reusable Workflows</h2>

<p>The CI logic is structured as a shared workflow that both dev and prod call with different inputs. The shared workflow handles init, format checking, validation, planning, and apply. The environment-specific workflows handle the inputs — which workspace, which var file, which actions are available.</p>

<p>This means there’s exactly one place where the Terraform plan/apply logic lives. If I fix a CI bug, both environments get the fix. If I add a new validation step, both environments get the step. The alternative — copying workflow YAML between dev and prod files — is how you end up with environments that slowly drift apart until something works in dev and breaks in prod for reasons that take an hour to debug.</p>

<h2 id="slack-notifications">Slack Notifications</h2>

<p>The Sunday pipeline sends a Slack notification when it completes, regardless of whether it succeeded or failed. The message includes the workflow name, the repository, the workspace, and a link to the run.</p>

<p>This is the simplest form of observability, and for a solo operator it’s one of the most important. If the Sunday bake fails, I find out in Slack on Sunday evening, not on Monday morning when someone complains that their build is slow because the AMI is a week out of date. If it succeeds, I get a quiet confirmation that the system is healthy and I don’t need to think about it.</p>

<p>The notifications only fire on scheduled runs. Manual runs don’t notify, because if I’m running something manually I’m already watching it.</p>

<h2 id="what-makes-it-work">What Makes It Work</h2>

<p>If I had to summarize what makes solo infrastructure management work, it would be this: make the safe path the easy path.</p>

<p>Promoting to prod should be easier through the pipeline than around it. Rolling instances should happen automatically, not manually. Destructive operations should have safety caps. Credentials should be ephemeral. Environments should be isolated by default, not by convention.</p>

<p>None of these are novel ideas. But the difference between knowing them and implementing them is the difference between a system you’re confident in and a system you’re afraid to touch. Building this infrastructure over the past 7 months — from a single bash script to the system described across these 5 posts — the thing I’m most satisfied with isn’t any individual feature. It’s that I can push a change on a Wednesday afternoon and not spend the rest of the day worrying about whether I just broke CI for the whole team.</p>

<hr />

<p><em>This is <strong>Part 5</strong> of a series on building production-grade self-hosted GitHub Actions runners at a robotics startup.</em></p>

<ul>
  <li><em><strong>Part 1:</strong> <a href="/2026/03/20/single-ec2-entire-ci.html">How a Single EC2 Instance Runs Our Entire CI Pipeline</a></em></li>
  <li><em><strong>Part 2:</strong> <a href="/2026/03/26/ami-baking-pipeline.html">We Replaced Packer With a GitHub Actions Workflow</a></em></li>
  <li><em><strong>Part 3:</strong> <a href="/2026/04/02/autoscaling-without-webhooks.html">Autoscaling GitHub Runners Without Webhooks</a></em></li>
  <li><em><strong>Part 4:</strong> <a href="/2026/04/04/shell-script-to-python-runtime.html">From Shell Script to Python Runtime</a></em></li>
  <li><em><strong>Part 5:</strong> Safe Infrastructure Changes With a Team of One (you are here)</em></li>
</ul>]]></content><author><name>Daniel Kelly</name></author><category term="devops" /><category term="aws" /><category term="terraform" /><category term="infrastructure" /><category term="security" /><summary type="html"><![CDATA[Dev/prod isolation, promotion gates, zero-downtime rollouts, and least-privilege IAM. How a solo DevOps engineer deploys infrastructure without breaking production.]]></summary></entry><entry><title type="html">From Shell Script to Python Runtime</title><link href="https://edition-x.github.io/2026/04/04/shell-script-to-python-runtime.html" rel="alternate" type="text/html" title="From Shell Script to Python Runtime" /><published>2026-04-04T00:00:00+00:00</published><updated>2026-04-04T00:00:00+00:00</updated><id>https://edition-x.github.io/2026/04/04/shell-script-to-python-runtime</id><content type="html" xml:base="https://edition-x.github.io/2026/04/04/shell-script-to-python-runtime.html"><![CDATA[<p>This is the post where I admit that I kept things in bash for too long.</p>

<p>In <a href="/2026/03/26/ami-baking-pipeline.html">Part 2</a> I mentioned the November 6th marathon — 22 commits in a day fixing AMI cleanup edge cases, all written in inline bash inside GitHub Actions workflows. I said I knew I’d need to rewrite it, but I had other priorities. That was true. It’s also true that by the time I finally did the rewrite, the bash had grown into something genuinely hard to maintain.</p>

<p>This post is about what that migration looked like, why it mattered more than I expected, and what the Python runtime actually does.</p>

<h2 id="how-it-started">How It Started</h2>

<p>The first version of the runner infrastructure was mostly bash. The EC2 user-data script that bootstraps a new instance was a shell script. The AMI baking logic was inline bash in the GitHub Actions workflow. The pre-job cleanup hooks were bash. Even the runner registration and deregistration was handled by shell commands piped together with <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> and <code class="language-plaintext highlighter-rouge">||</code>.</p>

<p>For a while this worked fine. Shell scripts are great for stringing together AWS CLI calls and systemd commands, and when the system was simple — one instance, one runner process, no autoscaling — there wasn’t enough complexity to justify anything else.</p>

<p>The problems crept in gradually. The first sign was the pre-job hook. We run persistent runners, which means the same runner picks up job after job without being destroyed between runs. That’s great for performance, but it means the workspace directories accumulate state between jobs. Git lock files from crashed builds, stale LFS hooks from repos that used to use LFS but don’t anymore, repo-local git config that bleeds across unrelated jobs.</p>

<p>The hook started as a two-line script that deleted <code class="language-plaintext highlighter-rouge">.git/index.lock</code> before each job. Then it needed to handle nested lock files. Then it needed to clean up LFS hooks that were causing checkout failures on runners that didn’t have <code class="language-plaintext highlighter-rouge">git-lfs</code> installed. Then it needed to scrub repo-local git config. Each addition was a few more lines of bash, and each one had to be careful about paths, error handling, and not breaking the cases it wasn’t trying to fix.</p>

<p>By the time the hook was “done” it had been through 5 iterations across multiple PRs, and it was still fragile. A missing quote here, a glob that expanded wrong there. Every fix felt like it might introduce a new edge case.</p>

<h2 id="the-breaking-point">The Breaking Point</h2>

<p>The real breaking point wasn’t a single incident. It was the accumulation of several things happening around the same time.</p>

<p>The AMI baking pipeline had grown from a simple “stop instance, create image” workflow to a multi-step orchestration with detach, patch, pre-pull, cleanup, snapshot, roll forward, and smoke test. All in bash. With error handling that was either missing or wrong.</p>

<p>The user-data bootstrap script had evolved to support multiple concurrent runner processes per instance, with systemd service templating, signal handling for graceful shutdown, and runner registration token lifecycle management. In bash.</p>

<p>And I was hitting POSIX compatibility issues. The EC2 instances run commands via SSM, and SSM’s <code class="language-plaintext highlighter-rouge">AWS-RunShellScript</code> document uses <code class="language-plaintext highlighter-rouge">/bin/sh</code>, not <code class="language-plaintext highlighter-rouge">/bin/bash</code>. Features I was relying on — <code class="language-plaintext highlighter-rouge">pipefail</code>, arrays, certain parameter expansion syntax — either didn’t work or behaved differently. I’d fix something locally, push it, and watch it fail in a completely different way on the instance.</p>

<p>The tipping point was when I needed to add autoscaling support. The Lambda metric publisher needed to be Python anyway (it’s a Lambda function), and looking at the codebase, I realized I was about to have the autoscaling logic in Python, the baking logic in bash, the bootstrap in bash, and the cleanup in a mix of both. Three different languages and paradigms for what was essentially one system.</p>

<p>So I carved out a week and rewrote everything.</p>

<h2 id="what-the-python-runtime-actually-does">What the Python Runtime Actually Does</h2>

<p>The core of the system is now a Python package called <code class="language-plaintext highlighter-rouge">runner_ops</code>. It has a CLI entrypoint with subcommands for each operational task, and a set of modules that handle specific domains.</p>

<p><strong>The bootstrap runtime</strong> is the big one — about 850 lines that get base64-encoded and embedded directly into the EC2 user-data via cloud-init. When an instance boots, the cloud-init script decodes the Python runtime, writes it to disk, and runs it. The runtime handles everything the bash used to do and more:</p>

<p>It queries the EC2 instance metadata service to figure out its own instance ID and region. It downloads and extracts the GitHub Actions runner archive. It creates systemd service units for however many concurrent runner processes the instance is configured to run — if the config says 4 concurrent runners, it generates 4 separate services, each with its own working directory and registration. It handles runner token registration with GitHub, including generating unique runner names based on the instance ID and slot number so there are no naming conflicts across the fleet.</p>

<p>The persistent runner loop is where the bash-to-Python difference matters most. Each runner process runs in a loop — register, start the listener, wait for it to exit, clean up, re-register. In bash, the signal handling for graceful shutdown was a mess of <code class="language-plaintext highlighter-rouge">trap</code> statements that sometimes worked. In Python, it’s a signal handler that sets a flag, and the loop checks the flag between iterations. If a SIGTERM arrives while a job is running, the runner finishes the current job and then exits cleanly instead of being killed mid-build.</p>

<p><strong>The pre-job hook</strong> went from a brittle bash script to a Python function that systematically walks the workspace directories and cleans up git lock files, LFS hooks, LFS config, and repo-local git configuration. It logs what it finds and what it removes, which made debugging checkout failures significantly easier. The bash version would silently succeed or silently fail. The Python version tells you exactly what state it found.</p>

<p><strong>The AMI baking module</strong> is the 900-line orchestrator from <a href="/2026/03/26/ami-baking-pipeline.html">Part 2</a>. The <code class="language-plaintext highlighter-rouge">finally</code> block alone justified the rewrite — it ensures the ASG min_size gets restored and detached instances get terminated even if the bake fails halfway through. In bash, there was no equivalent. If the script failed after detaching the instance but before the cleanup, I’d have a dangling EC2 instance accumulating charges until I noticed.</p>

<p><strong>The AMI cleanup module</strong> is the safety-capped deletion logic. <strong>The autoscaling metric publisher</strong> is the Lambda from <a href="/2026/04/02/autoscaling-without-webhooks.html">Part 3</a>. <strong>The notification module</strong> handles Slack webhook calls for pipeline status. <strong>The rollout module</strong> handles zero-downtime instance replacement after a prod apply.</p>

<h2 id="the-16kb-problem">The 16KB Problem</h2>

<p>There was one genuinely tricky part of the migration: EC2 user-data has a 16KB size limit. The bash bootstrap was already pushing against it, and 850 lines of Python is significantly larger than the shell version it replaced.</p>

<p>The solution was compression. The Terraform template base64-encodes the Python runtime and a JSON config blob. The cloud-init script — which is still a thin shell wrapper, because it has to be — decodes the base64, decompresses it, writes the Python files to disk, and starts a systemd service that calls the Python entrypoint.</p>

<p>The shell wrapper is about 60 lines now. Its only job is to unpack the Python runtime and hand off control. All the actual logic lives in the Python, which means all the actual logic is testable.</p>

<h2 id="the-part-that-actually-mattered">The Part That Actually Mattered</h2>

<p>The testability is what made the biggest difference in practice. Every function in <code class="language-plaintext highlighter-rouge">runner_ops</code> that computes something has unit tests. The cleanup plan logic, the shell command builders, the tag verification, the polling utilities, the metric publisher. The AMI baking module has tests for the error handling paths that were completely untested when they were bash.</p>

<p>When I added the LFS cleanup in March — the fix for checkout failures on persistent runners — I wrote the test first, implemented the cleanup function, and verified it worked before deploying. In the bash era, I would have written the fix, deployed it to dev, triggered a bunch of jobs, and hoped for the best. Then probably spent another 2-3 iterations fixing edge cases I didn’t think of.</p>

<p>The other thing that mattered was the CLI. Having <code class="language-plaintext highlighter-rouge">runner_ops</code> as a proper CLI tool with subcommands meant the GitHub Actions workflows became thin wrappers:</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">Bake and roll dev AMI</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">python -m runner_ops.cli bake-dev-ami --workspace dev ...</span>
</code></pre></div></div>

<p>The workflow doesn’t contain logic anymore. It passes configuration to the CLI, and the CLI does the work. If something goes wrong, I can reproduce it locally by running the same command with the same arguments. That was never possible with inline bash workflows where the logic was split across workflow YAML, shell scripts, and SSM commands.</p>

<h2 id="what-i-should-have-done-differently">What I Should Have Done Differently</h2>

<p>I should have started with Python. Not because bash is bad — it’s great for simple automation. But the moment I had multi-step orchestration with error handling and cleanup requirements, bash was the wrong tool. I knew this intellectually. I just kept thinking “I’ll rewrite it after this next feature” and then the next feature added more bash.</p>

<p>The migration itself took about a week of focused work. That’s not nothing, but it was less painful than I expected because the system’s behaviour was well-understood by that point. I wasn’t designing something new — I was translating something that already worked (mostly) into a language that could handle the edge cases properly.</p>

<p>If I were starting this project over, I’d write the cloud-init wrapper in shell — because you have to — and everything else in Python from day one. The initial velocity of bash isn’t worth the maintenance cost once the system gets beyond a few hundred lines.</p>

<h2 id="whats-next">What’s Next</h2>

<p>The tooling is in good shape now, but there’s one more piece of the story: how do you safely manage all of this as a single engineer? Dev/prod isolation, deployment gates, automated rollouts, and the practices that prevent a solo operator from accidentally breaking production. That’s <a href="/2026/04/06/safe-infra-changes-team-of-one.html">Part 5</a>.</p>

<hr />

<p><em>This is <strong>Part 4</strong> of a series on building production-grade self-hosted GitHub Actions runners at a robotics startup.</em></p>

<ul>
  <li><em><strong>Part 1:</strong> <a href="/2026/03/20/single-ec2-entire-ci.html">How a Single EC2 Instance Runs Our Entire CI Pipeline</a></em></li>
  <li><em><strong>Part 2:</strong> <a href="/2026/03/26/ami-baking-pipeline.html">We Replaced Packer With a GitHub Actions Workflow</a></em></li>
  <li><em><strong>Part 3:</strong> <a href="/2026/04/02/autoscaling-without-webhooks.html">Autoscaling GitHub Runners Without Webhooks</a></em></li>
  <li><em><strong>Part 4:</strong> From Shell Script to Python Runtime (you are here)</em></li>
  <li><em><strong>Part 5:</strong> <a href="/2026/04/06/safe-infra-changes-team-of-one.html">Safe Infrastructure Changes With a Team of One</a> — dev/prod isolation and deployment gates without a platform team</em></li>
</ul>]]></content><author><name>Daniel Kelly</name></author><category term="devops" /><category term="python" /><category term="github-actions" /><category term="aws" /><category term="automation" /><summary type="html"><![CDATA[850 lines of Python embedded in EC2 user-data, a 16KB size limit, and why testability matters more than velocity. How the runner bootstrap evolved over 7 months.]]></summary></entry><entry><title type="html">Autoscaling GitHub Runners Without Webhooks</title><link href="https://edition-x.github.io/2026/04/02/autoscaling-without-webhooks.html" rel="alternate" type="text/html" title="Autoscaling GitHub Runners Without Webhooks" /><published>2026-04-02T00:00:00+00:00</published><updated>2026-04-02T00:00:00+00:00</updated><id>https://edition-x.github.io/2026/04/02/autoscaling-without-webhooks</id><content type="html" xml:base="https://edition-x.github.io/2026/04/02/autoscaling-without-webhooks.html"><![CDATA[<p>In <a href="/2026/03/20/single-ec2-entire-ci.html">Part 1</a> I explained how we run our entire CI on a single EC2 instance with 4 concurrent runner processes. In <a href="/2026/03/26/ami-baking-pipeline.html">Part 2</a> I covered how we bake and promote AMIs without Packer. Both of those posts describe a system that works well under normal load.</p>

<p>The problem was burst load. When multiple developers push at the same time, or someone triggers a batch of integration runs, all 4 runner slots fill up and everything else queues. During one busy week we saw jobs waiting over an hour and a half for a free slot. CPU was hitting 100% on the instance and the queue just kept growing.</p>

<p>We needed autoscaling. And the standard advice for self-hosted GitHub runners is to use webhooks — listen for <code class="language-plaintext highlighter-rouge">workflow_job</code> events, spin up instances when jobs are queued, tear them down when they finish. GitHub’s own docs recommend this pattern, and tools like <code class="language-plaintext highlighter-rouge">actions-runner-controller</code> are built around it.</p>

<p>We went a different direction.</p>

<h2 id="why-not-webhooks">Why Not Webhooks?</h2>

<p>Webhooks are the obvious answer, and for larger teams with existing infrastructure they’re probably the right one. But for us, a small team with no public-facing endpoints and one DevOps engineer, the webhook approach had some problems.</p>

<p>First, you need something to receive the webhooks. That means either a public endpoint with authentication and TLS, or a more complex setup with API Gateway or a queue in front of it. Either way you’re maintaining infrastructure whose sole job is to receive HTTP callbacks from GitHub.</p>

<p>Second, webhook delivery isn’t guaranteed. GitHub has retry logic, but if your receiver is down during a burst, you miss the scaling signal entirely. Now you need to handle missed webhooks, which means you need a fallback mechanism anyway — at which point you’re maintaining two scaling signals instead of one.</p>

<p>Third, the coupling is tight. Your scaling logic is now driven by GitHub’s event schema. If GitHub changes the webhook payload, or if delivery latency spikes, your scaling reacts differently. You’re building reliability on top of someone else’s delivery pipeline.</p>

<p>What if we just asked GitHub “how busy are you right now?” on a schedule and scaled from there?</p>

<h2 id="the-polling-approach">The Polling Approach</h2>

<p>The idea is simple. A Lambda function runs every minute, calls the GitHub API to check how many runners are currently busy, and publishes that number as a CloudWatch metric. Standard CloudWatch alarms watch the metric and trigger ASG scaling policies when the count crosses a threshold.</p>

<p>That’s the whole architecture. No public endpoints, no webhook receivers, no event-driven complexity. Just a scheduled function that publishes a number, and AWS does the rest.</p>

<p>The Lambda itself is about 170 lines of Python. It fetches a GitHub PAT from SSM Parameter Store, paginates through the org runners API filtering by label, counts how many are busy, and publishes the count to CloudWatch with dimensions for the ASG name and runner label. EventBridge triggers it every minute.</p>

<p>The scaling logic is two CloudWatch alarms and two ASG policies. When the busy runner count hits 4 — meaning all slots on our instance are full — the scale-out alarm fires and adds one instance. When the count drops to 0 and stays there for 5 consecutive minutes, the scale-in alarm fires and removes one. SimpleScaling policies with a 60 second cooldown on scale-out and 300 seconds on scale-in.</p>

<h2 id="doing-the-maths-first">Doing The Maths First</h2>

<p>Before writing any code I wanted to know whether this was actually worth building. I pulled 14 days of GitHub Actions history from the API and built a queue-wait report — looking at how often all 4 runner slots were occupied, how long jobs were waiting, and what the actual queuing pattern looked like.</p>

<p>The data made it pretty clear. There were regular bursts where all 4 slots were full and jobs were backing up. Not constantly, but often enough that developers were noticing and occasionally scheduling their pushes around each other to avoid the queue.</p>

<p>I also ran cost projections for the additional instance. With a scale-out threshold of 4 and a 5 minute scale-in window, the extra instance would run for maybe a few hours per month during peak periods. The projected cost was under $3/month. For context, the time developers were losing to queue waits was worth significantly more than that.</p>

<p>The report made the decision easy. This wasn’t speculative infrastructure work — there was a concrete problem with measurable impact, and the fix was cheap.</p>

<h2 id="the-implementation">The Implementation</h2>

<p>The Lambda handler is straightforward. The interesting decisions are mostly in the configuration.</p>

<p><strong>Why poll every minute?</strong> Because CloudWatch alarms evaluate on metric periods, and a 1 minute period gives us a 1 minute reaction time to a full queue. Our instances take about a minute and a half to launch and register as runners, so the total time from “all slots full” to “new runner available” is around 2-3 minutes. That’s fast enough that most queued jobs only wait one cycle.</p>

<p><strong>Why a scale-out threshold of 4?</strong> Because we run 4 runner processes per instance. When all 4 are busy, the next job to arrive has nowhere to go. Setting the threshold at 4 means we trigger scale-out exactly when the queue would start building. Setting it lower would mean scaling out while there are still free slots, which wastes money. Setting it higher isn’t possible — you can’t have more than 4 busy runners on a 4-runner instance.</p>

<p><strong>Why scale-in at 0 for 5 minutes?</strong> Because we don’t want the extra instance oscillating up and down between bursts. If a developer pushes 3 PRs in sequence, we don’t want to scale in after the first batch finishes and then immediately scale out again when the next batch starts. The 5 minute window with a 300 second cooldown gives enough buffer for back-to-back pushes without keeping the instance running for hours after everyone’s gone home.</p>

<p><strong>Why SimpleScaling instead of StepScaling?</strong> Honestly, because StepScaling doesn’t support cooldown periods and I only discovered that when the Terraform apply failed halfway through. SimpleScaling does exactly what we need — add one instance when busy, remove one when idle. There’s no need for graduated steps when the ASG is scaling between 1 and 2 instances.</p>

<p><strong>What about <code class="language-plaintext highlighter-rouge">treat_missing_data</code>?</strong> Both alarms use <code class="language-plaintext highlighter-rouge">notBreaching</code>. If the Lambda fails to publish a metric for a few minutes — maybe the GitHub API is slow, maybe the Lambda times out — we don’t want that to trigger a scale-in. Missing data means “we don’t know,” not “nobody’s busy.” This is a small detail that prevents a bad scenario where a Lambda failure causes the ASG to scale down and drop running jobs.</p>

<h2 id="validating-it">Validating It</h2>

<p>I tested this in dev first by opening 5 draft PRs at once against the robot software repo. Each PR triggers an integration workflow that uses the self-hosted runners, so 5 PRs means 5+ jobs competing for 4 runner slots.</p>

<p>The first dev test showed the autoscaling working but exposed the bootstrap latency. The scale-out alarm fired within about 15 seconds of all slots filling up, and the ASG launched a new instance almost immediately. But the new instance took about 5 minutes from launch to picking up its first job — the runner binary had to download, register with GitHub, and start the listener processes. That first-job latency was too slow for what I wanted.</p>

<p>The AMI baking work from <a href="/2026/03/26/ami-baking-pipeline.html">Part 2</a> is what fixed this. With the runner binary and dependencies pre-baked into the AMI, the bootstrap got much faster. By the time I validated in production, the scale-out alarm was firing about 1 minute 42 seconds after the burst started, and the new instance was launching 8 seconds later. Total time from burst to new capacity was under 2 minutes.</p>

<p>The scale-in worked cleanly too. After all the PR jobs finished, the busy runner count dropped to 0, the alarm waited through its 5 evaluation periods, and the ASG scaled back to 1. No manual cleanup, no orphaned instances.</p>

<h2 id="what-it-costs">What It Costs</h2>

<p>The production autoscaling has been running since mid-March 2026. In the first few days of real usage, the extra instance ran for about 2.5 hours total during burst periods. That’s roughly 60 cents.</p>

<p>For a system that eliminates queue waits during peak development hours and requires zero manual intervention, that’s a pretty good trade. The Lambda itself runs within the free tier — 128MB of memory, 30 second timeout, once per minute. The CloudWatch metrics and alarms add negligible cost.</p>

<p>The most expensive part of the whole setup was the time I spent building the queue-wait report to justify it.</p>

<h2 id="the-whole-stack">The Whole Stack</h2>

<p>For anyone thinking about doing something similar, here’s what the infrastructure looks like:</p>

<p>One Lambda function (Python 3.12, 128MB, 30s timeout) triggered every minute by EventBridge. It reads a GitHub PAT from SSM, paginates the org runners API, counts busy runners by label, and publishes a CloudWatch custom metric.</p>

<p>Two CloudWatch alarms watching that metric — one for scale-out (busy count &gt;= runner concurrency), one for scale-in (busy count = 0 for N consecutive periods). Both use the Maximum statistic and treat missing data as not breaching.</p>

<p>Two ASG SimpleScaling policies — one that adds 1 instance, one that removes 1. Cooldowns of 60 seconds and 300 seconds respectively.</p>

<p>The whole thing is behind an <code class="language-plaintext highlighter-rouge">enable_job_autoscaling</code> feature flag in Terraform, so it can be toggled per environment. Dev and prod each have their own Lambda, alarms, and policies, publishing metrics with different runner label dimensions so they don’t interfere with each other.</p>

<h2 id="whats-next">What’s Next</h2>

<p>The autoscaling is running and the cost is negligible, but there’s a bigger story here about how the tooling around it evolved. The Lambda handler was one of the first things I wrote in Python for this project. The rest of the infrastructure — the runner bootstrap, the AMI baking, the operational CLI — started as bash and stayed as bash longer than it should have. That migration, and why it mattered, is <a href="/2026/04/04/shell-script-to-python-runtime.html">Part 4</a>.</p>

<hr />

<p><em>This is <strong>Part 3</strong> of a series on building production-grade self-hosted GitHub Actions runners at a robotics startup.</em></p>

<ul>
  <li><em><strong>Part 1:</strong> <a href="/2026/03/20/single-ec2-entire-ci.html">How a Single EC2 Instance Runs Our Entire CI Pipeline</a></em></li>
  <li><em><strong>Part 2:</strong> <a href="/2026/03/26/ami-baking-pipeline.html">We Replaced Packer With a GitHub Actions Workflow</a></em></li>
  <li><em><strong>Part 3:</strong> Autoscaling GitHub Runners Without Webhooks (you are here)</em></li>
  <li><em><strong>Part 4:</strong> <a href="/2026/04/04/shell-script-to-python-runtime.html">From Shell Script to Python Runtime</a> — how the runner bootstrap evolved over 7 months</em></li>
  <li><em><strong>Part 5:</strong> <a href="/2026/04/06/safe-infra-changes-team-of-one.html">Safe Infrastructure Changes With a Team of One</a> — dev/prod isolation and deployment gates without a platform team</em></li>
</ul>]]></content><author><name>Daniel Kelly</name></author><category term="devops" /><category term="github-actions" /><category term="aws" /><category term="autoscaling" /><category term="lambda" /><summary type="html"><![CDATA[A 170-line Lambda, two CloudWatch alarms, and a polling model that's more reliable than any webhook. How we added burst capacity for under $1/month.]]></summary></entry><entry><title type="html">We Replaced Packer With a GitHub Actions Workflow</title><link href="https://edition-x.github.io/2026/03/26/ami-baking-pipeline.html" rel="alternate" type="text/html" title="We Replaced Packer With a GitHub Actions Workflow" /><published>2026-03-26T00:00:00+00:00</published><updated>2026-03-26T00:00:00+00:00</updated><id>https://edition-x.github.io/2026/03/26/ami-baking-pipeline</id><content type="html" xml:base="https://edition-x.github.io/2026/03/26/ami-baking-pipeline.html"><![CDATA[<p>In <a href="/2026/03/20/single-ec2-entire-ci.html">Part 1</a> I talked about how we run our entire CI pipeline on a single EC2 instance with 4 concurrent runner processes. The secret ingredient was a custom AMI with our 23GB devcontainer image pre-baked into the Docker cache, eliminating the 6-8 minute image pull that was killing our build times.</p>

<p>What I didn’t talk about was how we build that AMI. And honestly, this is where things got interesting.</p>

<p>We don’t use Packer. We don’t use EC2 Image Builder. We don’t have a separate build server or a dedicated image pipeline. Instead, we bake our AMIs in-place — we take the running dev instance, patch it, update the cached Docker images, snapshot it, and roll forward. The whole thing is orchestrated by a single GitHub Actions workflow.</p>

<p>It works really well now. Getting here was a different story.</p>

<h2 id="why-not-just-use-packer">Why Not Just Use Packer?</h2>

<p>Packer is the standard answer to “how do I build AMIs” and for good reason — it’s mature, well-documented, and handles a lot of the complexity for you. But when I looked at what we actually needed, Packer felt like it was solving a bigger problem than we had.</p>

<p>Our setup is a single EC2 instance running GitHub Actions runners. The AMI doesn’t need to be built from scratch each time — it needs to be <em>updated</em>. New OS patches, a fresh pull of the devcontainer image, maybe a new version of the runner binary. The base doesn’t change. The delta is small.</p>

<p>Building from scratch with Packer would mean spinning up a temporary instance, running a full provisioning script, waiting for a 23GB Docker image to download over the network, creating the AMI, and tearing it all down. That’s 30+ minutes of build time and a bunch of infrastructure to maintain for something we only do once a week.</p>

<p>What if instead we just took the instance that’s already running — the one that already has the 23GB image cached, the runner binary installed, all the dependencies in place — and turned <em>that</em> into the next AMI?</p>

<h2 id="the-bake-in-place-approach">The Bake-in-Place Approach</h2>

<p>Here’s what the pipeline actually does, step by step:</p>

<p><strong>1. Detach the instance from the ASG.</strong> We lower the ASG min_size to 0 and detach the current instance. This is important because we need to stop the instance to create a clean snapshot, but we don’t want the ASG to panic and launch a replacement while we’re working.</p>

<p><strong>2. Patch via SSM.</strong> Once the instance is detached, we run <code class="language-plaintext highlighter-rouge">AWS-RunPatchBaseline</code> through Systems Manager. This applies any pending OS security patches without needing SSH access. The instance is still running at this point, we’re just using it as a build target.</p>

<p><strong>3. Pre-pull the latest devcontainer image.</strong> This is the step that matters most for our use case. We send an SSM command that logs into GHCR, pulls the latest <code class="language-plaintext highlighter-rouge">devcontainer:stable</code> image, and verifies it landed correctly. Because the instance already has the previous version cached, Docker only downloads the changed layers. What would be a 23GB pull from scratch is usually a few hundred MB of deltas.</p>

<p><strong>4. Clean up runner state.</strong> Before snapshotting, we need to remove anything that’s specific to this particular instance — runner registration credentials, systemd service state, work directories, cloud-init caches. If any of this ends up in the AMI, the next instance that boots from it will have identity conflicts.</p>

<p><strong>5. Stop the instance and create the AMI.</strong> We stop the instance (not terminate), create an image from it, tag the AMI and its snapshots, and wait for it to become available.</p>

<p><strong>6. Update the SSM parameter and roll forward.</strong> The new AMI ID gets written to an SSM parameter. Then we run <code class="language-plaintext highlighter-rouge">terraform plan</code> and <code class="language-plaintext highlighter-rouge">terraform apply</code>, which picks up the new AMI ID and updates the launch template. The ASG launches a fresh instance from the new AMI.</p>

<p><strong>7. Smoke test.</strong> We wait for the new instance to come up, verify via SSM that the runner service is healthy, and only then terminate the old detached instance.</p>

<p>The whole process takes about 15-20 minutes, most of which is waiting for the AMI to finish creating and the new instance to boot. The actual “work” — patching, pulling, cleaning — is maybe 5 minutes.</p>

<h2 id="the-day-everything-broke-22-commits-in-24-hours">The Day Everything Broke (22 Commits in 24 Hours)</h2>

<p>The first version of this pipeline worked on the happy path. The problem was that AMI operations on AWS have a lot of unhappy paths, and I found most of them on November 6th, 2025.</p>

<p>I was adding AMI cleanup — the part of the pipeline that deletes old AMIs so they don’t pile up and cost money. Simple enough in theory: list all AMIs matching a naming pattern, keep the newest ones, delete the rest along with their EBS snapshots.</p>

<p>The first version used inline bash in the GitHub Actions workflow, shelling out to the AWS CLI and piping through <code class="language-plaintext highlighter-rouge">jq</code> for JSON parsing. It worked locally. It did not work in CI.</p>

<p>Here’s what went wrong, roughly in the order I discovered it:</p>

<p><strong>Two machines, two different toolsets.</strong> The GitHub-hosted runner where the orchestration runs and the EC2 instance where the commands actually execute are two completely different environments with different tools installed. What’s available on one isn’t necessarily available on the other, and I was making assumptions about both.</p>

<p><strong>The AWS CLI <code class="language-plaintext highlighter-rouge">--query</code> parameter is unforgiving.</strong> I had a filter that was supposed to exclude the newest AMI from the deletion list, but the sort order wasn’t doing what I thought it was. The cleanup was keeping the wrong AMI and trying to delete the one it should have kept. These are the kinds of bugs you don’t catch until you have enough data to exercise the edge case — in this case, more than 2 AMIs.</p>

<p><strong>Eventually-consistent API responses.</strong> Sometimes <code class="language-plaintext highlighter-rouge">describe-images</code> returns an empty list briefly after a new AMI is created but before it’s fully registered. My code assumed the list would always have at least one entry and crashed with an index error. Needed retry logic for empty state responses.</p>

<p><strong>Bash arithmetic will betray you.</strong> I had a timeout calculation where operator precedence silently changed the meaning of the expression. The timeout was effectively infinite for the first few iterations and then immediately expired.</p>

<p><strong>Snapshot lifecycle quirks.</strong> When you deregister an AMI, its EBS snapshots don’t automatically get deleted — you have to delete them separately. But sometimes the snapshot is briefly “in use” even after the AMI is deregistered, and the delete call fails. More retry logic, more error handling.</p>

<p><strong>Inconsistent JSON output.</strong> The AWS CLI’s <code class="language-plaintext highlighter-rouge">--output json</code> flag formats things differently depending on whether there’s one result or many. A single AMI ID comes back as a string, multiple come back as an array. The bash script was handling one case but not the other.</p>

<p>That was 22 commits in a single day. Every time I’d fix one issue, the pipeline would get a little further and hit the next one. By the end of the day I had a working cleanup process, but the bash was getting messy. I knew I’d need to rewrite it properly at some point, but I had other priorities and it was working — I’ll talk more about that migration in <a href="/2026/04/04/shell-script-to-python-runtime.html">Part 4</a> of this series.</p>

<h2 id="the-safety-cap-that-saved-us">The Safety Cap That Saved Us</h2>

<p>One of the better decisions I made during that November 6th marathon was adding a safety cap to AMI deletions. The cleanup takes a <code class="language-plaintext highlighter-rouge">max_delete_per_run</code> parameter, and if the number of AMIs to delete exceeds that limit, it refuses to run and throws an error instead.</p>

<p>I started with a cap of 3. Within a few weeks, normal operation pushed us past that — we had a stretch where the weekly bake ran but cleanup kept failing silently, so AMIs accumulated. Bumped it to 5 in December. Hit the limit again in January after another cleanup regression. Bumped it to 10.</p>

<p>The cap seems conservative, but it’s there to prevent a bug in the AMI listing logic from accidentally deleting everything. If some API response comes back wrong and the “keep” list is empty, the cap ensures we delete at most N images instead of all of them. It’s a safety net for exactly the kind of API edge cases that caused all those November problems.</p>

<p>The logic itself is simple: the AMI list comes in sorted by creation date, we keep the newest N, and we refuse to delete more than the safety cap. That’s it. The simplicity is the point — when something guards against accidental deletion, you want it to be easy to reason about.</p>

<h2 id="devprod-promotion-the-full-pipeline">Dev/Prod Promotion: The Full Pipeline</h2>

<p>The bake pipeline only touches the dev environment. It creates the AMI from the dev instance, writes the new AMI ID to an SSM parameter, and rolls the dev ASG forward. Prod is a completely separate concern.</p>

<p>Getting the new AMI into production used to be a manual step — go to the GitHub Actions UI, pick the <code class="language-plaintext highlighter-rouge">promote_ami</code> action, trigger it. That worked fine, but it meant someone had to remember to do it. Now the whole thing is automated as part of the weekly scheduled run.</p>

<p>Every Sunday, the pipeline runs end-to-end: bake the dev AMI, promote the AMI ID from the dev parameter to the prod parameter, run <code class="language-plaintext highlighter-rouge">terraform apply</code> against prod to pick up the new launch template, roll any outdated runner instances, and send a Slack notification with the result. If any step fails, the pipeline stops and Slack gets a failure notification instead. The manual promote is still there in the prod workflow as a fallback if something needs to jump the queue.</p>

<p>The promotion itself is deliberately boring — it reads one SSM parameter and writes the value to another. But the pipeline around it means dev always bakes first, and if the bake fails, prod doesn’t get touched. Running in dev for a week before promoting to prod has caught real problems that the smoke test didn’t.</p>

<h2 id="whats-next">What’s Next</h2>

<p>This post covered the AMI pipeline — how we build it, what broke along the way, and how we promote changes safely from dev to prod. There’s more to say about the bash-to-Python migration specifically: the runner bootstrap went through the same journey, and that rewrite touched a lot more than just the AMI cleanup code. I’ll cover that properly in <a href="/2026/04/04/shell-script-to-python-runtime.html">Part 4</a>.</p>

<p>But first — there’s a question I’ve been dodging: how does the system know when to scale? We don’t use webhooks. We don’t use GitHub’s built-in autoscaling. We have a Lambda function that runs every minute, checks how many runners are busy, and publishes a CloudWatch metric. The ASG scales off that metric. It’s about 170 lines of code and it’s been more reliable than any webhook-based approach I’ve seen.</p>

<hr />

<p><em>This is <strong>Part 2</strong> of a series on building production-grade self-hosted GitHub Actions runners at a robotics startup.</em></p>

<ul>
  <li><em><strong>Part 1:</strong> <a href="/2026/03/20/single-ec2-entire-ci.html">How a Single EC2 Instance Runs Our Entire CI Pipeline</a></em></li>
  <li><em><strong>Part 2:</strong> We Replaced Packer With a GitHub Actions Workflow (you are here)</em></li>
  <li><em><strong>Part 3:</strong> <a href="/2026/04/02/autoscaling-without-webhooks.html">Autoscaling GitHub Runners Without Webhooks</a> — how a 170-line Lambda became our entire scaling layer</em></li>
  <li><em><strong>Part 4:</strong> <a href="/2026/04/04/shell-script-to-python-runtime.html">From Shell Script to Python Runtime</a> — how the runner bootstrap evolved over 7 months</em></li>
  <li><em><strong>Part 5:</strong> <a href="/2026/04/06/safe-infra-changes-team-of-one.html">Safe Infrastructure Changes With a Team of One</a> — dev/prod isolation and deployment gates without a platform team</em></li>
</ul>]]></content><author><name>Daniel Kelly</name></author><category term="devops" /><category term="github-actions" /><category term="aws" /><category term="ami" /><category term="python" /><summary type="html"><![CDATA[In-place AMI baking, 22 commits in 24 hours, and the safety cap that stopped us from deleting everything.]]></summary></entry><entry><title type="html">How a Single EC2 Instance Runs Our Entire CI Pipeline</title><link href="https://edition-x.github.io/2026/03/20/single-ec2-entire-ci.html" rel="alternate" type="text/html" title="How a Single EC2 Instance Runs Our Entire CI Pipeline" /><published>2026-03-20T00:00:00+00:00</published><updated>2026-03-20T00:00:00+00:00</updated><id>https://edition-x.github.io/2026/03/20/single-ec2-entire-ci</id><content type="html" xml:base="https://edition-x.github.io/2026/03/20/single-ec2-entire-ci.html"><![CDATA[<p>Look, I’m not going to pretend I set out to build something clever here. I’m a DevOps engineer at a robotics startup. We needed CI that worked. GitHub-hosted runners didn’t. So I built something that did.</p>

<p>7 months and 166 commits later, we’re running our entire CI pipeline — builds, tests, linting, Docker-based jobs, the lot — on a single EC2 instance. One. Not a fleet. Not Kubernetes. Not some managed runner service that charges per minute. One <code class="language-plaintext highlighter-rouge">m7i.xlarge</code> in <code class="language-plaintext highlighter-rouge">eu-central-1</code>.</p>

<p>And it handles everything.</p>

<h2 id="a-23gb-container-image-and-nowhere-to-put-it">A 23GB Container Image and Nowhere to Put It</h2>

<p>Here’s the thing nobody warns you about when you’re building robotics software with GitHub Actions: your devcontainer image will be enormous, and GitHub really does not want you to use it.</p>

<p>Our devcontainer image is about 23GB. It has ROS 2 Humble, a full robotics toolchain, simulation dependencies, OpenCV, BLAS libraries, <code class="language-plaintext highlighter-rouge">colcon</code> build tools — everything you need to build and test a robotics monorepo. It’s big because robotics is big. There’s no way around it.</p>

<p>Standard GitHub-hosted runners have 14GB of disk space. Our image literally does not fit. So right from the start, we were forced onto GitHub’s <code class="language-plaintext highlighter-rouge">Large-GPU-Runner</code> — a beefier hosted runner that costs significantly more per minute. And even on the large runner, every single CI run started by pulling that 23GB image from scratch. That pull alone took 6-8 minutes. Every. Single. Run.</p>

<p>We looked at caching it. GitHub’s built-in action cache has a 10GB limit. The Docker cache action is backed by the same 10GB store. Our image is 23GB. Dead end.</p>

<p>So before a single line of our code even started compiling, we were already 6-8 minutes into the job and burning money on oversized hosted runners. Multiply that by every PR, every push, every developer on the team. At a startup, time and money are the same thing, and we were wasting both.</p>

<h2 id="the-obvious-answer-and-why-i-didnt-use-it">The Obvious Answer (and Why I Didn’t Use It)</h2>

<p>Self-hosted runners were the obvious fix. But the question was how.</p>

<p>I looked at the options. There are managed self-hosted runner services. There’s the actions-runner-controller for Kubernetes. There are Terraform modules with hundreds of variables that try to cover every possible use case.</p>

<p>They all felt like overkill for what we needed.</p>

<p>We’re a small team. We don’t have a Kubernetes cluster to throw runners onto (and honestly, running K8s just to host CI runners is a special kind of infrastructure madness). The managed services charge per-minute pricing that, once you do the maths, isn’t that different from just paying GitHub directly. And the big open-source Terraform modules were so configurable that configuring them felt like a project in itself.</p>

<p>So I did what any engineer does when the existing tools don’t fit. I opened a new repo, wrote an initial commit, and started building.</p>

<h2 id="the-architecture-its-embarrassingly-simple">The Architecture (It’s Embarrassingly Simple)</h2>

<p>Here’s the entire infrastructure:</p>

<ul>
  <li>1 x <code class="language-plaintext highlighter-rouge">m7i.xlarge</code> EC2 instance (4 vCPUs, 16 GiB RAM)</li>
  <li>1 x Auto Scaling Group (min 1, max 2)</li>
  <li>1 x Launch Template with a custom AMI</li>
  <li>1 x Lambda function for autoscaling signals</li>
  <li>A handful of CloudWatch alarms</li>
</ul>

<p>That’s it. No load balancer. No container orchestration. No service mesh. The instance boots, registers itself with GitHub, and starts picking up jobs.</p>

<p>The key insight — and this took me a while to land on — is <strong>runner concurrency</strong>. A single GitHub Actions runner process can only handle one job at a time. But nothing stops you from running multiple runner processes on the same machine. So I run 4. One per vCPU.</p>

<p>When the instance launches, 4 separate runner processes register with our GitHub org. GitHub sees 4 idle runners, all with the same labels, and distributes jobs across them automatically. 4 concurrent CI jobs, one instance. Each runner gets about 1 vCPU and 4 GiB RAM, which is plenty for our workloads.</p>

<h2 id="the-real-win-zero-image-pull-time">The Real Win: Zero Image Pull Time</h2>

<p>The concurrency trick is nice, but the real win is what’s baked into the AMI.</p>

<p>Remember that 6-8 minute image pull? Gone. Completely gone. Our custom AMI has the 23GB devcontainer image pre-pulled into the Docker cache. When a CI job starts and specifies <code class="language-plaintext highlighter-rouge">container: image: ghcr.io/...devcontainer:stable</code>, Docker finds it locally and starts it in seconds. No network transfer, no waiting, no praying that GHCR doesn’t throttle you.</p>

<p>We bake a fresh AMI weekly. The pipeline detaches the current instance from the ASG, patches it via SSM, pre-pulls the latest devcontainer image, snapshots it, rolls the ASG forward to the new AMI, runs a smoke test, and terminates the old instance. No Packer, no separate build infrastructure — just GitHub Actions orchestrating AWS APIs.</p>

<p>That AMI baking pipeline deserves its own post (the number of edge cases we hit building it was genuinely absurd), but the point is: eliminating the image pull is what made the biggest single difference. Everything else is optimisation on top.</p>

<p>On the very first day I set this up, I actually ran a side-by-side comparison — the same test suite running on both the <code class="language-plaintext highlighter-rouge">Large-GPU-Runner</code> and the new self-hosted runner in parallel. The difference was immediately obvious. All that time we’d been sinking into image pulls, environment setup, and cold caches just vanished.</p>

<h2 id="from-ephemeral-to-persistent-and-why-that-matters">From Ephemeral to Persistent (and Why That Matters)</h2>

<p>My first version used ephemeral runners. Fresh registration for every job, clean state every time. It’s what most guides recommend and it feels “correct” from a security standpoint.</p>

<p>It was also slow.</p>

<p>Every job had to re-register the runner with GitHub, pull a fresh token, configure the runner binary. That’s 30-60 seconds of overhead per job, and when you’re running dozens of jobs a day across 4 runner slots, it adds up. Worse, you lose all your workspace caches between jobs. Git clones start from scratch, dependency caches are gone, build artifacts disappear.</p>

<p>About two months in, I switched to persistent runners. The runner processes start when the instance boots and they stay up. They pick up jobs, run them, pick up the next one. State persists between runs, which means git fetches are incremental instead of full clones, Docker layers stay warm, and builds that benefit from cached outputs get those benefits.</p>

<p>The tradeoff is that you need to actively manage workspace hygiene. Stale git lock files from killed jobs, leftover LFS hooks from previous checkouts, repo-local git config that bleeds between projects — all real problems we hit. I ended up writing a pre-job hook that runs before every single job and cleans all of this up. It was annoying to build, but it runs in under a second and it solved the reliability issues completely.</p>

<p>That’s the thing about self-hosted runners that nobody writes about. The initial setup takes a day. Getting it production-ready takes months. The edge cases that only surface when you’ve been running hundreds of jobs a week — those are where the real work is. But once you’ve solved them, they stay solved.</p>

<h2 id="what-about-cost">What About Cost?</h2>

<p>This is the part everyone actually cares about, so let me be real about it.</p>

<p>An <code class="language-plaintext highlighter-rouge">m7i.xlarge</code> on-demand in <code class="language-plaintext highlighter-rouge">eu-central-1</code> runs about $170/month. We could cut that significantly with reserved instances or savings plans, but we haven’t bothered yet because even at on-demand pricing, the maths works out easily.</p>

<p>Compare that to what we were paying before. The <code class="language-plaintext highlighter-rouge">Large-GPU-Runner</code> is one of GitHub’s premium hosted runner tiers — you’re paying a meaningful premium per minute for those. And we had no choice but to use them because standard runners couldn’t even fit our image. With self-hosted, we went from paying per-minute for oversized hosted runners to a flat monthly cost for a machine that’s always warm and always ready.</p>

<p>But the real cost saving isn’t the compute bill. It’s developer time. When every CI run was burning 6-8 minutes just pulling a container image before doing anything useful, that’s dead time on every single push. Across a team, across a day, across a sprint — it compounds into something massive. Eliminating that wait was worth more than any line item on the AWS bill.</p>

<h2 id="the-autoscaling-story">The Autoscaling Story</h2>

<p>For the first 6 months, we just ran a single instance with <code class="language-plaintext highlighter-rouge">max_size = 1</code>. No autoscaling. One box, 4 runners. It handled everything.</p>

<p>Recently, as the team and the number of pipelines grew, we started occasionally saturating all 4 slots. So I added job-driven autoscaling: a Lambda function runs every minute, calls the GitHub API to count how many runners with our label are currently busy, and publishes that as a CloudWatch metric. Standard ASG scaling policies react to it — scale out by 1 when all 4 runners are busy, scale back in when they’ve been idle for 5 minutes.</p>

<p>The whole autoscaling layer is about 170 lines of Python and some Terraform resources. It costs almost nothing to run (a Lambda invocation per minute is well within free tier), and it means we can burst to 8 concurrent jobs when we need to without paying for that second instance when we don’t.</p>

<p>I deliberately chose this polling approach over webhook-based autoscaling. Webhooks mean exposing a public endpoint, managing authentication, handling delivery failures. The polling model is simpler, cheaper, and “good enough” at 60-second granularity. We also did a bunch of work to get the ASG instance startup time down — started at around 5 minutes, got it to just over a minute. I’ll dig into the detail of that in a later post in this series.</p>

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p>If I started this project over, I’d skip ephemeral runners entirely and go straight to persistent. The security argument for ephemeral runners makes sense in theory, but in practice, if you’re running your own infrastructure, you’re already trusting the machine. The performance difference is too significant to ignore.</p>

<p>I’d also write the bootstrap runtime in Python from day one instead of starting with shell scripts. The first version was a cloud-init template with bash doing all the heavy lifting — fetching tokens, registering runners, managing systemd services. It worked, but it was brittle and nearly impossible to test. The Python rewrite gave us proper error handling, structured logging, and actual unit tests. Should have done that from the start.</p>

<p>Everything else — the Terraform structure, the ASG-based approach, the concurrency model — I’d keep exactly the same. It’s been running in production for 7 months, it’s handled thousands of CI jobs, and the total infrastructure cost is less than what most teams spend on a single SaaS tool.</p>

<h2 id="the-takeaway">The Takeaway</h2>

<p>You don’t need a complex orchestration layer to run self-hosted GitHub Actions runners. You don’t need Kubernetes. You don’t need a managed service. For most small-to-medium teams, a single well-configured EC2 instance with multiple concurrent runner processes will handle more than you think.</p>

<p>The trick is getting the foundations right: a custom AMI with your dependencies baked in, persistent runners for fast job starts, good workspace hygiene between jobs, and just enough autoscaling to handle burst load.</p>

<p>Start simple. You can always add complexity later. But you probably won’t need to as soon as you think.</p>

<hr />

<p><strong>This is Part 1 of a series on building production-grade self-hosted GitHub Actions runners.</strong> Coming up next:</p>

<ul>
  <li><strong>Part 2:</strong> <a href="/2026/03/26/ami-baking-pipeline.html">We Replaced Packer With a GitHub Actions Workflow</a> — our zero-downtime AMI baking pipeline and the edge cases that nearly broke it</li>
  <li><strong>Part 3:</strong> <a href="/2026/04/02/autoscaling-without-webhooks.html">Autoscaling GitHub Runners Without Webhooks</a> — how a 170-line Lambda became our entire scaling layer</li>
  <li><strong>Part 4:</strong> <a href="/2026/04/04/shell-script-to-python-runtime.html">From Shell Script to Python Runtime</a> — how the runner bootstrap evolved over 7 months</li>
  <li><strong>Part 5:</strong> <a href="/2026/04/06/safe-infra-changes-team-of-one.html">Safe Infrastructure Changes With a Team of One</a> — dev/prod isolation and deployment gates without a platform team</li>
</ul>]]></content><author><name>Daniel Kelly</name></author><category term="devops" /><category term="github-actions" /><category term="aws" /><category term="ci-cd" /><category term="robotics" /><summary type="html"><![CDATA[One m7i.xlarge, four concurrent runners, zero image pull time. How we built a production CI pipeline for a robotics startup for less than $170/month.]]></summary></entry></feed>