An ephemeral, self-hosted GitHub Actions runner built on Modal. Each job runs in a fresh sandbox, you pay only while jobs execute, and credentials are scoped to a single use.
# 1. Create a Modal secret with your GitHub PAT and webhook secret
modal secret create github-secret \
GITHUB_TOKEN=ghp_xxx \
WEBHOOK_SECRET=$(openssl rand -hex 32)
# 2. Deploy the runner
modal deploy app.py
# 3. Add a webhook in your GitHub repo settings
# Point it at the URL modal deploy prints, content type JSON,
# secret = the WEBHOOK_SECRET from step 1, events = "Workflow jobs"Then update your workflow file:
runs-on: [self-hosted, modal]Full deployment walkthrough: DEPLOY.md.
| Feature | Description |
|---|---|
| Ephemeral | Fresh Modal Sandbox per job. No state leaks between runs. |
| Zero idle cost | No long-running servers. You pay for the seconds a job runs, nothing else. |
| JIT security | Single-use runner tokens via GitHub's generate-jitconfig API. |
| GPU support | T4, L4, A100, A100-80GB, H100 through workflow labels. |
| Network isolation | Outbound CIDR allowlists or full network blocking. |
| Cache volumes | Persistent /cache mount across jobs via Modal Volumes. |
| Auto-retry | Exponential backoff (1s, 2s, 4s) on transient GitHub API failures. |
| Structured logging | JSON logs with job ID, repo, duration, and error context. |
| Modal Runner | ARC (K8s) | GitHub-hosted | |
|---|---|---|---|
| Infrastructure | None (serverless) | Kubernetes cluster | None (managed) |
| Idle cost | Zero | Node compute when idle | N/A (billed per minute) |
| Startup time | Sub-second sandbox | Pod scheduling (seconds to minutes) | ~10-20 seconds |
| GPU support | T4, L4, A100, A100-80GB, H100 | Requires GPU nodes | Limited, macOS only |
| Horizontal scaling | Automatic, no config | Requires HPA/cluster autoscaler | Automatic |
| Isolation | MicroVM sandbox | Container (shared kernel) | VM |
| Kubernetes required | No | Yes | No |
ARC is the better choice if you already run a Kubernetes cluster and need deep integration with your existing infrastructure. Modal Runner is simpler if you want something that deploys in under a minute with no ops overhead.
Add a gpu: label to your workflow's runs-on to request a specific GPU:
jobs:
train:
runs-on: [self-hosted, modal, gpu:a100]
steps:
- run: python train.pySupported labels:
| Label | Hardware |
|---|---|
gpu:t4 |
NVIDIA T4 (16 GB) |
gpu:l4 |
NVIDIA L4 (24 GB) |
gpu:a100 |
NVIDIA A100 (40 GB) |
gpu:a100-80gb |
NVIDIA A100 (80 GB) |
gpu:h100 |
NVIDIA H100 (80 GB) |
Control outbound network access from runner sandboxes with environment variables:
modal secret create github-secret \
GITHUB_TOKEN=ghp_xxx \
WEBHOOK_SECRET=xxx \
BLOCK_NETWORK=true # block all outbound
# or
ALLOWED_CIDRS="10.0.0.0/8,192.168.0.0/16" # allow specific rangesBLOCK_NETWORK=truedrops all outbound connections from the sandbox.ALLOWED_CIDRStakes a comma-separated list of CIDR ranges. When set, only those ranges are reachable.
Additional security controls:
ALLOWED_REPOSrestricts which repositories can trigger runner creation.- HMAC-SHA256 signature verification on every webhook request.
- Delivery ID deduplication prevents replay attacks.
- Per-repo concurrency limits via
MAX_CONCURRENT_PER_REPO.
sequenceDiagram
participant GH as GitHub Actions
participant WE as Modal Web Endpoint
participant GA as GitHub API
participant MS as Modal Sandbox
GH->>WE: 1. workflow_job (queued) Webhook
Note over WE: Verify Signature (HMAC-SHA256)
WE->>GA: 2. Request JIT Config (generate-jitconfig)
GA-->>WE: 3. Return JIT Config String
WE->>MS: 4. modal.Sandbox.create(image, JIT_CONFIG)
Note over MS: 5. Execute run.sh (as root in /tmp)
MS->>GH: 6. Connect & Execute Job
GH-->>MS: 7. Job Finished
MS->>MS: 8. Exit & Terminate Sandbox
- A workflow triggers and a job enters
queued. - GitHub sends a
workflow_jobwebhook to the Modal endpoint. - The endpoint verifies the HMAC-SHA256 signature, then calls GitHub's
generate-jitconfigAPI. - A Modal Sandbox is provisioned with the runner image and JIT config.
- The runner connects to GitHub, executes the job, and the sandbox terminates on completion.
| Variable | Required | Default | Description |
|---|---|---|---|
GITHUB_TOKEN |
Yes | GitHub PAT for runner registration | |
WEBHOOK_SECRET |
Yes | Secret for webhook signature validation | |
ALLOWED_REPOS |
No | (all) | Comma-separated allowlist of owner/repo |
RUNNER_VERSION |
No | 2.333.1 |
GitHub Actions runner version |
RUNNER_GROUP_ID |
No | 1 |
Runner group ID |
MAX_CONCURRENT_PER_REPO |
No | (unlimited) | Max concurrent sandboxes per repo |
ALLOWED_CIDRS |
No | (allow all) | Comma-separated CIDR ranges for outbound |
BLOCK_NETWORK |
No | false |
Fully isolate sandbox network |
CACHE_VOLUME_NAME |
No | Modal Volume name for persistent /cache |
|
MODAL_REGION |
No | us-east-1 |
Modal region for sandbox deployment |
SANDBOX_EXTRA_ENV |
No | JSON string of extra env vars for sandboxes | |
GITHUB_ENTERPRISE_DOMAIN |
No | Custom domain for GitHub Enterprise |
- Docker-in-Docker support uses Modal's alpha Docker-in-Sandbox feature. GitHub Actions
services:and container actions generally work but may have edge cases. - Every job runs in a fresh sandbox. Files saved outside the repository workspace are lost after the job completes.
If this project helps you, giving it a star helps others discover it.
Manas C. Bavaskar
- GitHub: @manascb1344
- Website: manascb.com
- LinkedIn: manas-bavaskar