The problem is simple to state: a workflow can label an issue, wake Claude Code, have it read your repo and instructions, write code, run tests, and open a pull request — all cleanly — and then stop dead the moment it needs something only your own network can give it. A database in a private subnet, an internal service, a preview deploy: none of it is reachable from a machine sitting on GitHub's own infrastructure, because that's exactly how private subnets are supposed to work.
The fix is ownership of the machine itself. A GitHub Actions runner never gets connected to — it phones out, holds an outbound connection open, and waits for work. That means it can sit deep in a private subnet with no route to the internet and still pick up jobs, and everything the job then does reaches out from inside your network.
Two ways to get there are laid out and compared directly: letting AWS CodeBuild's runner mode spin up a container per job and throw it away when done, or standing up and registering your own instances or Kubernetes pods. The recommendation is the managed option for almost everyone, with real per-minute and startup-time figures backing the trade-off, plus a full walkthrough — connecting the account to GitHub, creating the project, wiring subnets and a NAT path with interface endpoints, the exact runs-on label syntax the run identifier and attempt number require, authenticating through the project's own role versus an explicit federated handshake, and why ephemeral runners matter (a permanent, non-ephemeral runner can quietly hand one job's leftover cloud credentials to the next). It closes with the single most common failure — a job stuck silently "waiting for a runner" — and the two-minute check for it: the label, then the webhook delivery log.
A short round of Claude Code updates, several of which land right on this setup. The self-hosted runner command gained a shutdown-delay flag and two ways to supply proxy authorization, relevant the moment runners sit behind an authenticating proxy in a locked-down subnet. The same release fixed prompt caching for gateways using a custom base address, and fixed a memory leak in long unattended sessions. The introductory Sonnet 5 pricing has been made permanent, cancelling the planned September increase. Two default behaviors changed — todo/task-tracking tools are now off by default, and forked subagents are now the default — each with an environment variable to reverse it. The bundled API skill was cut from over 200,000 tokens of context down to roughly 25,000. And the temporary boost to weekly usage limits has expired.
Here is the wall you have walked into by now.
The label goes on the issue. The workflow fires. Claude Code wakes up on a fresh machine, reads the repository, reads the project instructions, writes code, runs the unit tests, commits to a branch, and opens a pull request. The run is green. The agent behaved. Nothing in the audit log looks wrong. And the pipeline stops right there, every single time, because the next thing you want it to do is something it cannot physically do.
You want it to run the tests that talk to the real database. You want it to hit the internal service that only answers to callers inside your network. You want it to deploy the branch to a preview environment and poke at it. And it cannot, because the machine the run happened on is not yours. It belongs to GitHub. It sits on GitHub's network. Your database is in a private subnet in your own account, reachable from inside your own network and nowhere else, which is exactly how you set it up, on purpose, because that is how you keep it from being reachable by the entire internet.
So the run is not failing. The run is stranded. It is doing everything you asked on a machine standing outside the building.
That is the rung this episode climbs. Not a new agent trick, not a new prompt, not a new safety layer. Ownership of the machine. By the end you will have one job in one workflow that runs Claude Code on a runner living inside your own Amazon Web Services account, on your own network, with a path to the private things your application actually depends on, authenticating to the cloud without a long-lived key sitting anywhere in your repository, and disappearing completely when the job finishes.
Everything you have built so far comes along unchanged. The workflow plumbing from the Actions foundations episode — the triggers, the top-level permissions block, the federated identity handshake, the job timeout, the concurrency group — is settled and does not get relitigated here. The sandboxing and the deny rules from the autonomous-run safety episode stay exactly as they are. The bounded credentials, the narrow role, the branch protection that stops the agent merging its own work, all of that from the blast-radius episode stays. The telemetry you wired up in the fleet observability episode keeps streaming, and it keeps working, because it does not care what kind of machine it is running on.
What changes is the stakes on all of it. Up to now, if a run went badly wrong, the worst it could touch was a throwaway virtual machine on somebody else's network plus whatever your narrow role allowed. Starting now, the run happens next to real infrastructure. The machine can open a socket to your production database if the network lets it. The instance it runs on may carry a role of its own. That earlier bounding work stops being belt-and-braces and becomes the actual thing standing between one bad turn and a very bad afternoon. So do not treat this episode as a place to loosen anything. Treat it as the episode where the locks you already fitted start earning their keep.
Start with the plainest possible version of the mechanism, because a lot of people carry a fuzzy picture of it.
A GitHub Actions runner is not something GitHub reaches into. It is a small program that phones out. It opens an outbound connection over the standard secure web port, holds it open, and waits to be told there is a job for it. When a job arrives, it pulls the job down, runs the steps on whatever machine it happens to be sitting on, and reports back over that same outbound connection. Nothing ever connects inward to the runner. There is no port to open, no inbound rule, no public address needed.
That single fact is the whole reason this works. Because the runner only ever calls out, you can park it deep inside a private subnet with nothing routable from the internet, and it will still take jobs happily. And because the steps execute on that machine, in that subnet, every network call your job makes starts from inside your network. The database connection resolves. The internal service answers. The private endpoint is right there.
So the job that was stranded is now standing in the building. Same workflow, same agent, same prompt, different floor.
Now, two honest ways to get there, and I am going to tell you which one to pick.
The first is to let a managed build service be the runner. In AWS that is CodeBuild, and it has a mode built exactly for this: it listens for GitHub telling it a job has been queued, spins up a container for that one job, registers it as a runner, runs the job, and throws the container away. You never install anything, never patch anything, never watch a machine.
The second is to stand up runners yourself — an instance, a container task, or pods on a Kubernetes cluster with the runner controller — install the runner package, register it against your repository or organisation, and own the lifecycle from then on.
Take the managed build service. For almost everyone listening, on the first pass, it is the right answer, and the reason is not cost or speed. It is that the second option hands you a long-lived machine to look after, and a long-lived machine that runs an autonomous coding agent is a machine you now have to think about every week. Patching. Disk filling up. Whatever the last run left behind. You did not get into this to run a small fleet of build boxes.
Where the other route earns its keep: you have runs firing constantly, all day, and the wait to get a container going is eating you alive; or your job needs hardware the managed service does not offer; or you already run the Kubernetes runner controller and adding one more pool is a ten-minute change. If none of that is you, take the managed one.
Let me put real numbers on the trade, and be clear about which ones are sourced and which are illustrative.
On price per minute of run time, GitHub's own standard two-core Linux machine runs about eight tenths of a cent a minute. The managed build service, on general-purpose Linux, is about half a cent a minute for the small size at two cores, about a cent for the medium at four cores, and about two cents for the large at eight. Arm-based compute comes in cheaper, in the region of a third to two thirds of a cent. A dedicated instance you keep running yourself, something like a two-core general-purpose box, is roughly eight and a half cents an hour, which works out near a seventh of a cent per minute, and spot pricing can knock most of that off again.
Read those the right way. The small managed size is cheaper per minute than the hosted machine, and the machine you keep running is cheaper still per minute — but only while it is busy. An instance you leave up all night to serve four runs a day is not cheap per minute of useful work, it is just cheap per minute of existing. Whereas the managed build service bills you for build minutes and nothing else, so an idle day costs nothing.
On startup wait, the sourced picture is this. A hosted machine picks the job up in something like two to ten seconds. The managed build service typically takes thirty to ninety seconds, because it has to schedule a container, attach a network interface into your subnet, and let the runner register itself. A machine you keep permanently running picks the job up almost instantly, nought to two seconds. Anything that scales up from nothing — auto-scaled instances, runner controller pods from zero — lands in the same thirty to a hundred and twenty second range.
So the honest cost of the managed route is about a minute of dead air at the front of every job. For an agent run that is going to spend fifteen minutes reading, writing, and testing, a minute of setup is noise. For a lint job that takes eleven seconds, a minute of setup is absurd. That is your rule of thumb: put the long, expensive, network-bound jobs on the private runner and leave the quick public ones on hosted machines. Nothing says every job in the repository has to move.
Now the actual thing, in order.
First, connect the account to GitHub. In the CodeBuild console you create a source connection to your GitHub account or organisation — the same connection type you would define in a CloudFormation template as a source credential resource. This is a one-time step per account and it is what lets the build service receive GitHub's webhook and register runners on your behalf.
Second, create the project that will be the runner. Source provider is GitHub, pointed at the repository. Then the part that makes it a runner rather than an ordinary build: set the webhook to trigger on the queued-workflow-job event. That event is GitHub saying "a job is waiting and it asked for you." Nothing else about the project needs to look like a build. There is no build script to write. The service ignores any build specification file sitting in your repository and injects its own runner bootstrap instead — which surprises people, so know it now. If you genuinely need your own build specification to run, there is an explicit label you add to opt into that, and until you add it, yours is ignored on purpose.
Third, and this is the whole point of the episode, put the project on your network. In the project's network settings, choose your virtual private cloud, select the private subnet identifiers you want the runner to live in, and attach a security group. From that moment, every container this project starts gets a network interface in those subnets, and every call your job makes originates from inside your network.
Get the routing right or nothing works. The private subnet must not have a route to an internet gateway — that is what makes it private. But the runner still has to call out to GitHub to get its job, and Claude Code still has to call out to the model application programming interface to do any thinking at all. So the default route in that subnet's route table has to point at a network address translation gateway, or an egress firewall, sitting in a public subnet. The security group needs outbound access on the secure web port; it does not need a single inbound rule, and you should delete the ones the console gives you unless you have a reason for internal administrative access.
That translation gateway charges you for every gigabyte it processes, and a coding agent pulling packages and container images all day will process a lot of gigabytes. So put interface endpoints in the network for the AWS services your job actually touches — the container registry pair, the parameter and session channels, logs, and the token service — plus a gateway endpoint for object storage. Traffic to those then stays on the AWS backbone instead of going out and back through the translation gateway, which is both cheaper and one less thing in the path when you are debugging.
Fourth, point the job at it. This is one line in the workflow, and it is fussier than it looks. The runs-on value has to be the literal string codebuild, then a hyphen, then your project name, then a hyphen, then the run identifier from the github context, then a hyphen, then the run attempt. Those last two are not decoration. The build service uses the run identifier and the attempt number to tie one container to one specific job, and to kill the container if the workflow gets cancelled underneath it. Leave them out and the job sits there forever.
You can add override labels alongside that one to pick image and size — a list where the first entry is the codebuild name and the rest are things like an Arm image and a small instance size. If your workflow has more than one job, add a unique label of your own to each, something as dumb as job-one and job-two. Without it, GitHub can hand a runner to whichever job has the smaller set of labels, and you get a job running on hardware meant for a different one. There is also a single-label form where the environment type, image, and size are all glued onto the end of the same string with hyphens, if you prefer one long label to a list.
Fifth, authentication, and here you get a small gift. Because the job now runs inside a container the build service started under a role you defined, the environment already carries credentials for that role. No secret to inject. No key in the repository. Nothing to rotate. The narrow role you designed back in the blast-radius episode becomes the project's service role, and the agent's reach is whatever that role's policy allows and not one action more.
Should you still do the explicit federated handshake instead — the identity provider, the trust policy, the credentials action? Sometimes, yes, and here is the case for it. The ambient role is the same role for every job in the project. The federated route lets you write a trust policy that only allows the role to be assumed when the token says it came from this repository on this branch, and it gives you a token exchange in the audit trail for every single assumption. If you want per-workflow separation, or you want the assumption itself logged, do it explicitly: register the identity provider with GitHub's token issuer as the provider and the security token service as the audience, write the trust policy so the subject claim matches your repository and reference, keep the id-token write permission in the workflow, and use the official credentials action with the role to assume and the region. That is the same pattern from the foundations episode, and it works identically on a private runner.
My recommendation: start with the project's own role because it is one less moving part while you are getting the network right, and move to the explicit exchange when you have more than one workflow sharing the project and you want them separated.
Sixth, teardown, which the managed route gives you for free and which is worth understanding anyway because it explains the failure everybody hits with the other route. A container started for one queued job runs that one job, deregisters itself from GitHub, and dies. The working directory goes. Untracked files go. The temporary directory goes. Any credentials that landed on disk go. Environment variables go. Nothing survives to the next run, because there is no next run on that machine — there is no machine.
If you do go the self-managed route, this is the flag you must not skip. You provision the instance or the pod, install the runner package, and register it by fetching a registration token from the repository or organisation and running the configuration script with the repository address, that token, your labels, and unattended mode. Add the ephemeral flag. Then the runner takes one job, deregisters, and exits, and your scaling layer replaces it. In the Kubernetes runner controller this is already the default. Targeting those runners from a workflow is ordinary label matching: a list containing self-hosted and whatever tags you gave it, or a runner group with labels inside it.
Skip the ephemeral flag and run one instance permanently under the service manager, and here is what you have actually built. The checkout step does try to clean up, but it cleans the git tree, not the machine. What stays behind is the working folder layout, cached container layers, containers that are still running or stopped and still holding ports, whatever a previous job installed into the home directory, background processes nobody killed, and — this is the one that should make you wince — temporary cloud credentials written into the credentials file in the home directory, and registry tokens in the container configuration file. So run two of a different workflow starts life holding run one's keys. On a machine that can reach your database.
That is not a hypothetical. It shows up as a git error saying the destination path already exists and is not empty. It shows up as a container failing to bind a port because that port is already allocated. And it shows up in the worst way as a step succeeding at something it had no permission to do, because it quietly inherited credentials from a job that ran an hour ago. If you ever have to check, the first step of the job is where you list the containers, list the workspace, and look for anything sitting in the temporary directory or the cloud credentials directory. But the real answer is to not build it that way: ephemeral, always.
Which brings me to the one failure you are most likely to meet this week, and it is the quietest one in the whole system.
The symptom: your workflow starts, the job appears, and it just sits there saying it is waiting for a runner to pick it up. No error. No red mark. No log to read, because no log exists — nothing has run. And it will sit there, apparently working, until the timeout fires, which by default is six hours away. This is the failure that eats an afternoon, because there is nothing to debug and nothing on fire, just a spinner.
The cause is almost always that nothing on your side ever heard the job existed, or heard it and did not think it was being addressed. Two checks, and together they take under a minute.
Check the label first, character by character. The project name misspelt, or the run identifier and run attempt left off the end, and GitHub is asking for a runner that will never appear. Then check the webhook: in the repository settings, under webhooks, look at recent deliveries for the queued-workflow-job event. If deliveries are failing with a four-hundred or five-hundred series response, the build service never got told. If there are no deliveries at all, the project's webhook trigger is not configured for that event.
For a self-managed pool the equivalent check is the runners page in the repository or organisation settings. Either no runner carrying all the labels the job asked for is listed, or one is listed and shows as offline. Offline usually means the runner cannot reach out — a subnet with no working route to the translation gateway, or a security group with no outbound allowance — and on the machine itself you will find the runner logs complaining that it failed to connect, or a connection timing out against GitHub's application programming interface.
Notice how that last one generalises, because it is the same shape of fault dressed differently. Egress you forgot shows up as timeouts and hosts that will not resolve — the token service unreachable, a registry pull hanging, a connection that never times out until it does. If the network could reach your database beautifully and the job seems to hang doing nothing, that is not the agent thinking hard. That is a private subnet with no path out. Route table, security group, then flow logs, where rejected traffic on the secure web port tells you exactly what you blocked.
Get all of that in place and what you have at the end of today is unglamorous and load-bearing: a private runner that works, sitting in your own network, next to your own data, with nothing running on it yet.
Fresh from the Claude Code changelog, and one of these lands directly on the ground we just built on.
The self-hosted runner command picked up new flags in the release that also brought the Concise output style. There is now a way to defer shutdown for a set number of minutes, and two options for supplying proxy authorisation — one that runs a command to fetch it, one that reads it from a file. If your runners sit behind an authenticating proxy, which is common the moment you put them in a locked-down private subnet, that is the difference between working and not. Next action: read the self-hosted runner section of the docs before you finish wiring your network, not after.
The same release fixed prompt caching for gateways using a custom base address. If you route Claude Code through an internal gateway and your costs looked stubbornly higher than they should, that was probably why — caching was not landing. Next action: upgrade, then watch the cost metric on the dashboards from the observability episode for a day and see whether the per-run figure drops.
That release also fixed memory leaks in long sessions by freeing subagent tool results once they scroll out of the display window. Unattended runs are exactly the long sessions this hits. Next action: nothing to configure, just take the version.
Bigger news for anyone budgeting a fleet: the introductory Sonnet 5 pricing of two dollars per million input tokens and ten per million output has been made permanent. The planned rise to three and fifteen on the first of September is cancelled. If you built a cost model with that increase penciled in, delete the line — your per-run figures hold.
Two changes to defaults worth knowing before they confuse you. The todo and task-tracking tools are now off by default on the current models; if a workflow of yours depended on the agent keeping a task list, there is an environment variable to switch them back on. And forked subagents are now the default, so background subagents inherit the full conversation cache, with an environment variable available to turn that off if it causes you trouble. Next action: if you see a behaviour change in a run you did not change, check those two before you blame your prompt.
A smaller convenience: there is now an environment variable that sets the default model for new sessions while letting a model override survive a restart instead of being clobbered. Useful for pinning a model across a fleet without fighting your own configuration.
Also from the same window: the bundled Claude application programming interface skill was trimmed hard, from over two hundred thousand tokens of context down to roughly twenty-five thousand, by loading its reference material only when needed, and it gained an upgrade command that migrates projects from the older Python client series to the current one. If that skill was crowding your context, it no longer is.
Rounding out the fortnight, a couple of releases carried general reliability and bug fixes with nothing you need to act on, there is a research preview of a design command for drafting user interface artboards in the command line and desktop clients, invisible text watermarking is now deployed across the platform to satisfy European transparency rules, and security scanning of connected repositories has entered public beta for enterprise accounts. One last practical note: the temporary fifty percent boost to weekly usage limits has expired, so if your fleet felt tighter this week, that is why and not your own doing.