A pool of self-hosted GitHub Actions runners, each backed by a Dedalus Machine. The first job warms ~/.cargo, ~/.rustup, ~/.npm, ~/.cache/sccache. Every job after that wakes the same machine and reuses everything. You pay only for the seconds a runner is actually running a job.
1. Provision a runner pool
Run this once per runner you want in the pool. Each machine registers itself as an Actions runner on first boot.
import Dedalus from "dedalus-labs";
const dedalus = new Dedalus({ apiKey: process.env.DEDALUS_API_KEY! });
async function provisionRunner(label: string, registrationToken: string) {
const m = await dedalus.machines.create({ vcpu: 4, memory_mib: 8192, storage_gib: 40 });
await runAndWait(m.machine_id, ["/bin/bash", "-c", `
set -e
apt-get update && apt-get install -y curl git build-essential
useradd -m runner && cd /home/runner
curl -L https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64.tar.gz | tar xz
sudo -u runner ./config.sh --unattended --replace \
--url https://github.com/your-org \
--token ${registrationToken} \
--labels dedalus,${label}
cat >/etc/systemd/system/gh-runner.service <<EOF
[Service]
User=runner
WorkingDirectory=/home/runner
ExecStart=/home/runner/run.sh
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl enable --now gh-runner
`], 600_000);
return m.machine_id;
}
2. Wake on workflow_job webhook, sleep when idle
GitHub fires a workflow_job webhook with action queued when a job is dispatched. Wake the matching machine; let auto-sleep handle the rest.
import http from "node:http";
http.createServer(async (req, res) => {
const body = await readJson(req);
if (body.action === "queued" && body.workflow_job.labels.includes("dedalus")) {
const id = await pickFreeMachine();
await dedalus.machines.wake(id); // sub-second; runner auto-picks up the job
}
res.end();
}).listen(8080);
That’s the entire control loop. The runner’s gh-runner.service is on by default; once the VM wakes, systemd starts the runner process and it polls the GitHub queue. After the job finishes the runner goes idle. Five minutes later the platform auto-sleeps the VM (autosleep, default "5m").
Why this beats GitHub-hosted runners
- Hot toolchain caches.
~/.cargo (3 GB of crates), ~/.rustup, ~/.cache/sccache, ~/.npm, node_modules for monorepo workspaces — all stay on disk between jobs. GitHub-hosted runners cold-pull every job. A Rust job that takes 12 minutes cold takes 90 seconds warm.
- Pay only for awake-seconds. A pool of 10 runners that handle a 4-hour-per-day workload pays for ~40 vCPU-hours, not 240.
- 4 vCPU / 8 GiB / 40 GiB out of the box; provision bigger if you need it. Standard GitHub runners cap at 2 vCPU / 7 GiB.
- Real Linux, your kernel — install kernel modules, run Docker-in-VM with full nesting, run privileged containers, whatever your build needs.
Notes
- Pre-warming. If you want zero cold-start on the first job of the day, run a dummy
wake on cron a few minutes before peak. (See Cron on a Sleeping VM.)
- Pool size. Provision N machines; a queued job wakes the first idle one. Concurrency above N queues — your webhook handler should fall back to creating a new machine if the pool is saturated.
- Caches survive sleep and wake. They don’t survive
delete. If you provision a new machine you lose its cache; budget for that on first build.
- Auto-sleep timing. Default 300s of idle is fine for back-to-back PRs. Push it higher (
autosleep: "30m") for spiky CI patterns.
Last modified on May 12, 2026