Converting an Existing Workflow
Most of a working workflow needs no changes. What breaks depends entirely on the workload’s
runtime tier: on dind or isolated-sysbox a job’s containers
are children of the runner’s own daemon and almost everything behaves as it did on ubuntu-latest.
On shared-daemon the daemon belongs to the host, so containers the job starts are its siblings,
and that single fact is behind every item below. isolated-kata runs no daemon at all, so a job
that needs Docker doesn’t belong there.
Start here, whatever the tier
Section titled “Start here, whatever the tier”- Point
runs-onat the workload’s labels.self-hostedis implied by every Runaway runner; add it alongside the workload’s own label. See routing. - Put toolchain actions first.
actions/setup-node,setup-pythonand friends should run before any step that shells out tonode,npmorpython. The Standard runner image’s system interpreters are older than a hosted runner’s, and an installer that runs beforesetup-noderuns on whatever the image ships. See the Standard image. - Drop the GitHub cache actions.
actions/cacheandcache:inputs on setup actions round-trip through GitHub’s Cache service;/cacheis already warm and local. See Caching.
That’s the whole list for none, dind and isolated-sysbox — and for isolated-kata, provided the job needs no Docker.
Then, for shared-daemon only
Section titled “Then, for shared-daemon only”-
Replace
services:. The runner publishes a service container’s ports on the host and then looks for them on its ownlocalhost, where nothing is listening. Start the container yourself on a per-run network and join the runner to it:- name: Start Postgresrun: |docker network create "ci-$GITHUB_RUN_ID"docker run -d --name "postgres-$GITHUB_RUN_ID" \--network "ci-$GITHUB_RUN_ID" --label "ci-run=$GITHUB_RUN_ID" \-e POSTGRES_PASSWORD=ci postgres:17-alpinedocker network connect "ci-$GITHUB_RUN_ID" "$(cat /etc/hostname)"A
container:job has no conversion. The runner injects its own Node into the job container from a path that exists only inside the runner image, and the host’s daemon can’t see it, so JavaScript actions fail before your steps run. Move those jobs to a nested tier. -
Replace every
localhostwith a container name. Anything the job reaches over the network — a database URL, a health check, a smoke test — addresses the container by name on that network. Published ports are not how the job gets in; network membership is. -
Scope container names and image tags to the run. A fixed
--nameor-tis global on a shared daemon, so two concurrent jobs collide. Use$GITHUB_RUN_IDin both. -
Add a teardown step with
if: always(). The runner is ephemeral; the daemon is not. Anything the job started outlives it unless the job removes it. See hygiene. -
Check what the job bind-mounts. Paths outside
$GITHUB_WORKSPACEdon’t exist on the host and mount empty. See bind mounts. -
Give
docker builda route to any service it needs. BuildKit won’t join a user-defined network. See BuildKit networking.
What keeps working unchanged
Section titled “What keeps working unchanged”Worth knowing so you don’t rewrite more than you need to:
- Every action from the marketplace, including
actions/checkoutandupload-artifact. - Matrix builds,
concurrency,if:conditions, reusable workflows, and job outputs. - Anything that runs directly on the runner — compilers, test suites, linters, and a server the job
starts and then talks to on its own
localhost. - Secrets,
GITHUB_TOKEN, and$GITHUB_ENV/$GITHUB_OUTPUT.
One-way doors
Section titled “One-way doors”Converting a workflow for shared-daemon makes it specific to a runner with a host socket. It
will no longer run on ubuntu-latest, because docker network connect … $(cat /etc/hostname) has
no meaning there. If you need a workflow that runs in both places, use a nested tier instead and keep
services:.