preloop serve runs the control plane and its microVM runner pool in one process. Keep the listener private by default, expose only the webhook path when GitHub needs inbound access, and use a separate runner origin only for remote runners.
Requirements
For a runner pool, install the Linux
preloop-runner bundle as part of the release installation. A missing bundle disables the pool after its queue grace period; see VM images.
Install and run
Use a tagged release rather than piping an unreviewed branch into a shell:PRELOOP_LISTEN defaults to 127.0.0.1:9090. PRELOOP_HOME defaults to $HOME/.preloop and contains the state directory, credentials, VM artifacts, and socket. PRELOOP_PUBLIC_URL is the GitHub-facing base and defaults to the loopback listen URL when serve starts without an override.
Run as a service
On Linux, install hardened systemd units with socket activation:preloop.service and preloop.socket, a private environment file, and—unless --no-update-timer is set—preloop-update.service and preloop-update.timer. The socket listens on the configured port and starts the service on demand. The update timer waits 10 minutes after boot, then checks hourly with a randomized delay of up to 30 minutes.
The system install uses a dedicated preloop account, keeps state in /var/lib/preloop by default, delegates CPU/memory/PID cgroups to the VM launcher, and restarts the service after failure. On Linux it also adds that account to the host’s kvm group when /dev/kvm exists, because the VMM opens the device as the service identity; it warns instead when there is no kvm group or the change fails. Use --systemd-credential PATH to mount an encrypted secret file with LoadCredentialEncrypted; create it with:
--dry-run to inspect changes. --user installs a systemd user unit or macOS LaunchAgent without root; on Linux, enable lingering if it must survive logout:
--user. Remove units without deleting data:
--purge-data only when the state directory should also be removed.
What must be reachable
PRELOOP_PUBLIC_URL is not the runner data path. PRELOOP_RUNNER_URL defaults to the loopback engine URL and carries broker, results, cache, artifact, OIDC, and live-log links. Do not point it at a public tunnel for local VM jobs.
Exposure options
Keep the engine on127.0.0.1:9090 and path-filter an ingress to /api/v1/github/webhooks.
Caddy:
path: ^/api/v1/github/webhooks$ and a final http_status:404 rule. A catch-all tunnel publishes runner registration and management APIs.
Tailscale: use Tailscale Serve for tailnet-only access:
tailscale funnel status is the check that matters: it lists every route that is actually public on this node, so anything else it names is open to the internet. preloop init makes the same check before it offers that URL as the suggested answer: it reads the name from tailscale status --json and accepts Enter on it only when tailscale funnel status --json shows the webhook path as the sole public route here. A name that is not Funnel-published (or one whose Funnel also publishes other mounts) is still shown, but only as a hint — blank keeps webhooks off.
If you also need other authenticated paths (run pages, for example), put a path-filtering proxy in front and publish only its port. Keep Preloop on loopback and place the proxy on a separate local port — the Caddy example above does it — then expose only that proxy port through Funnel, not the engine listener, and only on a hostname with nothing else mounted on the same port:
preloop run or the REST dispatch API.
Exposing the App-setup routes
preloop setup github --via app uses PRELOOP_PUBLIC_URL when it hosts the App-creation form itself: the browser loads /setup/github and GitHub redirects to /setup/github/callback. Those two routes answer 404 except while a setup link is open, and the link works once for ten minutes. Your browser must reach the URL to finish the setup, but GitHub also validates the webhook URL in the App manifest, so the address needs to be one GitHub can reach.
The engine only accepts a setup for an origin it can vouch for: with PRELOOP_PUBLIC_URL set, --public-url (or the environment fallback) has to name that same origin, and a plain-http address is refused unless it is loopback. POST /api/v1/github/setup-sessions — which hands out the one-time setup link — needs no inbound route of its own: the CLI calls it on the engine’s own address.
If you publish only the webhook path (the pattern above), let the two browser routes through the same proxy while a setup runs, and nothing else:
/api/v1/github/setup-sessions: it is the authenticated endpoint that mints the setup link, and the CLI reaches it over the engine’s own address or your existing authenticated ingress.
Two operational notes for the hosted flow:
- The engine reads its own public address from
PRELOOP_PUBLIC_URL, and nothing a CLI sends can change a running process’s environment. A setup opened for another address therefore warns: the App’s webhook points at the CLI’s URL while the delivery-drift check keeps expecting the engine’s, sopreloop statusreports drift until you set the same value forpreloop serve. --repochecks wait up to five minutes for the App to be installed on those repositories before checking them.PRELOOP_SETUP_INSTALL_WAIT_SECSchanges the wait (0checks immediately).
Operating and upgrading
preloop status reads queue health and recent runs. The native status endpoint requires the system bearer; keep PRELOOP_SYSTEM_TOKEN in the credential store or another protected environment. After updating SmolVM or the binary under a system install, rerun sudo preloop server install so the service’s protected runtime prefix is refreshed atomically.
Only one engine can own a state directory. A second preloop serve on the same PRELOOP_HOME exits at once; when the operating system can prove which process owns serve.lock, the error names the engine and the way to stop it:
- Started in the background by a launcher that set
PRELOOP_ENGINE_ORIGIN=background— no preloop command starts an engine on its own (issue #658):kill <pid>stops it, and thenpreloop servecan run in this terminal. - A systemd service: stop both units. Stopping only
preloop.serviceis not enough, becausepreloop.socketstarts it again on the next connection. Per-user installs usesystemctl --user.
ready can be delivered again, while terminal or unclaimable work is settled. Pool VMs are not resumed across a process restart; rerun the affected workflow when needed.
