This page covers installing the engine, connecting it to GitHub, config and storing secrets.
Windows users run Preloop under WSL2; the standalone runner release targets Linux and macOS, not native Windows. Inside WSL2, everything works like Linux:
# Homebrew and npm install the same binaries (macOS and Linux/WSL2):
brew install preloopdev/tap/preloop
npm install -g @preloop-dev/cli
# Or fetch the installer at a release tag, read it, then run it — it downloads
# the release binaries and verifies every artifact's sha256.
ver=v0.33.6 # any tag from https://github.com/preloopdev/preloop/releases
curl -fsSL "https://raw.githubusercontent.com/preloopdev/preloop/$ver/install.sh" -o install.sh
less install.sh
sh install.sh- For the full microVM runner pool, enable nested virtualization in
.wslconfig([wsl2] nestedVirtualization=true) so/dev/kvmis exposed. - Without it,
preloop-runner runstill works (jobs run as WSL processes); only the VM pool needs KVM.
If you only want preloop-runner — a drop-in Rust replacement for the official
actions/runner (config.sh / run.sh) that speaks the same wire protocol
and registers directly against GitHub (or a preloop server) without the
control plane or microVM infrastructure — follow this guide.
It behaves exactly like the official runner:
- Registers with repository or organization runner pools
- Listens for jobs via GitHub's broker service
- Executes jobs, streams step logs, and reports timelines directly to GitHub
- Requires no control plane, no
smolvm, and no background services
Install with one command:
curl -fsSL https://raw.githubusercontent.com/preloopdev/preloop/main/install.sh | sh -s -- --runnerThis detects your platform, downloads preloop-runner, verifies the sha256 checksum,
and installs it into ~/.local/bin/preloop-runner (or pass --prefix <dir>):
- Linux:
x86_64oraarch64 - macOS: Apple Silicon (
aarch64) or Intel (x86_64) - Windows:
x86_64(standalone.exevia release downloads)
Alternatively, download standalone platform binaries (preloop-runner-<triple>) and
CycloneDX SBOMs (preloop-runner_<triple>.cdx.json) from the
latest release.
The container image is available at ghcr.io/preloopdev/preloop-runner:latest.
Get a runner registration token from your GitHub repository or organization:
- Repository:
Settings→Actions→Runners→New self-hosted runner - Organization:
Settings→Actions→Runners→New runner - GitHub CLI:
gh api --method POST /repos/OWNER/REPO/actions/runners/registration-token --jq .token
Run configure with your GitHub URL and registration token. Flags mirror
the official config.sh:
preloop-runner configure \
--url https://github.com/OWNER/REPO \
--token <REGISTRATION_TOKEN> \
--name my-runner \
--labels self-hosted,linux,x64 \
--unattended \
--replace| Flag | Meaning | Official runner equivalent |
|---|---|---|
--url |
GitHub repository or organization URL | --url |
--token |
Registration token from GitHub | --token |
--name |
Runner name in GitHub UI (defaults to hostname) | --name |
--labels |
Comma-separated labels for workflow matching | --labels |
--work |
Work directory (defaults to _work) |
--work |
--runner-group |
Runner group for organization runners | --runnergroup |
--unattended |
Run non-interactively | --unattended |
--replace |
Replace an existing runner with the same name | --replace |
--ephemeral |
Unregister runner after completing one job | --ephemeral |
--no-externals |
Skip downloading Node 20/24 (if pre-installed) | — |
During configure, preloop-runner:
- Authenticates against GitHub's runner API.
- Generates an RSA keypair for message encryption.
- Downloads Node 20 and Node 24 runtimes to
externals/for JavaScript actions. - Persists runner settings to
.runnerand credentials to.credentials.
preloop-runner runThe listener connects to GitHub (broker.actions.githubusercontent.com), polls
for matching jobs, and executes them. Press Ctrl-C for a graceful shutdown.
If you want the runner to process exactly one job, deregister, and exit:
preloop-runner configure --url https://github.com/OWNER/REPO --token <TOKEN> --ephemeral --unattended
preloop-runner run --onceTo run preloop-runner as a persistent background service on a Linux server:
# /etc/systemd/system/preloop-runner.service
[Unit]
Description=preloop GitHub Actions Runner
After=network.target
[Service]
Type=simple
User=runner
WorkingDirectory=/home/runner/preloop-runner
ExecStart=/usr/local/bin/preloop-runner run
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now preloop-runnerTo remove the runner from GitHub:
# Get a remove token from GitHub repo settings or:
# gh api --method POST /repos/OWNER/REPO/actions/runners/remove-token --jq .token
preloop-runner remove --token <REMOVE_TOKEN>preloop init # wizard: credentials, golden image, how to run
preloop serve # engine on 127.0.0.1:9090
cd my-repo
preloop run -f .github/workflows/ci.yml --event pushpreloop init is the front door: it runs the credential step below, chooses
the golden image every job forks from (the official GitHub runner image by
default, or an OCI reference, a Dockerfile in your repo, or a local
.smolmachine/rootfs), picks how to run (foreground, service, or configure
only), and preflights the host (disk, architecture, hypervisor) before writing
the config. In a terminal it is a step-by-step wizard; without one it takes the
same answers as flags, and preloop init --probe --json reports host
capabilities without changing anything. Re-running reconfigures in place.
The sections below are the same steps in depth — preloop setup github
remains the credentials command that init calls.
preloop run snapshots the local workspace (dirty changes included) so a run
will run your workflows locally. It doesnt depends on what is pushed to GitHub or require you to create a commit locally.
If you are running it as an always-on server, service install, every runtime knob, and how to expose it (tunnel, funnel, or your own domain) is covered in self-hosting.md.
Workflows reference GitHub (${{ github.repository }}, GITHUB_TOKEN,
secrets.* etc) so the engine needs a credential. Two kinds are supported:
| GitHub App (recommended) | Fine-grained PAT | |
|---|---|---|
| Token scope | Per-installation, narrows to the repos you pick | Repo/org scoped, expires on a schedule |
| Token shape | ghs_… (installation), minted by the engine |
github_pat_… |
| Setup effort | Create app + install once | Generate once, rotate when it expires |
| Best for | Teams, servers, anything long-running | Personal machines, quick starts |
Classic (ghp_…) and OAuth (gho_…) tokens work but are warned against:
they carry every scope the account has. The wizard refuses nothing but tells
you what you are doing.
Decide first whether you need webhooks:
- Just running CI that talks to GitHub (checkout,
gh,GITHUB_TOKEN, API steps): create the App and stop there. No public address is needed; you start runs yourself withpreloop run. - Check runs on GitHub (the checks on commits and pull requests): you also need webhooks, and webhooks need a publicly accessible HTTP address for the engine. This applies to laptops too, not just servers: a tunnel from your machine is enough.
The App and its webhook are one object; the webhook only adds GitHub's
ability to call you. You can enable it later with --public-url, so
starting without it is fine.
Run this command:
preloop setup github --via appThe command binds a single-use listener on loopback, opens it in your browser, and uses it as the manifest's redirect target. You click Create on GitHub; GitHub redirects back with a one-time code, and the CLI converts it into the App id, private key, and webhook secret and stores the secrets in the operating-system credential store. The config file retains only non-secret credential references (and remains mode 0600). The browser then lands on the installation page, pick the repositories you run, and the CLI reports the installation id and exits.
The private key never leaves the machine: the redirect target is
127.0.0.1, not a hosted page.
| Flag | Effect |
|---|---|
--org NAME |
Create the App under an organization instead of your account. |
--public-url URL |
Also enable webhook delivery to that URL. Omitted, the App is created with webhooks off since GitHub cannot reach localhost. |
--app-name NAME |
App name (GitHub requires global uniqueness). Default preloop-local. |
--port N |
Pin the loopback port instead of taking a free one. |
--no-browser |
Print the URL instead of opening a browser (headless/SSH). |
Without --public-url the App is created with its webhook inactive, because
GitHub cannot reach 127.0.0.1. That only removes GitHub's ability to call
you; everything outbound still works:
| webhooks off (default) | --public-url |
|
|---|---|---|
| What starts a run | you do: preloop run, just submit-ci |
a push/pull_request on GitHub |
Private-repo checkout, gh, API steps |
works — the App mints a token per job | same |
| Check runs on the commit | published (outbound to GitHub) | same |
So a laptop setup is a complete CI system you trigger yourself. When you later get a reachable address — something like a tunnel is enough — point the App you already have at it:
cloudflared tunnel --url http://127.0.0.1:9090 # → https://xxx.trycloudflare.com
preloop setup github --via app --public-url https://xxx.trycloudflare.comFor anything persistent, prefer a named tunnel: the trycloudflare.com
address above changes every restart, which would leave the webhook URL
pointing at a dead address. A named tunnel keeps a stable hostname:
cloudflared tunnel create preloop
cloudflared tunnel route dns preloop ci.example.com
cloudflared tunnel --url http://127.0.0.1:9090 run preloopPoint the webhook at the stable hostname once:
preloop setup github --via app --public-url https://ci.example.comOn an already-configured App, --public-url updates the webhook
(PATCH /app/hook/config) instead of creating a second App, and stores the
secret in the operating-system credential store so deliveries verify. GitHub
exposes no API for the webhook Active
checkbox, so an App created without --public-url needs that ticked once in
its settings.
Already have an App or your org blocks manifest creation? Create it by hand at https://github.com/settings/apps/new (name it, leave webhooks off, download the PEM), install it on the accounts whose repos you run at https://github.com/apps/YOUR-APP/installations/new, then:
preloop setup github --via app --app-id 123456 --pem-file app.pem
preloop doctor --repo owner/repoEither way the engine mints a fresh installation token per job with no long-lived secret sits in the config.
preloop setup github --via pat --token github_pat_… --repo owner/repoor without --token (prompted, hidden input):
preloop setup github --via pat --repo owner/repoUnlike Apps, PATs have no manifest flow. GitHub exposes no API for creating
one so this path opens the creation page and waits at a hidden prompt.
--no-browser skips the opening; piping the token in (or setting
PRELOOP_GITHUB_PAT) skips the prompt entirely, so automation is unaffected.
Two things a PAT does not get you:
-
Check runs. GitHub's checks API only accepts App installation tokens, so a PAT-configured engine records check runs locally instead of publishing them to the commit. Jobs still get the PAT as
GITHUB_TOKEN, so checkout,gh, and API steps work normally. -
A webhook secret. The App flow receives one from GitHub; here you create the webhook yourself (repository → Settings → Webhooks, pointed at
<public-url>/api/v1/github/webhooks) and store its secret:preloop setup github --via pat --webhook-secret "$(openssl rand -hex 32)"
Create the PAT at https://github.com/settings/personal-access-tokens/new
with Repository access → Only select repositories and the permissions the
wizard prints. The checklist is derived from your own workflows' permissions
blocks (union across .github/workflows/*.yml), so you can grant exactly what
your pipelines use.
id-token:is not a PAT permission. The engine is itself the OIDC issuer for local runs (${{ steps.oidc.outputs.jwt }}is signed by the engine), so workflows declaringid-token: writeneed no GitHub-side permission for it.
For orgs that gate app installations, a fine-grained PAT scoped to the org's repos is the supported fallback.
preloop secret mirrors GitHub's secret model, three tiers:
- global (like org-level secrets): injected into every trusted job
- per-repository (like repo secrets): injected only into that repository's jobs
- per-environment (like environment secrets): injected only into jobs that declare that environment for that repository
Per-repo secrets override the global tier per name; per-environment secrets override both; values a submission passes explicitly win over all three.
preloop secret set DOCKERHUB_TOKEN # prompts, hidden
preloop secret set AWS_CREDS --repo owner/repo --value …
preloop secret set DB_PASSWORD --repo owner/repo --env prod --value …
preloop secret list # names only, never values
preloop secret list --repo owner/repo
preloop secret list --repo owner/repo --env prod
preloop secret rm DOCKERHUB_TOKEN
preloop secret rm AWS_CREDS --repo owner/repo
preloop secret rm DB_PASSWORD --repo owner/repo --env prodNames must be UPPER_SNAKE; values are masked in logs exactly like GitHub
(***). Environment names are GitHub-style (prod, staging, …): letters,
digits, hyphens, underscores, at most 255 chars, not starting with - or _.
Secrets apply live: when an engine is running, set/rm go through the
engine API and affect the very next submitted run. With no engine running
they are written to the config file and apply on next start.
Workflows read them the usual way:
steps:
- run: echo "$DOCKERHUB_TOKEN" | docker login -u user --password-stdin
env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}Trust: submissions from untrusted events (fork PRs via the webhook path) do
not receive stored secrets; native preloop run submissions always do.
preloop run warns when a workflow reads a secret this engine has not stored,
and prints the preloop secret set lines that fix it; --strict-secrets
turns that warning into a failure. The engine never reads GitHub's secret
store — values there are write-only, so nothing can import them — which means
an unset name reaches its step as an empty string.
By default stored secrets persist in the config file ([secrets],
[repo_secrets…], [env_secrets…], mode 0600). For deployments that must
not hold plaintext at rest, two options:
-
Memory-only store — set the top-level
secrets_store = "memory"in the config file (orPRELOOP_SECRETS_STORE=memory): the live secrets API keeps values in engine memory for the process lifetime and never writes them to the file.preloop secret setthen requires a running engine; after a restart you re-seed. Combine with the systemd credential below for a durable base set. -
systemd credential — install the service with
--systemd-credential /etc/preloop-secrets.encto mount an encrypted credential (LoadCredentialEncrypted=preloop-secrets); the engine reads[secrets]/[repo_secrets]from it at startup, overriding the config file per name. Create the blob with:systemd-creds encrypt --name=preloop-secrets secrets.toml /etc/preloop-secrets.enc
At rest the blob is encrypted and bound to the host (TPM or machine key); systemd decrypts it into an in-memory file (memfd) the service never writes back. Secrets already in the config file still load and apply — the credential wins per name. Note: secrets backed by the credential are re-applied on every engine restart —
preloop secret rmremoves them from the running store, but to make the removal permanent, edit the credential file (or the config file) itself.
Everything lives in ~/.preloop/config.toml (mode 0600; PRELOOP_CONFIG
overrides the path, PRELOOP_HOME the default directory). GitHub App keys,
PATs, and webhook secrets are held in the operating-system credential store
(Keychain on macOS, Credential Manager on Windows, Secret Service on Linux);
the config file keeps only a *_ref name pointing at each one. Credentials
still stored inline are migrated on the next preloop serve, which rewrites
the config to reference them instead. A missing or empty file is fine
(everything defaults); a malformed one fails startup, so a typo is caught
before a mint or a job hits it. preloop setup github
writes the [github] section; preloop secret writes the secret tables;
preloop init writes the [golden] section and runs setup for the
credentials.
On a host with no reachable credential store — a headless Linux box without a
Secret Service daemon, for example — startup logs a warning and falls back to
any inline values plus the PRELOOP_GITHUB_* environment variables, which
always take precedence. Nothing is migrated there, so the config is left
exactly as written.
Fields:
secrets_store = "file" # "file" (default) | "memory" (see above)
[github]
app_id = "123456"
app_pem_ref = "github-app-pem-123456" # written by setup; key lives in the OS store
mint_failure = "pat" # "local" | "error" | "pat"
pat_ref = "github-pat" # fallback under `pat` policy; `--via pat` credential
webhook_secret_ref = "github-app-webhook-123456" # written by `setup github --via app`
server_url = "https://github.com" # GHES: point at your host
api_url = "https://api.github.com" # GHES: REST base
graphql_url = "https://api.github.com/graphql"
[golden]
kind = "oci" # official | oci | dockerfile | file
base_image = "ghcr.io/acme/base@sha256:…" # what `serve` boots the golden from
dockerfile = "ci/Dockerfile" # only for kind = "dockerfile"
[secrets]
DOCKERHUB_TOKEN = "…"
[repo_secrets."owner/repo"]
AWS_CREDS = "…"
[env_secrets."owner/repo"."prod"]
DB_PASSWORD = "…"Every field is overridable by its environment variable
(PRELOOP_GITHUB_APP_ID, PRELOOP_GITHUB_APP_PEM,
PRELOOP_GITHUB_APP_MINT_FAILURE, PRELOOP_GITHUB_TOKEN,
PRELOOP_WEBHOOK_SECRET, PRELOOP_GITHUB_SERVER_URL,
PRELOOP_GITHUB_API_URL, PRELOOP_GITHUB_GRAPHQL_URL,
PRELOOP_SECRETS_STORE) — the file is the durable store, env vars are the
escape hatch for containers. GitHub credential changes are picked up on
engine restart; secrets changes apply live.
preloop doctor [--repo owner/repo …] verifies each configured credential:
it mints an App token (or uses the PAT) and probes the repository for
contents/pull-requests/actions/issues read. Run it after setup and any time a
job's GITHUB_TOKEN misbehaves.
An engine is built for a single operator. Nothing stops several people from
pointing PRELOOP_URL at the same server, but the server does not yet model
who submitted a run, so treat a shared engine as unsupported:
- One identity. Every caller authenticates with the same native token, so the server cannot tell two people apart. Anyone who can reach the API can read every run's logs and secrets-bearing job messages.
preloop pushdefaults to the server's most recent run, not to yours. On a shared engine that may be a colleague's run — publishing their commit and opening their pull request under your git credentials. Pass an explicit run id (preloop push <run_id>) if you share an engine anyway.- One credential set. The configured App or PAT is used for every run, so check runs and pull requests are always attributed to that identity rather than to the person who submitted.
Give each person their own engine until per-user identity lands.
Run history, queued jobs, runners, sessions, and logs survive restarts. The
default backend is SQLite at <state dir>/preloop.db zero config,
correct for a single machine, and the right choice unless you have a reason
to move off it.
To use Postgres, point the engine at a database with --store or
PRELOOP_STORE_URL:
preloop serve --store 'postgres://user:password@host:5432/preloop?sslmode=require'
# or, for systemd deployments:
# Environment=PRELOOP_STORE_URL=postgres://…?sslmode=requiresqlite://<path>, a bare path, or nothing = SQLite (default).postgres://…= the Postgres backend. It creates the same control schema as SQLite (in acontrolschema) on first use and stamps the version inschema_meta; a database whosecontrolschema is at another version is refused at startup — there is no in-place upgrade from a pre-ControlBackenddatabase, and its old tables are left untouched rather than read. Unlike SQLite, one Postgres database may be shared by several engine nodes: each node opens its own pools (PRELOOP_PG_WRITERS/PRELOOP_PG_READERS, default 16 connections each, plus one listener), transitions are conditional updates, and queued jobs are claimed withSELECT … FOR UPDATE SKIP LOCKED, so concurrent nodes take different jobs and a wake published on one node reaches runners long-polling another. Sizemax_connectionsfor every node's writer + reader + listener connections. SQLite stays single-process — its file must not be shared by two engine processes.- TLS: add
?sslmode=require(orverify-ca/verify-full) for remote managed Postgres (Neon, RDS, Supabase, …) typically requires it. Verification always uses the system root store. Plaintext is the default for loopback databases.
Run Postgres however you like, a managed service, a postgres container on
the same host, or an OS package. The engine does not bundle or spawn a
database server; SQLite is the embedded option, Postgres is an external
dependency you point at.
For a team server that must survive reboots and restarts, install the engine
as a supervised service instead of running preloop serve by hand:
sudo preloop server install \
--public-url https://ci.example.com \
--github-app-id 123456 \
--github-app-key /etc/preloop/app.pem \
--webhook-secret '…'What it does:
-
Linux (systemd) writes hardened units to
/etc/systemd/system/preloop.{service,socket}plus a self-update timer (preloop-update.{service,timer}, hourly, polls GitHub Releases). The control plane is socket-activated on the port of--listen(default 9090). -
macOS (launchd) writes a LaunchDaemon plist to
/Library/LaunchDaemons/dev.preloop.server.plist(mode 0600). -
--systemd-credential PATH(Linux) — mounts an encrypted systemd credential (LoadCredentialEncrypted=preloop-secrets:PATH) so stored secrets come from an encrypted, host-bound blob instead of the config file; see "Where secrets live" in the Secrets section. -
Configuration is written to a mode-0600 environment file (
/etc/preloop/environmenton a Linux system install,<home>/environmentfor--user) — the webhook secret never lands in a world-readable unit. -
State lives in
/var/lib/preloop(mode 0700;--homeoverrides). -
Linux only — dedicated service identity. The systemd service runs under a
preloopsystem account (created automatically;kvmgroup membership added when/dev/kvmexists) instead of root. This is the load-bearing hardening for the VM pool: a guest→VMM escape lands in the SmolVM boot subprocess, which inherits the service identity, so root would hand an escape the whole host. The installer chowns the state dir to that account, because the engine writes its database,config.toml, and keys there.Everything the service must not be able to rewrite deliberately lives outside
PRELOOP_HOME. On Unix the directory write bit governs unlink and rename, so any file inside a directory the service owns can be replaced by the service regardless of that file's own owner and mode. Accordingly:Artifact Location Ownership environment file /etc/preloop/environmentroot:root0600staged App key /etc/preloop/github-app-key.pemroot:preloop0640bootstrapped smolvm /usr/local/lib/preloop/smolvm-prefixroot:root,a+rXengine state /var/lib/prelooppreloop:preloop0700/etc/preloopitself isroot:preloop0750: the service can traverse in to read its key and nothing more. The environment file matters becauseEnvironmentFile=overrides the unit's ownEnvironment=— a service-writable copy would let a compromised VMM persistSMOLVM_SECCOMP=offacross the nextRestart=on-failureand come back unconfined. The smolvm prefix matters because/usr/local/bin/smolvmpoints into it and root executes that path (preloop updateprobessmolvmbefore deciding to reinstall), so a service-writable prefix would be a direct service-user → root escalation.The key is staged rather than chowned in place because a key left in the caller's tree (e.g. under
/root) is unreachable no matter how it is owned — the service user cannot traverse the parent. The caller's original file is never modified. If smolvm is only installed under/root/.local/bin(thepreloop update/ official-installer location), the installer copies it into the prefix above and links/usr/local/bin/smolvmso the service can resolve it; that copy is refreshed on every re-install when the source is newer — re-runsudo preloop server installaftersudo preloop update— atomically (the new prefix is assembled in a staging directory and swapped into place, so a running service never observes a half-copied prefix), and an independently installed system smolvm is never shadowed. The unit also delegates its cgroup subtree (Delegate=cpu memory pids) so each VM gets its own capped cgroup, and denies the service the ability to rewrite its own binary.A system install requires a
--homethe service account can reach:/home/...,/root..., and/run/user/...are rejected up front, becausepreloopcannot traverse them whatever the state dir's own mode is. Use the default/var/lib/preloop, another root-reachable path, or--user.
The --github-app-* / --webhook-secret flags are optional at install time,
but you must define these secrets for the service to be useful: without a
webhook secret the engine rejects every GitHub webhook delivery, and without
an App key it cannot mint GITHUB_TOKEN. They land in the mode-0600
environment file (never in the world-readable units); alternatively install
first, then configure credentials with
sudo -u preloop env PRELOOP_HOME=/var/lib/preloop preloop setup github
(writes the mode-0600 config.toml owned by the service account — see
above; running it as root instead would write a file the service cannot
read). preloop server install --dry-run prints the full plan without
touching the system, and
sudo preloop server uninstall removes the units while keeping
/var/lib/preloop data; pass --purge-data to delete it. Manual copies of
the units live in contrib/systemd/.
Every Linux operation that can boot or restart a SmolVM machine runs
smolvm with the hardening smolvm serve applies, inherited by the
_boot-vm subprocess — the VM provider's create/start/fork/pack/exec paths
and the CLI's direct machine exec/cp/shell calls (which connect to a
machine, starting it when it is stopped) all go through the same policy:
SMOLVM_SECCOMP=enforce— a syscall allowlist kills the VMM on any disallowed syscall (ptrace,mount,bpf,unshare, …). Arch note: upstreamsmolvm serveonly defaults this on Linux/x86_64 (src/cli/serve.rsis gated#[cfg(all(target_os = "linux", target_arch = "x86_64"))]), while the boot subprocess honours the variable on both x86_64 and aarch64 (src/cli/internal_boot.rs). Preloop sets it on every Linux arch, so on Linux/aarch64 it enables a filter upstream leaves off by default. Verify it on a new aarch64 host with theSeccomp: 2check below before relying on it.SMOLVM_LANDLOCK=enforce— the VMM's filesystem view is restricted to its own rootfs/disks/devices; the rest of the host is denied. (Fork clones skip Landlock upstream because they must map the golden's memfd — they stay confined by seccomp and the cgroup.) Upstream gates this on Linux only, with no arch restriction, so Preloop matches it exactly.SMOLVM_CGROUP_ROOT— when the service unit delegates its cgroup subtree (it does by default), each_boot-vmplaces itself in a per-VMvm-<pid>leaf capped on CPU, PIDs, and memory. Note thatDelegate=alone is not enough: systemd chowns the unit's cgroup subtree to the service user but leavescgroup.subtree_controlempty, so a child leaf created there has nocpu.max/memory.max/pids.max. The server therefore performs the same one-time setupsmolvm servedoes at startup — move itself into apreloop-supervisorleaf, then enablecpu/memory/pidson the now-empty unit cgroup — and only then advertises the root. That write happens once, explicitly, in the server; the CLI never mutates the cgroup hierarchy and falls back to a read-only check, sopreloop shelland the debug session leave it untouched. No usable delegation, no variable.
Both controls fail closed: if the operator has already set
SMOLVM_SECCOMP/SMOLVM_LANDLOCK in the service environment, the pre-set
value wins (the same precedence smolvm serve documents), but only modes
SmolVM actually honors (enforce/audit/off for seccomp, enforce/off
for Landlock) — an unrecognized value is a hard error rather than the silent
"off" upstream would treat it as. Setting SMOLVM_SECCOMP=off /
SMOLVM_LANDLOCK=off is the deliberate, visible escape hatch for a
self-hosted single-tenant box that cannot tolerate the filters.
Verifying activation on Linux. After the first machine exists, find the VMM and check the kernel's own record:
pgrep -af "_boot-vm" # -> <pid> smolvm _boot-vm <config>
sudo grep Seccomp /proc/<pid>/status # -> Seccomp: 2 (filter active)
sudo tr '\0' '\n' < /proc/<pid>/environ | grep -E '^SMOLVM_(SECCOMP|LANDLOCK|CGROUP_ROOT)='Seccomp: 2 is the kernel's confirmation that the allowlist is enforced.
Landlock has no status field in /proc, so its activation is verified by the
boot subprocess's environment (SMOLVM_LANDLOCK=enforce above) plus
SmolVM's own fail-closed behavior: with the variable set, a Landlock
restriction that fails to install aborts the boot rather than running
unconfined. SMOLVM_CGROUP_ROOT should name the service's own cgroup
(/sys/fs/cgroup/system.slice/preloop.service); the per-VM leaves appear as
vm-<pid> subdirectories with cpu.max/pids.max/memory.max set.
Don't have (or don't want) root on the box? Install a per-user service
instead — no sudo required, and state defaults to ~/.preloop instead of
/var/lib/preloop:
preloop server install --user --public-url https://ci.example.com- Linux — systemd user units in
~/.config/systemd/user/, managed withsystemctl --user(the self-update timer works the same). They stop when you log out;sudo loginctl enable-linger $USERkeeps them running. - macOS — a LaunchAgent at
~/Library/LaunchAgents/, loaded into your GUI session. LaunchAgents only run while you're logged in. - Everything else is identical: same flags, same 0600 config file, same
--dry-run, andpreloop server uninstall --userto remove it.
System scope is still the right default for a team server (runs before login,
accepts webhooks unattended); --user fits personal machines and dev boxes.
--public-url is the address GitHub uses to deliver webhooks and link check
runs. Without it (or with a loopback default), GitHub can't reach the engine —
the service runs, but nothing ever triggers. Two ways to make it reachable:
Production — a domain. You should definitely point a DNS record at the host and terminate TLS
in front of the engine: a Caddy/nginx reverse proxy to 127.0.0.1:9090 and do a bunch of other security hardening stuff on your server. (or bind 0.0.0.0:9090behind your own TLS). Register the App's webhook ashttps://ci.example.com/api/v1/github/webhooks` (gated by the webhook secret)
and install with:
sudo preloop server install \
--public-url https://ci.example.com \
--github-app-id 123456 --github-app-key /etc/preloop/app.pem \
--webhook-secret '…'Trying it out use a tunnel. No DNS record or inbound port needed:
cloudflared tunnel --url http://127.0.0.1:9090 # quick tunnel → https://xxx.trycloudflare.com
ngrok http 9090
tailscale funnel 9090Re-run preloop server install with the tunnel URL as --public-url (or set
PRELOOP_PUBLIC_URL in the service environment file and restart). Note that a
quick tunnel's URL changes on every restart — for anything long-lived, use a
named Cloudflare tunnel or the domain path above.
doctorsays the App has no installation for a repo — install the app on that account/repo, or check the installation's repository selection.GITHUB_TOKEN403s in a job — the installation may not grant the workflow's requested permissions. The engine logs which permissions are ungranted; grant them on the installation page.- A secret reads empty in a run — check
preloop secret list(was it scoped to another repo?) and whether the event was trusted (fork PRs get no stored secrets). - Mint failure policy —
mint_failuredecides what happens when App minting fails:local(fall back to the local JWT),error(fail the job),pat(fall back to the PAT).