This is a follow-up to a previous post on running your own self-hosted GitHub actions runners.

Towards the end of the previous post I covered a couple of security concerns. First, that a self-hosted runner shouldn’t be attached to a public repo that takes fork PRs. And second, that you should be extremely careful about the level of access you give and not mount the host’s Docker socket.

But that’s not a full security coverage. Even tying it to a private repo is not entirely safe. It isn’t. It closes a single vector. So we need to revisit that approach a bit. Think of this as a “revision”, and not a sequel.

Three Vectors, One Closed

There are three vectors of attack that can seriously be opened by a self-hosted runner. The first vector was covered, and mitigated, by the previous blog post: A fork PR. By tying the runner to a private repo, or at least to one that doesn’t accept fork PRs, we close off that vector of attack pretty well.

The second vector are dependencies. We are constantly hearing about compromised dependencies these days. Zero-day attacks via NPM or NuGet libraries are an almost daily news story. A compromised package in your tree executes on your machine the moment the CI runs. Being a private repo doesn’t help with that in the slightest, and even a simple solution like a Hugo block can easily be infiltrated by a Node dependency that some attacker has gained control of. And these days, even a simple solution has a host of Node packages tied to it.

The third vector we’ll look at are workflows or actions that you add to your pipeline. These are often third-party actions, that themselves have various dependencies. Any security break along the chain in the workflow and its tools and your runner is checking out and running untrusted code on purpose. A private repo doesn’t help you there either.

So we see that even with a private repository, the threat model isn’t so much someone specifically targeting you. Instead, it’s something in the supply chain gets infiltrated, and you’ve volunteered your desktop as a place it lands.

Persistence Is The Whole Problem

In part 1, the runner we created is deliberately long-lived. That was a key feature. We wanted it to be fast and re-usable. The .runner was built on a named volume so the registration survives. The volume is cached so builds are fast. But what that means is that each job is build on the jobs that ran before it. A modified binary on the PATH, a poisoned node_modules package in the npm cache, a git hook in the workspace, or a background process that outlives its job can all be vectors of attack and compromise.

The fix is ephemeral runners. An ephemeral runner is one that processes exactly one job, and then de-registers itself. This breaks the entrypoint we defined in part 1. In this setup, the runner is now stale after every job. It also means re-registering it on every container start.

The compromise I’d suggest is to keep the read-only-ish tool caches on the volumes, but make the workspace disposable. You accept that ephemeral costs you some of the speed you gained from part 1’s setup, but the benefit is added security.

Fixing the entrypoint

Here’s the entrypoint we ended up with in part 1:

if [[ ! -f .runner ]]; then
  ./config.sh --unattended --url ... --token ...
fi
exec ./run.sh

Register once, never again. That was the point. But add --ephemeral to that config.sh call and watch what happens. The runner takes a job, finishes it, and de-registers itself from GitHub. Meanwhile .runner is still sitting there on your named volume, because nothing deleted it. Next time the container starts, that if is false, so we skip registration entirely and go straight to run.sh.

And then nothing happens. The runner starts. It doesn’t crash. It doesn’t log an error you’d notice. It just sits there long-polling against a registration that GitHub deleted twenty minutes ago, and no job ever comes. I spent longer than I’d like to admit staring at a container that looked perfectly healthy.

An ephemeral runner has to register on every start, which means cleaning up the remains of the last registration first:

#!/usr/bin/env bash
set -euo pipefail

: "${GITHUB_URL:?GITHUB_URL is required}"
: "${RUNNER_TOKEN:?RUNNER_TOKEN is required}"

# --ephemeral de-registers after one job, so a .runner left over from the
# previous run is a lie. Clear it out or config.sh refuses to run.
if [[ -f .runner ]]; then
  ./config.sh remove --token "${RUNNER_TOKEN}" || true
  rm -f .runner .credentials .credentials_rsaparams
fi

./config.sh \
  --unattended \
  --ephemeral \
  --url "${GITHUB_URL}" \
  --token "${RUNNER_TOKEN}" \
  --name "${RUNNER_NAME:-docker-desktop-runner}" \
  --labels "${RUNNER_LABELS:-hugo,docker}" \
  --work "_work" \
  --replace

# Whatever the last job left behind, it doesn't get to meet the next one.
rm -rf _work

exec ./run.sh

There’s a catch, and it’s a real one. Registration tokens expire in about an hour. In part 1 that didn’t matter, because you supplied a token exactly once in your entire life and the .runner file carried the registration forward forever. Now you need a fresh token every single time the container starts, which is after every single job. The token you exported into your PowerShell session is not going to be there at 3 AM.

The JIT approach

The cleaner answer is a just-in-time runner. One API call to create a complete, single-use runner configuration. No config.sh, no token file on disk, nothing persistent that can go stale:

#!/usr/bin/env bash
set -euo pipefail

: "${GITHUB_REPO:?e.g. barretb/barretcodes}"
: "${RUNNER_PAT:?fine-grained PAT with Administration: read+write on this repo}"

while true; do
  # runner_group_id 1 is "Default", which is the only group a personal
  # account gets.
  JIT=$(curl -fsSL -X POST \
    -H "Authorization: Bearer ${RUNNER_PAT}" \
    -H "Accept: application/vnd.github+json" \
    "https://api.github.com/repos/${GITHUB_REPO}/actions/runners/generate-jitconfig" \
    -d "$(jq -nc \
          --arg name "barretcodes-jit-$(date +%s)" \
          '{name: $name, runner_group_id: 1, labels: ["barretcodes","hugo"]}')" \
    | jq -r .encoded_jit_config)

  rm -rf _work

  # Runs exactly one job and exits. The loop gets us the next one.
  ./run.sh --jitconfig "${JIT}"
done

That solves the staleness problem and gives every job a clean _work directory. But understand what you just traded. There is now a long-lived personal access token with admin rights on that repo sitting in your container’s environment. That is a strictly more powerful credential than the one-hour registration token it replaced. If someone pops the runner, they’ve got it.

If you go this route, use a fine-grained PAT scoped to that one repository, with Administration: read and write and nothing else, and give it an expiry date you’ll actually honor. Keep it out of compose and out of the repo:

    env_file:
      - runner.env          # RUNNER_PAT=github_pat_...  and it's gitignored

Keeping the caches warm anyway

Ephemeral doesn’t have to mean starting from absolute zero every time. The registration and the workspace are the parts that need to be disposable. The tool caches can stay:

    volumes:
      # Gone: barretcodes-runner-data. The runner directory is ephemeral now.
      - barretcodes-npm-cache:/home/runner/.npm
      - barretcodes-go-modcache:/home/runner/go/pkg/mod

Be carefule. This is genuinely less safe than a runner that starts completely clean. A compromised build can write into that npm cache, and the next build will happily read it back. It’s a choice of speed over security here. That’s a legitimate choice for a blog. It might not be a legitimate choice for anything that matters. Either way, it should be a decision you made on purpose rather than a default you inherited from part 1.

Fork PRs, And The One Keyword That Should Stop You

Part 1 told you not to attach a self-hosted runner to a public repo that takes fork PRs. That advice stands. What part 1 didn’t get into is why the obvious mitigations don’t rescue you.

GitHub gives you a setting under Settings => Actions => General => Fork pull request workflows to require approval for all external contributors. You should absolutely turn this setting on if you’re accepting PRs. But understand: it’s a speed bump, not a boundary. It works right up until the moment a maintainer looks at a PR, thinks “sure, that’s a reasonable typo fix,” and clicks approve. Reading a diff is not the same thing as auditing what that code does at build time. A malicious PR is specifically engineered to look reasonable.

GitHub’s own documentation says self-hosted runners should “almost never” be used with public repositories. That’s unusually direct language for a docs page, and they mean it.

If you’re determined to do it anyway, at minimum guard the job so fork PRs don’t reach your runner:

jobs:
  build:
    # Same-repo branches only. A fork PR has a different head repo
    # and gets skipped entirely.
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: [self-hosted, Linux, X64, barretcodes]

That’s still a mitigation rather than a fix. Think of it as something you add on the way to moving that job back onto a hosted runner where it belongs.

The loaded gun

Now the specific thing that turns a bad idea into a catastrophe. There is one trigger keyword that should make you stop typing and reconsider your life choices: pull_request_target.

A normal pull_request run from a fork gets no secrets and a read-only token. That’s deliberate, and it’s what makes fork PRs survivable. pull_request_target runs in the context of the base repository instead, which means it has your secrets and a write-capable token. People reach for it precisely because they want secrets available on a fork PR. And then they check out the PR’s code to test it:

# DO NOT DO THIS. ANYWHERE. BUT ESPECIALLY NOT ON A SELF-HOSTED RUNNER.
on:
  pull_request_target:          # runs with base-repo secrets and a write token

jobs:
  build:
    runs-on: [self-hosted, Linux, X64]
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # untrusted code, right here
      - run: npm ci && npm run build                       # executing with all of it

Three lines and a stranger’s postinstall script is running on the machine under your desk, with a token that can push commits to your repository. And one of the things you can push with a write token is a workflow file.

pull_request_target exists so a workflow can label or comment on a PR without running its code. The instant you combine it with a checkout of head.sha, you have written the exploit yourself. If both of those appear in the same file, stop.

The safe version of the same intent puts the untrusted code on a machine you don’t care about, with nothing valuable in reach:

on:
  pull_request:                 # no secrets, no write token

jobs:
  build:
    runs-on: ubuntu-latest      # explicitly not yours
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - run: npm ci && npm run build

Scope The Token, Not Just The Secrets

Most of the advice around Actions security is about secrets. Secrets matter, but the GITHUB_TOKEN is the credential people forget, and on a self-hosted runner it’s the one that turns a compromised build into a compromised repository.

Default everything to read

Set the floor at the top of the workflow and let individual jobs opt into more:

name: Build and deploy

on:
  push:
    branches: [main]

# Everything defaults to read. Jobs ask for more, one at a time.
permissions:
  contents: read

Then set the matching repository default so a workflow you add six months from now doesn’t start life with write access: Settings => Actions => General => Workflow permissions => Read repository contents and packages permissions.

The one that surprised me

actions/checkout writes the GITHUB_TOKEN into .git/config by default. That’s the persist-credentials option, and it defaults to true.

On a hosted runner, who cares. The VM evaporates ninety seconds after your job ends and takes the token with it. On your self-hosted runner, .git/config is on a named volume on your desktop, and that token sits on disk until something overwrites it. Every subsequent step in the job can read it. So can the next job, if your cleanup didn’t fire.

      - uses: actions/checkout@v4
        with:
          submodules: true
          fetch-depth: 0
          # checkout stores GITHUB_TOKEN in .git/config unless you say otherwise.
          persist-credentials: false

If some step genuinely needs to push back to the repo, set it to true on that one checkout and nowhere else.

Separate the job that runs strangers’ code from the job that holds the key

This is the structural change that does the most work for the least effort. In part 1 my build and deploy were a single job, which meant the Azure Static Web Apps deployment token was sitting in the environment while npm ci installed several hundred packages. Those two things do not belong together.

jobs:
  build:
    runs-on: [self-hosted, Linux, X64, barretcodes]
    timeout-minutes: 20
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true
          fetch-depth: 0
          persist-credentials: false

      # Everything untrusted happens in this job. Note what is NOT in scope
      # here: AZURE_STATIC_WEB_APPS_API_TOKEN.
      - run: npm ci
      - run: hugo --minify --cleanDestinationDir
      - run: npx pagefind --site public

      - uses: actions/upload-artifact@v4
        with:
          name: site
          path: public/
          retention-days: 1

  deploy:
    needs: build
    runs-on: [self-hosted, Linux, X64, barretcodes]
    timeout-minutes: 10
    # An environment gates the secret. Add required reviewers and even a
    # compromised build can't ship without you clicking a button.
    environment: production
    permissions:
      contents: read
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: site
          path: public

      - name: Deploy
        env:
          SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
        run: npx --yes @azure/static-web-apps-cli@2.0.10 deploy ./public --env production --no-use-keychain

It isn’t airtight. The deploy job still runs npx, which still resolves a package from the registry, so there’s still a window. But it’s a much smaller window than “the deployment token is present in the environment while my entire dependency tree installs itself.”

Clean The Workspace Like You Mean It

actions/checkout runs a git clean -ffdx by default, and a lot of people read that as “the workspace starts clean.” It doesn’t. It cleans the repository directory. It does nothing at all about _work/_actions, where downloaded actions live, or _work/_tool, or _work/_temp, or anything a job decided to write outside the repo folder.

The runner gives you two hooks for this, set in a .env file in the runner’s own directory:

# /actions-runner/.env
ACTIONS_RUNNER_HOOK_JOB_STARTED=/actions-runner/hooks/job-started.sh
ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/actions-runner/hooks/job-completed.sh
COPY hooks/ /actions-runner/hooks/
COPY runner.env /actions-runner/.env
RUN sed -i 's/\r$//' /actions-runner/hooks/*.sh \
    && chmod +x /actions-runner/hooks/*.sh

Note that sed. The same CRLF trap from part 1 applies to hook scripts, and the error message is no more helpful the second time around.

Here’s the cleanup:

#!/usr/bin/env bash
# hooks/job-completed.sh
# Runs after the last step, before the job reports back to GitHub.
set -uo pipefail          # deliberately no -e, see below

WORK="/actions-runner/_work"

# git clean handled the repo directory. These it never touches.
rm -rf "${WORK}/_temp" "${WORK}/_actions" 2>/dev/null || true

# Anything a job backgrounded and orphaned.
pkill -u "$(id -u)" -f 'node|hugo|go' 2>/dev/null || true

# A token that made it into .git/config anyway.
find "${WORK}" -name config -path '*/.git/*' \
  -exec sed -i '/http\..*\.extraheader/d' {} + 2>/dev/null || true

exit 0

Two things about that script that are easy to get wrong.

Why there’s no set -e and why it ends in exit 0. A non-zero exit from a hook fails the job. If you write a clever cleanup script and one of its commands returns 1 on an edge case, your builds start failing for reasons that appear nowhere in your workflow file. Cleanup is best effort. The build’s success shouldn’t depend on it.

This is housekeeping, not teardown. The completed hook fires before the job actually finishes, so you can’t use it to stop the runner or destroy the container. If you want genuine disposal, that’s the ephemeral section above. These hooks are the mitigation for people who decided the speed hit wasn’t worth it.

The Blast Radius Is Your Living Room

A lot of the advice you’ll find online isn’t useful here, because nearly all of it is written for organizations running runners in a datacenter. Your runner isn’t in a datacenter. It’s eighteen inches from you, on the same subnet as your NAS, your router’s admin page, your printer, your other dev machine, and whatever smart home junk you’ve accumulated.

Default Docker bridge networking means a compromised container can reach basically all of it.

services:
  barretcodes-runner:
    build:
      context: .
      dockerfile: dockerfile.hugo
      args:
        RUNNER_VERSION: "2.336.0"
    container_name: barretcodes-actions-runner
    restart: unless-stopped

    # Its own network. It has no business seeing your other containers.
    networks:
      - runner-net

    # No privilege escalation, and drop every capability. A Node, Hugo and Go
    # build needs none of them. Test against your own image before trusting it.
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL

    # A hard ceiling, so a runaway or malicious build can't take the whole
    # desktop down with it.
    mem_limit: 4g
    pids_limit: 512

    env_file:
      - runner.env
    environment:
      GITHUB_URL: "https://github.com/barretb/barretcodes"
      RUNNER_NAME: "barretcodes-docker-desktop"
      RUNNER_LABELS: "barretcodes,hugo"

    volumes:
      - barretcodes-npm-cache:/home/runner/.npm
      - barretcodes-go-modcache:/home/runner/go/pkg/mod
      # Still, emphatically, no /var/run/docker.sock.

networks:
  runner-net:
    driver: bridge

volumes:
  barretcodes-npm-cache:
  barretcodes-go-modcache:

You can’t just set the network to internal: true and call it a day, because the runner has to reach github.com to do its job at all. The realistic control is at your router or firewall, limiting which internal addresses that subnet can talk to. Docker Desktop on Windows makes this more annoying than it ought to be, and I haven’t fully solved it on my own setup. But knowing the exposure is there is still better than not knowing.

About that root user

Part 1 set RUNNER_ALLOW_RUNASROOT=1 and I justified it by saying that in a container whose entire job is being disposable, root is fine.

Re-reading that, the reasoning doesn’t hold. The part 1 container wasn’t disposable. It had persistent named volumes and a registration that survived image rebuilds. That’s about as far from disposable as a container gets. So let’s fix it.

# ... toolchain install as root, unchanged from part 1 ...

RUN useradd --create-home --shell /bin/bash runner \
    && mkdir -p /actions-runner \
    && chown -R runner:runner /actions-runner

# installdependencies.sh needs root, so run it BEFORE dropping privileges.
RUN cd /actions-runner && ./bin/installdependencies.sh

USER runner
WORKDIR /actions-runner

ENV PATH="/usr/local/go/bin:${PATH}"
ENV HOME=/home/runner
# RUNNER_ALLOW_RUNASROOT is gone. We aren't root anymore, so the runner
# stops asking.

ENTRYPOINT ["/entrypoint.sh"]

Order matters there. installdependencies.sh installs system packages and needs root, so it has to run before the USER directive.

One gotcha that will waste your afternoon: the cache paths move from /root/.npm to /home/runner/.npm. If you change the Dockerfile and forget the volume mounts in compose, nothing errors. The caches just quietly stop being used and your builds get slower for no visible reason. That’s why the compose file above already points at /home/runner.

Pin Everything, And Know What Pinning Buys You

Third-party actions should be pinned to a full commit SHA, not a tag:

      # A tag is a pointer the author can move whenever they like.
      # A commit SHA is not.
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
        with:
          persist-credentials: false

And then keep those pins current, or pinning just means running two-year-old code on purpose:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Pinning to a SHA defeats one specific attack: someone repointing a tag at malicious code under your feet. It does nothing if the version you pinned was already compromised when you pinned it, and it says nothing whatsoever about that action’s own dependencies. It raises the cost of one attack. That’s what most security controls do, and it’s worth doing anyway.

The good news is that if you’re already running npm ci against a committed lockfile, you’re doing the equivalent on the Node side. Which is exactly why vector two from the top of this post is a supply chain problem rather than a you problem.

What I Actually Changed

Enough theory. Here’s the real diff on my setup, including the parts I decided to skip.

Did it:

  • Split the build and deploy into separate jobs, with the SWA token only in scope for deploy. Easiest win on the list, took about ten minutes.
  • persist-credentials: false on every checkout. Free.
  • permissions: contents: read at the top of every workflow, plus the repo-level default.
  • Pinned third-party actions to SHAs and turned on Dependabot for github-actions.
  • Dropped root. The non-root Dockerfile above is what I’m running, and yes, I hit the cache path problem and spent twenty minutes wondering why builds got slower.
  • Put the runner on its own Docker network.
  • Job-completed cleanup hook.

Didn’t do:

  • Full ephemeral, for now. The cold workspace costs me real time on every build, and for a blog that publishes a few times a month I decided the job hooks plus a non-root, network-isolated container was a reasonable place to stop. That’s a judgment call and a reader with anything more valuable on their network should make a different one.
  • Read-only root filesystem. Hugo and the Node toolchain write to enough places that I got bored fighting it. Maybe another day.
  • Proper firewall segmentation. Still on the list. Docker Desktop on Windows plus my router’s fairly limited rule support makes this more work than it should be, and I’d rather admit that than pretend otherwise.

Still no docker.sock, which remains the single most important line in both of these posts.

The Checklist

Door inWhat it gets themWhat actually closes itWhat it costs
Fork PR on a public repoCode execution as you, plus repo writeDon’t attach a self-hosted runner to itHosted minutes for that repo
pull_request_target plus a checkout of headSame, plus every secret in the repoNever put those two in the same fileNothing. You didn’t need it
Malicious dependency in npm ciCode execution inside the runnerEphemeral runner, split build and deployA cold workspace each run
Leftovers between jobsPersistence, cache poisoning--ephemeral, or a job-completed hookSpeed, or complexity
Token left in .git/configRepo write from any later steppersist-credentials: falseNothing
Deploy secret present during buildShip malicious outputSeparate deploy job behind an environmentOne extra job
Container reaching your LANEverything else you ownDedicated network plus firewall rulesAn afternoon
docker.sock mountedRoot on the hostDon’t mount itNo container actions

Wrapping Up

The thing I’d want you to take away isn’t any individual setting in this post. It’s the reframe.

In part 1, I said “private repos only,” and that’s still true, but it quietly implies that a private repo is the boundary. It never was. A private repo closes one of three doors, and the other two, a compromised dependency and a compromised action, don’t care in the slightest how your repository visibility is configured.

The actual boundary is what your build can reach on the worst day. That’s a thing you get to decide today, cheaply, while nothing is on fire. Or you can find out what it is later, when something is.