Skip to content

Instantly share code, notes, and snippets.

@elithrar
Last active August 15, 2026 14:18
Show Gist options
  • Select an option

  • Save elithrar/082344e39d8a10c5d48b93075554ce89 to your computer and use it in GitHub Desktop.

Select an option

Save elithrar/082344e39d8a10c5d48b93075554ce89 to your computer and use it in GitHub Desktop.
Minimal ephemeral GitHub Actions runner on Cloudflare Sandboxes

Cloudflare Sandbox GitHub Actions runner

This is a minimal, manually triggered repository runner. Each POST /runners request starts a new Cloudflare Sandbox, obtains a short-lived GitHub runner registration token, and registers an ephemeral runner that accepts one job. The sandbox stays alive while the runner waits or works, then the image calls back to the Worker to destroy it when the runner exits. Read GitHub's self-hosted runner overview before using it.

Use this as a proof of concept. For production autoscaling, consume workflow_job events or use GitHub's Runner Scale Set Client, add durable deduplication and retries, forward runner logs to external storage, and use a GitHub App instead of a personal access token.

Setup

  1. Confirm you have a Cloudflare Workers Paid plan, Node.js, Docker, and admin access to the target GitHub repository. See Cloudflare's Sandbox setup guide.

  2. Download the Gist files and change GITHUB_REPOSITORY in wrangler.jsonc.

  3. Install dependencies with npm install.

  4. Create a fine-grained GitHub token scoped to the target repository with Administration: write permission. GitHub documents the token permission and token setup.

  5. Store the GitHub token and a random controller token of at least 32 characters:

    export CONTROL_TOKEN="$(openssl rand -hex 32)"
    npx wrangler secret put GITHUB_TOKEN
    printf '%s' "$CONTROL_TOKEN" | npx wrangler secret put CONTROL_TOKEN
  6. Deploy with npm run deploy.

  7. Target the runner with a dedicated label:

    jobs:
      test:
        runs-on: cloudflare-sandbox
        steps:
          - uses: actions/checkout@v4
          - run: npm test

    Queue the job, then start one runner:

    curl --fail-with-body \
      --request POST \
      --header "Authorization: Bearer $CONTROL_TOKEN" \
      https://YOUR_WORKER.workers.dev/runners

The runner omits GitHub's default self-hosted, OS, and architecture labels so it cannot accidentally claim generic self-hosted jobs. The image starts a rootless Docker daemon before registering the runner, so it supports Docker container actions, job containers, service containers, and ordinary docker builds. Pass --network=host to workflow docker build and docker run commands that need network access, as required by Cloudflare's Docker-in-Docker guide. This satisfies GitHub's Linux-and-Docker requirement for containerized workflows.

Cloudflare Containers cannot manipulate iptables, so runner-managed containers use host networking. Access service containers through localhost and their container port instead of the service label. Do not declare service ports: because published-port mappings require iptables. Inner containers share the sandbox network stack, cannot run privileged, and lose images and volumes when the sandbox is destroyed. Docker isolation here is not an additional security boundary beyond the sandbox.

GitHub recommends external log retention for ephemeral runners. The image sends runner diagnostics to stdout, but you must configure durable log export before using this in production. The image retries its authenticated cleanup callback, but add independent reconciliation before production so a prolonged network failure cannot leave a keep-alive sandbox running. Keep the pinned Sandbox SDK, Sandbox image tag and digest, Wrangler, and GitHub runner versions current.

#!/bin/bash
set -euo pipefail
daemon_pid=
# shellcheck disable=SC2329 # Invoked through the signal and EXIT traps.
stop_docker() {
if [[ -n "$daemon_pid" ]]; then
kill -TERM "$daemon_pid" 2>/dev/null || true
wait "$daemon_pid" 2>/dev/null || true
fi
}
trap stop_docker EXIT INT TERM
runuser --user runner -- \
env \
DOCKER_HOST="$DOCKER_HOST" \
HOME=/home/runner \
LOGNAME=runner \
USER=runner \
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \
/usr/local/bin/dockerd-rootless.sh \
--host="$DOCKER_HOST" \
--iptables=false \
--ip6tables=false \
--storage-driver=fuse-overlayfs &
daemon_pid=$!
for _ in {1..150}; do
if runuser --user runner -- \
env DOCKER_HOST="$DOCKER_HOST" /usr/local/bin/docker-real version \
>/dev/null 2>&1; then
echo "Rootless Docker is ready"
wait "$daemon_pid"
exit $?
fi
if ! kill -0 "$daemon_pid" 2>/dev/null; then
wait "$daemon_pid"
exit $?
fi
sleep 0.2
done
echo "Rootless Docker did not become ready within 30 seconds" >&2
exit 1
#!/bin/bash
set -euo pipefail
real_docker=${DOCKER_REAL:-/usr/local/bin/docker-real}
uses_runner_network=false
expect_network=false
for argument in "$@"; do
if $expect_network; then
[[ "$argument" == github_network_* ]] && uses_runner_network=true
expect_network=false
continue
fi
case "$argument" in
--network)
expect_network=true
;;
--network=*)
[[ "$argument" == --network=github_network_* ]] && uses_runner_network=true
;;
esac
done
if ! $uses_runner_network || [[ "${1:-}" != create && "${1:-}" != run ]]; then
exec "$real_docker" "$@"
fi
# Cloudflare disables iptables in nested Docker. Use the supported host network
# for runner-managed containers so pulls and job traffic can reach the network.
arguments=()
skip_next=false
for argument in "$@"; do
if $skip_next; then
skip_next=false
continue
fi
case "$argument" in
--network)
arguments+=(--network host)
skip_next=true
;;
--network=github_network_*)
arguments+=(--network=host)
;;
--network-alias)
skip_next=true
;;
--network-alias=*)
;;
*)
arguments+=("$argument")
;;
esac
done
exec "$real_docker" "${arguments[@]}"
# syntax=docker/dockerfile:1
ARG SANDBOX_VERSION=0.12.5
ARG SANDBOX_DIGEST=sha256:315b14485d6982774521f3e2f605fdf39ba6de6942d65a022048a54967ca0062
FROM docker.io/cloudflare/sandbox:${SANDBOX_VERSION}@${SANDBOX_DIGEST}
ARG RUNNER_VERSION=2.336.0
ARG RUNNER_SHA256=04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d
ARG DOCKER_VERSION=29.7.2
ARG DOCKER_SHA256=803d433f226db4776e1768fd319fc6c6e4935a456acf84fcc0080818b854bc8f
ARG DOCKER_ROOTLESS_SHA256=15a5cb81f2c5cf15ea21427f2e8241eac0deb2221175f993b5e76926e705ec6a
ARG BUILDX_VERSION=0.36.1
ARG BUILDX_SHA256=48af8a397ebd60178778bf63611dbcebe5f5e7a9be90eb9147b24b9587455778
USER root
RUN set -eux; \
apt-get update; \
apt-get install --yes --no-install-recommends \
dbus-user-session \
fuse-overlayfs \
iproute2 \
iptables \
slirp4netns \
uidmap; \
useradd --create-home --uid 1001 --user-group --shell /bin/bash runner; \
echo 'runner:100000:65536' >> /etc/subuid; \
echo 'runner:100000:65536' >> /etc/subgid; \
mkdir -p /opt/actions-runner /run/user/1001 /workspace/_work; \
chmod 0700 /run/user/1001; \
curl --fail --location --show-error \
--proto '=https' --proto-redir '=https' \
--output /tmp/docker.tgz \
"https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz"; \
curl --fail --location --show-error \
--proto '=https' --proto-redir '=https' \
--output /tmp/docker-rootless.tgz \
"https://download.docker.com/linux/static/stable/x86_64/docker-rootless-extras-${DOCKER_VERSION}.tgz"; \
echo "${DOCKER_SHA256} /tmp/docker.tgz" | sha256sum --check --strict; \
echo "${DOCKER_ROOTLESS_SHA256} /tmp/docker-rootless.tgz" | sha256sum --check --strict; \
tar --extract --gzip --file /tmp/docker.tgz --strip-components 1 --directory /usr/local/bin; \
tar --extract --gzip --file /tmp/docker-rootless.tgz --strip-components 1 --directory /usr/local/bin; \
mv /usr/local/bin/docker /usr/local/bin/docker-real; \
mkdir -p /usr/local/libexec/docker/cli-plugins; \
curl --fail --location --show-error \
--proto '=https' --proto-redir '=https' \
--output /usr/local/libexec/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-amd64"; \
echo "${BUILDX_SHA256} /usr/local/libexec/docker/cli-plugins/docker-buildx" | sha256sum --check --strict; \
chmod 0755 /usr/local/libexec/docker/cli-plugins/docker-buildx; \
/usr/local/bin/docker-real buildx version; \
curl --fail --location --show-error \
--proto '=https' --proto-redir '=https' \
--output /tmp/actions-runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"; \
echo "${RUNNER_SHA256} /tmp/actions-runner.tar.gz" | sha256sum --check --strict; \
tar --extract --gzip --file /tmp/actions-runner.tar.gz --directory /opt/actions-runner; \
/opt/actions-runner/bin/installdependencies.sh; \
rm -f /tmp/actions-runner.tar.gz /tmp/docker.tgz /tmp/docker-rootless.tgz; \
rm -rf /var/lib/apt/lists/*; \
chown -R runner:runner /home/runner /opt/actions-runner /run/user/1001 /workspace/_work
COPY --chmod=0755 boot-docker.sh /usr/local/bin/boot-docker
COPY --chmod=0755 docker-shim.sh /usr/local/bin/docker
COPY --chmod=0755 run-actions-runner.sh /usr/local/bin/run-actions-runner
ENV DOCKER_HOST=unix:///run/user/1001/docker.sock \
XDG_RUNTIME_DIR=/run/user/1001
# Keep the Sandbox ENTRYPOINT; it starts this command as a managed child.
CMD ["/usr/local/bin/boot-docker"]
{
"name": "cloudflare-sandbox-github-runner",
"private": true,
"type": "module",
"scripts": {
"check": "wrangler deploy --dry-run --containers-rollout=none",
"deploy": "wrangler deploy",
"dev": "wrangler dev"
},
"dependencies": {
"@cloudflare/sandbox": "0.12.5"
},
"devDependencies": {
"wrangler": "4.121.0"
}
}
#!/bin/bash
set -euo pipefail
cleanup_url=$RUNNER_CLEANUP_URL
cleanup_token=$RUNNER_CLEANUP_TOKEN
unset RUNNER_CLEANUP_TOKEN RUNNER_CLEANUP_URL
cleanup() {
status=$?
trap - EXIT
curl --fail --silent --show-error \
--connect-timeout 10 \
--max-time 60 \
--retry 5 \
--retry-all-errors \
--request DELETE \
--header "Authorization: Bearer $cleanup_token" \
"$cleanup_url" || true
exit "$status"
}
trap cleanup EXIT
for _ in {1..150}; do
if runuser --user runner --preserve-environment -- \
/usr/local/bin/docker version >/dev/null 2>&1; then
break
fi
sleep 0.2
done
if ! runuser --user runner --preserve-environment -- \
/usr/local/bin/docker version >/dev/null 2>&1; then
echo "Rootless Docker is unavailable" >&2
exit 1
fi
# shellcheck disable=SC2016 # Expand runner variables after dropping privileges.
runuser --user runner --preserve-environment -- /bin/bash -c '
set -euo pipefail
cd /opt/actions-runner
./config.sh --unattended --ephemeral --disableupdate --no-default-labels \
--url "$RUNNER_URL" --token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" --labels "$RUNNER_LABELS" \
--work /workspace/_work
unset RUNNER_TOKEN
exec ./run.sh
'
import { getSandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
const RUNNER_READY = /Listening for Jobs/i;
const RUNNER_ID_PATTERN = /^runner-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REPOSITORY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9_.-]{1,100}$/;
const textEncoder = new TextEncoder();
async function secureEqual(provided, expected) {
const [providedHash, expectedHash] = await Promise.all([
crypto.subtle.digest("SHA-256", textEncoder.encode(provided)),
crypto.subtle.digest("SHA-256", textEncoder.encode(expected)),
]);
return crypto.subtle.timingSafeEqual(providedHash, expectedHash);
}
async function authenticate(request, expectedToken) {
if (typeof expectedToken !== "string" || expectedToken.length < 32) {
return false;
}
const authorization = request.headers.get("Authorization") ?? "";
const providedToken = authorization.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: "";
return secureEqual(providedToken, expectedToken);
}
async function createCleanupToken(sandboxId, controlToken) {
const key = await crypto.subtle.importKey(
"raw",
textEncoder.encode(controlToken),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
textEncoder.encode(sandboxId),
);
return Array.from(new Uint8Array(signature), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
async function createRegistrationToken(repository, githubToken) {
const response = await fetch(
`https://api.github.com/repos/${repository}/actions/runners/registration-token`,
{
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"User-Agent": "cloudflare-sandbox-actions-runner",
"X-GitHub-Api-Version": "2026-03-10",
},
},
);
if (!response.ok) {
await response.body?.cancel();
throw new Error(`GitHub registration-token request failed: ${response.status}`);
}
const body = await response.json();
if (
typeof body !== "object" ||
body === null ||
typeof body.token !== "string" ||
typeof body.expires_at !== "string"
) {
throw new Error("GitHub returned an invalid registration-token response");
}
return body;
}
async function startRunner(env, origin) {
if (!REPOSITORY_PATTERN.test(env.GITHUB_REPOSITORY)) {
throw new Error("GITHUB_REPOSITORY must use the OWNER/REPO format");
}
if (typeof env.CONTROL_TOKEN !== "string" || env.CONTROL_TOKEN.length < 32) {
throw new Error("CONTROL_TOKEN must be at least 32 characters");
}
const registration = await createRegistrationToken(
env.GITHUB_REPOSITORY,
env.GITHUB_TOKEN,
);
const id = crypto.randomUUID();
const runnerName = `cloudflare-${id}`;
const sandboxId = `runner-${id}`;
const sandbox = getSandbox(env.Sandbox, sandboxId, {
enableDefaultSession: false,
keepAlive: true,
normalizeId: true,
transport: "rpc",
labels: {
repository: env.GITHUB_REPOSITORY,
workload: "github-actions-runner",
},
});
try {
const process = await sandbox.startProcess(
"/usr/local/bin/run-actions-runner",
{
processId: "actions-runner",
autoCleanup: false,
env: {
ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT: "1",
DOCKER_HOST: "unix:///run/user/1001/docker.sock",
HOME: "/home/runner",
LOGNAME: "runner",
RUNNER_CLEANUP_TOKEN: await createCleanupToken(
sandboxId,
env.CONTROL_TOKEN,
),
RUNNER_CLEANUP_URL: `${origin}/runners/${sandboxId}`,
RUNNER_LABELS: env.RUNNER_LABELS,
RUNNER_NAME: runnerName,
RUNNER_TOKEN: registration.token,
RUNNER_URL: `https://github.com/${env.GITHUB_REPOSITORY}`,
USER: "runner",
XDG_RUNTIME_DIR: "/run/user/1001",
},
},
);
await process.waitForLog(RUNNER_READY, 120_000);
console.log(
JSON.stringify({
message: "ephemeral runner ready",
repository: env.GITHUB_REPOSITORY,
runnerName,
sandboxId,
}),
);
return {
expiresAt: registration.expires_at,
runnerName,
sandboxId,
};
} catch (error) {
try {
await sandbox.destroy();
} catch (cleanupError) {
console.error(
JSON.stringify({
message: "failed to destroy sandbox after runner startup failure",
error:
cleanupError instanceof Error
? cleanupError.message
: String(cleanupError),
sandboxId,
}),
);
}
throw error;
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const cleanupSandboxId = url.pathname.startsWith("/runners/")
? url.pathname.slice("/runners/".length)
: "";
if (request.method === "DELETE" && RUNNER_ID_PATTERN.test(cleanupSandboxId)) {
if (
typeof env.CONTROL_TOKEN !== "string" ||
env.CONTROL_TOKEN.length < 32 ||
!(await authenticate(
request,
await createCleanupToken(cleanupSandboxId, env.CONTROL_TOKEN),
))
) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const sandbox = getSandbox(env.Sandbox, cleanupSandboxId, {
enableDefaultSession: false,
normalizeId: true,
transport: "rpc",
});
await sandbox.destroy();
console.log(
JSON.stringify({
message: "destroyed completed ephemeral runner",
sandboxId: cleanupSandboxId,
}),
);
return new Response(null, { status: 204 });
}
if (url.pathname !== "/runners") {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (request.method !== "POST") {
return Response.json(
{ error: "Method not allowed" },
{ status: 405, headers: { Allow: "POST" } },
);
}
if (!(await authenticate(request, env.CONTROL_TOKEN))) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const runner = await startRunner(env, url.origin);
return Response.json(runner, { status: 202 });
} catch (error) {
console.error(
JSON.stringify({
message: "failed to start ephemeral runner",
error: error instanceof Error ? error.message : String(error),
repository: env.GITHUB_REPOSITORY,
}),
);
return Response.json({ error: "Failed to start runner" }, { status: 502 });
}
},
};
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "github-actions-sandbox-runner",
"main": "worker.js",
"compatibility_date": "2026-08-12",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"GITHUB_REPOSITORY": "OWNER/REPO",
"RUNNER_LABELS": "cloudflare-sandbox"
},
"secrets": {
"required": ["CONTROL_TOKEN", "GITHUB_TOKEN"]
},
"containers": [
{
"class_name": "Sandbox",
"image": "./Dockerfile",
"instance_type": "standard-1",
"max_instances": 5
}
],
"durable_objects": {
"bindings": [
{
"name": "Sandbox",
"class_name": "Sandbox"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Sandbox"]
}
],
"observability": {
"enabled": true,
"logs": {
"enabled": true,
"head_sampling_rate": 1
},
"traces": {
"enabled": true,
"head_sampling_rate": 0.01
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment