Ephemeral GitHub Actions runner daemon. One binary, every platform. Secure by default.
ephemerd manages self-hosted GitHub Actions runners that are isolated, disposable, and automatic. Every job gets a fresh environment. When it's done, everything is destroyed. No leftover state, no security risk from untrusted PRs.
Designed by @luthermonson in Arizona 🌵 Assembled in Claude Opus 4.6.
Self-hosted GitHub Actions runners on bare metal are a security problem — any PR can run arbitrary code on your machine. The existing solutions don't cover cross-platform:
- ARC requires Kubernetes. Linux only. No Windows.
- Firecracker runners are Linux only.
- GitHub hosted runners are expensive, limited ARM64, and you don't control the environment.
ephemerd is a single binary that runs on Linux, Windows, and macOS. It embeds containerd as a Go library (the same approach k3s and rke2 use) and manages the full lifecycle: receive job → create isolated environment → run → destroy.
Containers run directly on the host via the embedded containerd. No VM needed — fastest path.
graph TB
GH[GitHub] -->|webhook / poll| E[ephemerd]
subgraph "Linux Host"
E -->|create container| CTD[containerd — embedded]
CTD -->|OCI container| R[Runner + Job]
R -->|job complete| E
E -->|destroy container| CTD
end
Windows jobs run in Hyper-V isolated containers (each gets its own kernel). Linux jobs are dispatched via gRPC to a Hyper-V Linux VM that ephemerd boots and manages itself — it embeds a Linux kernel, initrd, rootfs, and a cross-compiled Linux binary, creates the VM through the Host Compute Service (vmcompute.dll) API with a direct kernel boot, and runs containerd inside it. The Windows host runs a single scheduler that routes jobs by OS label.
Creating the VM through HCS rather than WSL2 means ephemerd works from any Windows security context, including LocalSystem — which is what the Windows service runs as, and which WSL2 does not support. (WSL is still used by ephemerd run for local workflow runs, but not by the daemon.) GitHub credentials stay on the Windows host — the VM only receives container lifecycle commands via gRPC dispatch.
graph TB
GH[GitHub] -->|webhook| E[ephemerd.exe]
subgraph "Windows Host"
E -->|Windows job| CTD[containerd native]
CTD -->|Hyper-V container| WR[Windows Runner]
E -->|Linux job via gRPC| LVM[Hyper-V Linux VM — HCS boot]
LVM -->|containerd in VM| LC[OCI Container]
LC --> LR[Linux Runner]
end
A long-running lightweight Linux VM (via Apple's Virtualization.framework) hosts containerd for Linux jobs — same OCI images, same Dockerfiles. macOS-native jobs (Xcode, Swift) get their own ephemeral macOS VM cloned from a base image via APFS copy-on-write (instant, no data copied until writes occur).
The Linux VM uses virtio-fs to share files between the host and VM. OCI artifact layers are extracted on the host and mounted into the VM, so macOS jobs can access pre-built artifacts without downloading them during the job.
graph TB
GH[GitHub] -->|webhook| E[ephemerd]
subgraph "macOS Host (Apple Silicon)"
E -->|Linux job| LVM[Linux VM — Virtualization.framework]
LVM -->|containerd in VM| LC[OCI Container]
LC --> LR[Linux ARM64 Runner]
E -->|macOS job| MVM[macOS VM — APFS clone-on-write]
MVM --> MR[macOS Runner + Xcode]
end
Linux OCI images work everywhere. The same Dockerfile builds an image that runs on Linux directly, inside a Hyper-V Linux VM on Windows, and inside a Virtualization.framework Linux VM on macOS. (Windows-native jobs run Windows containers and need their own Windows-base image.)
graph LR
D[Dockerfile] -->|docker build| I[OCI Image]
I --> L[Linux Host — containerd direct]
I --> W[Windows Host — containerd in Hyper-V VM]
I --> M[macOS Host — containerd in Virtualization.framework VM]
OCI images aren't just for containers. ephemerd also uses them as a delivery mechanism for pre-built artifacts on macOS VM jobs. You package your build outputs into a scratch OCI image, push it to a registry, and ephemerd unpacks the layers into the VM before your job runs. This lets you chain build pipelines — build once, use everywhere.
Example: packaging a darwin libphp.a
Your PHP SDK pipeline builds libphp.a for darwin/arm64. Package it into an OCI image:
FROM alpine AS fetch
ARG PHP_VERSION=8.5.2
RUN wget -O /tmp/sdk.tar.gz \
https://github.com/ephpm/php-sdk/releases/download/v${PHP_VERSION}/php-sdk-${PHP_VERSION}-macos-aarch64.tar.gz && \
mkdir -p /sdk && tar xzf /tmp/sdk.tar.gz -C /sdk
FROM scratch
COPY --from=fetch /sdk/ /php-sdk/# Build for a specific PHP version
docker buildx build --platform linux/arm64 \
--build-arg PHP_VERSION=8.5.2 \
-t ghcr.io/ephpm/php-sdk:8.5.2-macos \
-f Dockerfile.sdk --push .
# New PHP version? Just change the arg
docker buildx build --platform linux/arm64 \
--build-arg PHP_VERSION=8.4.7 \
-t ghcr.io/ephpm/php-sdk:8.4.7-macos \
-f Dockerfile.sdk --push .The final image is tiny — just the SDK files on a scratch base, no OS, no runtime. One Dockerfile handles every PHP version.
Example: using it in a macOS job
Your ePHPm release pipeline needs that libphp.a to build the final binary on macOS. Set container: on the job and ephemerd pulls the OCI image, unpacks its layers into the VM via virtio-fs, and your job finds the files ready to go:
jobs:
build-macos:
runs-on: [self-hosted, macos, arm64]
container:
image: ghcr.io/ephpm/php-sdk:8.5.2-macos
steps:
- uses: actions/checkout@v4
- name: Build ephpm
run: |
export PHP_SDK_PATH=/ephemerd-artifacts/php-sdk
cargo build --releaseephemerd handles the rest:
- Boots an ephemeral macOS VM (clone-on-write from base snapshot)
- Pulls
ghcr.io/ephpm/php-sdk:8.5.2-macosvia containerd - Unpacks the OCI layers into
/ephemerd-artifacts/inside the VM - Starts the GitHub runner — the job finds
libphp.awaiting at/ephemerd-artifacts/php-sdk/ - Job completes, VM is destroyed
graph LR
B1[PHP SDK Build] -->|push| R[ghcr.io/ephpm/php-sdk:8.5.2-macos]
R -->|ephemerd pulls + unpacks| VM[macOS VM]
VM -->|libphp.a ready| B2[ePHPm Release Build]
No downloading during the job. No recompiling PHP. The artifact is cached in the registry and unpacked in seconds. This same pattern works for any pre-built dependency — Rust toolchains, native libraries, test fixtures.
A single machine can serve multiple job types:
| Host | Linux jobs | Native OS jobs |
|---|---|---|
| Linux x86_64 | containerd direct | — |
| Linux arm64 | containerd direct | — |
| Windows x86_64 | Hyper-V Linux VM | Hyper-V Windows containers |
| macOS arm64 | Virtualization.framework Linux VM | macOS VM (clone-on-write) |
A Windows box and a Mac Mini covers every combination: linux/amd64, linux/arm64, windows/amd64.
Download the latest binary from Releases, then:
sudo ./ephemerd installThis copies the binary to /usr/local/bin/, creates a default config at /var/lib/ephemerd/config.toml, and installs a systemd service (Linux), launchd plist (macOS), or Windows service.
Or build from source with mage build.
sudo vim /var/lib/ephemerd/config.toml # set github.owner
sudo vim /etc/default/ephemerd # set GITHUB_TOKENsudo systemctl start ephemerd
sudo systemctl enable ephemerd # start on bootOr run manually:
export GITHUB_TOKEN="ghp_your_token_here"
sudo -E ephemerd servesudo ephemerd uninstallThis stops the service, removes the binary, service files, and data directory. Use --keep-data to preserve your config and logs.
runs-on: [self-hosted, linux, x64]Use the standard container: key in your workflow. ephemerd's containerd pulls the image and runs the job inside it:
jobs:
build-php:
runs-on: [self-hosted, linux, x64]
container:
image: ghcr.io/myorg/php-builder:latest
steps:
- uses: actions/checkout@v4
- run: make buildWindows jobs also run in OCI containers (Hyper-V isolated). The image is
resolved the same way for every OS — workflow container.image, then the
per-repo [runner.images] override, then the per-OS default, then a
host-matched mcr.microsoft.com/windows/servercore:ltsc20XX fallback
(pkg/scheduler/scheduler.go, pkg/runtime/image_windows.go).
Set the image through config, not through container:. Setting container:
on a Windows job also drags in the runner's own sibling-container handling,
which does not work on Windows — see
Known Limitations.
macOS jobs run in ephemeral VMs, not containers. GitHub-hosted macOS runners ignore the container: key, but on ephemerd it has a specific meaning: the OCI image whose layers are extracted onto the running VM via virtio-fs. Use it to deliver pre-built SDKs, toolchains, or release artifacts alongside the job:
jobs:
build-ios:
runs-on: [self-hosted, macos, arm64]
container:
image: ghcr.io/your-org/xcode-sdk:16
steps:
- uses: actions/checkout@v4
- run: xcodebuild -scheme MyAppephemerd reads the workflow YAML from the GitHub API when a job is queued and picks up container.image before creating the VM. The image layers are extracted into a host directory that is shared into the VM over virtio-fs — no container runs, just filesystem payload.
If container: is not set, the base macOS VM boots as-is — all the tools provisioned into the disk image are already there.
ephemerd supports multiple Git forges via a provider interface. Configure one provider per instance.
[github]
owner = "your-org"
# repos = ["repo1", "repo2"] # optional — omit for org-level runners
# Authentication: PAT via GITHUB_TOKEN env var, or GitHub App:
# app_id = 123456
# installation_id = 789012
# private_key_path = "/path/to/app.pem"[forgejo]
instance_url = "https://codeberg.org"
token = "runner-registration-token" # from Forgejo admin > Actions > Runners
owner = "your-org"
# repos = ["repo1", "repo2"] # optional — omit for all repos
# job_image = "gitea/runner-images:ubuntu-24.04" # default job execution image[gitea]
instance_url = "https://gitea.example.com"
token = "runner-registration-token" # from Gitea admin > Actions > Runners
owner = "your-org"
# repos = ["repo1", "repo2"] # optional — omit for all repos
# job_image = "gitea/runner-images:ubuntu-24.04" # default job execution image[gitlab]
instance_url = "https://gitlab.com"
token = "glrt-xxxxxxxxxxxx" # runner auth token (GitLab 16+)
tags = ["linux", "docker", "ephemerd"][woodpecker]
server_url = "woodpecker.example.com:9000" # Woodpecker server gRPC URL
agent_secret = "your-shared-secret" # agent authentication secretThe provider is auto-detected from which section has credentials set. Precedence: Forgejo > Gitea > GitLab > Woodpecker > GitHub (default). See docs/architecture/multi-forge-providers.md for the full architecture, and docs/guides/providers.md for per-provider configuration.
[github]
# Authentication: PAT or GITHUB_TOKEN env var
# token = "ghp_..." # or set GITHUB_TOKEN env var
owner = "your-org" # org or user
# repos = ["repo1", "repo2"] # optional — omit for org-level runners
[webhook]
# Default: none (polling). Set to "localtunnel" or "ngrok" for instant delivery.
# tunnel = "localtunnel" # zero-config tunnel (recommended)
# tunnel_url = "http://tunnels.example.com" # self-hosted localtunnel server
# tunnel = "ngrok" # use ngrok instead (requires auth token)
# ngrok_authtoken = "..." # or set NGROK_AUTHTOKEN env var
[runner]
max_concurrent = 4 # parallel jobs
extra_labels = [] # additional runner labels
job_timeout = "2h" # kill jobs after this
shutdown_timeout = "5m" # wait for running jobs on SIGTERM
# Cross-OS Linux VM (Windows and macOS hosts only)
[vm.linux]
enabled = true # boot a Linux VM for Linux jobs
cpus = 2
memory_mb = 2048
disk_size_gb = 50 # sparse — only uses space as needed
# macOS jobs (macOS hosts only) — always run in an isolated VM.
# No enable/disable toggle — macOS VMs always run on darwin hosts.
[vm.macos]
disk_image = "/path/to/macos.img" # base disk image (or auto-pulled from Tart OCI registry)
cpus = 4
memory_mb = 8192
max_concurrent = 2 # max simultaneous macOS VMs (default: auto-detected)
[network]
# subnet = "10.88.0.0/16" # container network subnet
# mtu = 1500
[dind]
# enabled = false # mount fake Docker socket into containers
[metrics]
# enabled = false # Prometheus metrics endpoint
# port = 9090
# path = "/metrics"
# Go module caching proxy — speeds up `go mod download` across jobs
[module_proxy]
enabled = true # run a GOPROXY on the bridge gateway
# port = 8082 # default listen port
# upstream = "https://proxy.golang.org" # upstream to fetch from on cache miss
# cleanup = true # wipe cache on shutdown; set false to keep it warm across restarts
# max_cache_gb = 20 # LRU eviction down to this ceiling
# prune_interval = "1h" # how often eviction runs (negative disables it)
# Cargo/crates caching proxy — pull-through cache for the crates.io sparse
# index, .crate tarballs, and rustup toolchains. No workflow changes needed.
[cargo_proxy]
enabled = true # run the Cargo proxy on the bridge gateway
# port = 8083 # default listen port
# upstream = "https://index.crates.io" # sparse registry index
# rustup_upstream = "https://static.rust-lang.org"
# index_ttl = "10m" # index revalidation (tarballs are immutable)
# cleanup = false # keep the cache across restarts
[log]
level = "info" # debug, info, warn, error
format = "text" # text or json
log_retention = "7d" # max age for job log filesBy default, ephemerd polls the GitHub API for queued jobs. No inbound ports, no tunnels, no TLS certificates — works behind NAT, on laptops, anywhere.
sequenceDiagram
participant E as ephemerd
participant GH as GitHub
loop Every 30 seconds
E->>GH: GET /repos/.../actions/runs?status=queued
GH-->>E: Queued jobs (if any)
end
Note over GH: Workflow job queued
E->>GH: Poll finds queued job
E->>E: Create container, run job
Jobs start within 30 seconds of being queued (default poll interval). Tune it in the config:
[github]
poll_interval = "10s" # faster polling, uses more API quotaA personal access token (PAT) gets 5,000 API requests per hour. At the default 30s poll interval, ephemerd uses ~120 requests/hour per repo — fine for many repos, but it adds up.
For higher limits, use a GitHub App instead of a PAT. GitHub Apps get 15,000 requests per hour per installation and don't count against your personal quota.
- Create a GitHub App with these permissions:
- Repository permissions: Actions (read), Administration (read/write)
- Organization permissions: Self-hosted runners (read/write)
- Subscribe to events: Workflow job
- Generate a private key and download the
.pemfile - Install the app on your org or repos
- Configure ephemerd:
[github]
app_id = 123456
installation_id = 789012
private_key_path = "/path/to/app.pem"
owner = "your-org"ephemerd automatically refreshes the installation token before it expires.
For instant job delivery with zero latency, ephemerd can create a tunnel and register webhooks automatically. On startup it generates a random HMAC secret, registers a workflow_job webhook on GitHub, and starts receiving events. On shutdown, the webhook is removed.
sequenceDiagram
participant E as ephemerd
participant T as Tunnel Server
participant GH as GitHub
E->>T: Connect tunnel
T-->>E: Public URL
E->>GH: POST /repos/.../hooks (register webhook)
GH-->>E: Hook created
Note over GH: Workflow job queued
GH->>T: POST /webhook/github (workflow_job event)
T->>E: Forward request
E->>E: Verify HMAC, create container, run job
Note over E: SIGTERM
E->>GH: DELETE /repos/.../hooks (deregister)
E->>T: Close tunnel
We recommend a self-hosted localtunnel server ($5/month Linode Nanode — see examples/localtunnel) over the free public server, which is unreliable.
[webhook]
tunnel = "localtunnel"
tunnel_url = "http://tunnels.example.com"Or use ngrok (requires free account):
[webhook]
tunnel = "ngrok"
ngrok_authtoken = "your-token"If your machine has a public IP and a TLS certificate (from Let's Encrypt, etc.), ephemerd can receive webhooks directly — no tunnel needed. You manage the webhook registration in GitHub yourself.
[webhook]
tunnel = "none"
tls_cert = "/etc/ephemerd/tls.crt"
tls_key = "/etc/ephemerd/tls.key"
secret = "your-webhook-secret"
port = 8080Then add a webhook in your GitHub repo or org settings:
- Go to Settings → Webhooks → Add webhook
- Payload URL:
https://your-host:8080/webhook/github— the path is per-provider (/webhook/<provider>), not a bare/webhook - Content type:
application/json - Secret: same value as
secretin your config - Events: select "Workflow jobs"
ephemerd verifies the HMAC-SHA256 signature on every delivery.
Every job runs in full isolation:
- Ephemeral environments — created per job, destroyed after. No state leaks between jobs.
- Hyper-V isolation on Windows — each container gets its own kernel. Real VM-level isolation.
- Network firewall — on Linux and macOS, containers are blocked from RFC 1918 and link-local ranges by default: jobs reach the internet but not your LAN. On Windows this requires
network.l2bridge_egress(see the Security guide); the default Windows NAT network does not filter egress. - Read-only runner mount — the GitHub Actions runner binary is bind-mounted read-only.
- No host access — no Docker socket, no host filesystem, no privileged mode.
ephemerd serve Start the daemon
ephemerd run Run a workflow locally without pushing to GitHub
ephemerd start Start the ephemerd system service
ephemerd stop Stop the ephemerd system service
ephemerd restart Restart the ephemerd system service
ephemerd logs Tail the ephemerd system service logs
ephemerd status Show running jobs, health, uptime
ephemerd drain Stop accepting new jobs, wait for running jobs
ephemerd uncordon Resume claiming jobs after a cordon or aborted drain
ephemerd jobs List and manage running jobs (kill, logs, ssh — macOS only)
ephemerd cache Inspect and clear on-disk caches (list, clear)
ephemerd config Validate configuration
ephemerd doctor Check system readiness and clean up stale state
ephemerd install Install binary and register as a system service
ephemerd upgrade Install a specific release and restart into it
ephemerd uninstall Remove binary, service, and data
ephemerd crictl Debug the embedded containerd (in-process crictl)
crictl gives you direct access to the embedded containerd — list containers, inspect images, check pods. Built into the ephemerd binary, nothing extra to install.
ephemerd uses standard OCI images. Build them with Docker:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y \
build-essential cmake autoconf automake \
git curl wget pkg-config
# Add your project-specific tools
# RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# COPY libphp.a /usr/local/lib/docker build -t ghcr.io/your-org/ephemerd-build:latest .
docker push ghcr.io/your-org/ephemerd-build:latestThe same Linux image runs on every host — Linux directly, Windows via Hyper-V Linux VM, macOS via Virtualization.framework Linux VM. Windows-native jobs need a separate Windows-base image; see docs/guides/runner-images.md.
Windows services: / container: YAML keys — not supported. A Windows-native job that sets either key fails. Where it fails depends on your image and on your ephemerd version; that it fails is structural. The reason is not the one this section used to give: GitHub's runner binary does not block container operations on Windows. It tries, and the attempt fails further down. What actually happens:
- ephemerd does honour
container.imageon Windows for picking the runner container's image — image resolution is OS-agnostic (pkg/scheduler/scheduler.goresolveImage,pkg/github/client.goFetchJobImage). That part works, and is the only part that does. - The runner binary then handles
container:/services:itself: it shells out to a Docker CLI and asks for a sibling container (docker pull,docker createwith a long-vlist,docker execper step). It does not refuse on Windows — an observed Windowscontainer:job got as far as looking fordocker.exe. - The auto-detected Windows default image (
mcr.microsoft.com/windows/servercore:ltsc20XX,pkg/runtime/image_windows.go) ships no Docker CLI, so on that image the failure is a missingdocker.exe.images/runner-ci-windows/Dockerfileinstalls one; a custom image would have to do the same. - Supplying a Docker CLI only moves the failure deeper. The fake daemon's container-create path is built for Linux and only Linux: it asks containerd for a
linux/<arch>platform spec, theoverlayfssnapshotter and theio.containerd.runc.v2runtime (pkg/dind/containers.gohandleContainerCreate). A Windows containerd offers none of those — its snapshotters arewindows/windows-lcow(pkg/dind/cleanup.go). The bind-mount translation behind it is likewise written for a Linux runner container on overlayfs with POSIX source paths (pkg/dind/bindtranslate.go). Depending on the version you run, this surfaces either as an explicit "not implemented" from the daemon or as a raw snapshotter/runtime error out of containerd. - Windows-native
container:is an explicit deferred follow-up in docs/arch/dind-bind-translation.md — "needs its own translation layer or a clean 'not supported' rejection at request time". Nothing in ephemerd's CI exercisescontainer:on any platform, so the failure modes above are read off the code rather than off a red test.
The same limit applies to the docker run workaround this section used to recommend: on a Windows-native job it runs into exactly the same wall. Container creation through the fake daemon is Linux-only. docker build and docker push are not — those route to the embedded BuildKit solver and are exercised on Windows by this repo's own build-images.yml.
Linux jobs are unaffected on every host, including Windows hosts: those run inside the Hyper-V Linux VM, where a separate ephemerd process runs as Linux against a Linux containerd. container: and docker run work there.
macOS builds require macOS — the darwin binary uses Virtualization.framework (CGO + Apple SDK). Cross-compilation from Linux isn't possible. Build on a Mac or use GitHub's macOS hosted runners for the darwin release.
Docker-in-Docker (fake daemon) — ephemerd mounts a fake Docker Engine API socket at /var/run/docker.sock inside each Linux job container (VM-isolated and Windows-native jobs get the same API over DOCKER_HOST=tcp://… instead, since a bind-mounted socket cannot cross a kernel boundary). For Linux jobs docker pull, docker run, docker build, and docker push all work; on Windows-native jobs docker run does not, per the entry above. The fake daemon translates Docker API calls into containerd operations on the host. No real Docker daemon runs, no privileged containers, no CAP_SYS_ADMIN. Sidecars created via docker run are sibling containers on the same network. Enable with dind.enabled = true in config. See docs/architecture/fake-docker-daemon.md for the full design.
ARM64 Windows — ephemerd supports it at the infrastructure level, but PHP and most build toolchains don't ship ARM64 Windows binaries yet.
See docs/architecture/overview.md for the full design document covering isolation backends, embedded containerd, VM lifecycle, and the GitHub integration model.
MIT