The preview-deployment daemon for an ePHPm cluster. A small Rust binary that
runs as a systemd unit beside ePHPm on every node, consumes job files from a
local queue, provisions PR previews into sites_dir, and reports them on the
pull request.
switchboard began as one binary that received GitHub webhooks and provisioned previews. It has been split in two:
| Component | Language | Runs as | Responsibility |
|---|---|---|---|
ephpm/switchboard-api |
PHP | a confined vhost inside ePHPm | receive webhooks, verify signatures, write job files |
| switchboard (this repo) | Rust | a systemd unit beside ePHPm | consume jobs, check out repos, provision previews, kick drain, report to GitHub |
The split is forced by ePHPm's own isolation model and turns it into a property
worth having. In sites_dir mode ePHPm applies open_basedir per request, so
the API — the component exposed to the internet, accepting unauthenticated
POSTs — provably cannot reach sites_dir, cannot run a deploy, and holds no
credential that can write to a GitHub repository. The daemon does all of that
and never listens on a public port.
The old in-process webhook receiver is still compiled in but is off by
default; see --webhook-server-enabled.
Jobs are schema-1 JSON documents the API writes to <state_dir>/queue/. The
contract — field reference, validation rules, and the consumption protocol — is
owned by switchboard-api's README, sections "The job file contract" and
"How the daemon should consume the queue". This daemon implements it:
- Arrival order is
sort(). Filenames are<13-digit millis>-<16 hex>.json, so a lexicographic sort ofreaddiris chronological. - Claim with
link(), notrename().link()fails withEEXISTwhen another worker already claimed the job;rename()would overwrite silently and both callers would believe they won. The queue entry is unlinked once the link succeeds. schemamust equal 1. Anything else is rejected rather than guessed at.- Act on
intent(deploy/teardown), never on the raw GitHubaction. - Coalescing is the daemon's job. Rapid pushes produce several jobs for one
preview.label; the newest wins and the rest are discarded. - Delete on success, leave on failure. A failed job stays in
queue/claimed/for inspection rather than being retried forever.
preview.label is authoritative and never recomputed — with
--preview-domain appended it is the preview host ePHPm resolves.
A job file states what was true when switchboard-api wrote it, and a queue can
hold that statement indefinitely. A pull_request/opened job that had sat
unclaimed for two days was applied during a restart and provisioned a preview
for a pull request that had already merged — restarts are exactly when this
fires, because a restart drains a backlog of statements about the past.
So a deploy job is validated at claim time, not trusted from enqueue time. Two checks, deliberately different in kind:
| Check | Cost | Fails |
|---|---|---|
Age — has this job been sitting in the queue longer than --max-job-age-secs? |
offline arithmetic on the filename's timestamp | closed (discards the job) |
| PR state — is the pull request still open, per GitHub now? | one API call, needs the App credentials | open (applies the job) |
The age bound runs on every node, including one with no GitHub App configured
(the e2e cluster). The state check is authoritative but best-effort: a GitHub
outage, a rate limit, or a state value this daemon does not recognise applies
the job with a WARN rather than dropping it, because refusing to deploy
because a third party is unreachable is its own kind of drift.
A discarded job is resolved, not failed: it is cleared from claimed/ like
a successful one, with the reason logged at WARN.
Teardown jobs are never validated. A teardown is idempotent, removes drift rather than creating it, and is never wrong to apply late — the preview it names should not exist either way. Refusing a stale one would strand exactly the artifacts teardown exists to remove, the same reasoning that makes fork teardowns unconditional.
Every per-site artifact is named by the canonical site key, which is ePHPm's
derivation, not switchboard's (src/site_key.rs): the preview host, normalized,
with ePHPm's [server] sites_domain_suffix stripped. So:
[server] sites_domain_suffix on the node |
--sites-domain-suffix |
Site key for blog-pr-7.preview.ephpm.dev |
|---|---|---|
.preview.ephpm.dev (what the preview cluster runs) |
(default) | blog-pr-7 |
| unset | "" |
blog-pr-7.preview.ephpm.dev |
Getting this wrong does not error — it provisions the preview into a directory
ePHPm never resolves, and per ePHPm's fail-closed rule such a request gets no
per-site database and no DB_* credentials. That is why the suffix is an
explicit, validated flag rather than an assumption. The daemon logs the
resolved derivation at startup.
A deploy fetches refs/pull/<n>/head at the recorded head.sha from the
base repository (which works for forks, and for deleted forks, without
trusting a third-party clone URL). The fetch is authenticated with a
short-lived GitHub App installation token (the same credential used for
reporting), injected as a transient http.extraheader scoped to that one git
process — never written into the checkout's persisted git config and never
logged — so private repositories can be previewed. When no App credentials
are configured the fetch stays unauthenticated: public repos still work, and a
private repo fails with a clear "configure --app-id/--app-key and grant the
installation contents: read" message. The deploy then materializes the app's
env:, publishes the
manifest's docroot: and the generated env prepend as ePHPm's per-site
override, installs the result at <sites_dir>/<key>/ by staging into <key>.tmp
and renaming, and only then runs the manifest's build: and seed: steps —
sandboxed (see below).
Before the swap the deploy takes the manifest itself out of the served root:
ephpm.yaml / ephpm.yml / ephpm.json are moved to <site>/.switchboard/.
They are ordinary, non-dot-prefixed files at the repository root, and for an app
declaring docroot: "." — WordPress, and most bespoke apps — the repository
root is the web root, so GET /ephpm.yaml returned 200 with the build
commands, the enabled services and the whole seed sequence (switchboard#16). The
archive is dot-prefixed, so ePHPm's hidden_files default (deny) makes it a
403; it is inside the site directory, so the existing teardown reaps it. A
build: or seed: step that wants the manifest must read
.switchboard/ephpm.yaml.
build: and seed: execute untrusted code from a pull request. They used
to run as root via sh -c — a confirmed root-RCE on the preview cluster.
Every such step now runs through ePHPm's sandboxed execution primitive
(ephpm exec --site, ephpm#484):
ephpm exec --config <ephpm.toml> --site <key> -- sh -c "cd <workdir> && <step>"
which drops to the tenant uid, applies a Landlock filesystem scope (the vhost
container + its private temp/session root, and nothing else — not /etc/ephpm,
not /root), and arms the host's uid-keyed egress firewall. build: runs at the
container root (where composer.json lives); seed: runs at the document root.
The step's environment (PREVIEW_URL, PREVIEW_HOST, PR,
COMPOSER_NO_INTERACTION, …) is inherited through ephpm exec. This is why
build: moved to after the atomic swap: ephpm exec --site <key> sandboxes
a command in the existing vhost directory, which does not exist until the swap.
This fails closed. If the configured ephpm (--ephpm-bin) does not support
exec — an ePHPm predating #484 — a deploy refuses rather than falling back
to running the step as root. There is deliberately no unsandboxed fallback. That
makes the rollout order a hard requirement: deploy the ePHPm carrying #484 to a
node before deploying this switchboard to it. In the wrong order switchboard
refuses to deploy previews (the safe direction) until ePHPm is upgraded.
A teardown removes everything the preview left on this node, each derived from the site key by exact path — never a glob wider than the one site:
<sites_dir>/<key>/(and a leftover<key>.tmpstaging directory);- the per-site database
<key>.db+-wal/-shm/-journalunder--sqlite-dir(ePHPm's[db.sqlite].dir) — the dominant disk consumer on a WordPress preview; - the
<key>.tomlper-site override (document root +auto_prepend_file) under--site-overrides-dir; - the per-vhost temp/session state root under
--vhost-temp-base(default: this process's<system temp>/ephpm-vhosts, matching ePHPm's default). The directory name embeds a hash ePHPm computed over the container path; the daemon reproduces it and additionally sweeps for this site's exact name shape (<key>-<16 hex>), so a hash it cannot reproduce still gets reaped; - switchboard-api's desired-state marker at
<state_dir>/applied/<label>— the record of what this node last materialized into its own queue. This one is keyed by the preview label, not by the site key (they differ on a node with nosites_domain_suffix). Nothing else reaps it, so a removed preview used to leave a "this site should exist" record behind on every node. A marker recording a deploy newer than the teardown is left alone: that is current desired state, not drift.
In cluster mode every node's daemon runs the same teardown against its own disk, which is the complete story — each node reaps its own replicas.
A deployed preview currently serves to anyone who resolves its hostname — there is no access gate on the preview URL. Now that a private repository can be checked out (above), that means a private repo's preview is reachable by anyone who knows the hostname. This is stated rather than implied: it is a real exposure, and the fix is designed but not yet shipped.
The gate belongs in ePHPm, not switchboard — it has to cover the static-file
path as well as PHP and fail closed, which is the request-phase middleware layer
ePHPm already has, and switchboard's only per-site channel (the two-key override
file) is deliberately closed. The full design, threat model, and the companion
ePHPm issue it depends on (ephpm/ephpm#487) are in docs/preview-access-gate.md.
Until that lands, treat previews as world-readable and do not preview a
repository whose mere contents are sensitive.
--sqlite-dir and --site-overrides-dir have no defaults — they are ePHPm's
configuration and the daemon cannot derive them — so the daemon refuses to
start without them. Leaving them unset used to be a quiet skip: the vhost
directory went away, the tenant's <key>.db and -wal stayed on disk, and the
teardown reported success. That is data retention, not untidiness, and the live
preview cluster ran that way for weeks because its systemd unit (which is not in
this repo) passed neither flag.
Set both, or acknowledge the gap explicitly with --allow-incomplete-teardown.
With the acknowledgement the daemon starts, every teardown logs a WARN naming
the artifact classes it did not attempt, and the job still succeeds. Without it,
a teardown that cannot reach an artifact class removes everything it can, then
fails with a message naming exactly what it left — so the job stays in
queue/claimed/ for an operator instead of disappearing.
switchboard-api is a PHP vhost: it only runs when a request arrives, so it has
no timer of its own. Work it must do off the webhook path needs someone to call
it, and this daemon is already running on every node. On each
--drain-interval-secs it sends:
GET http://<drain_addr>/drain
Host: <drain_host>
X-Drain-Token: <trimmed contents of drain_token_file>
The token is re-read per kick, so the shared secret can be rotated without
restarting the daemon, and is trimmed — a secret written with echo carries a
trailing newline that would otherwise be sent as part of the header.
A refused connection or a non-200 is a WARN and the loop carries on; a missed
kick is picked up by the next one. Set --drain-interval-secs 0 to disable
the kick entirely — the correct setting for a single-node deployment with
nothing to fan out to.
The daemon mints an installation token from the GitHub App private key, posts
(or updates) the preview comment on the PR, and creates the Deployment status.
The comment is sticky: it carries a hidden <!-- switchboard-preview -->
marker, so a later push updates the same comment in place rather than appending
a new one.
Reporting degrades rather than fails. With no App credentials configured,
startup logs one INFO line and every deploy proceeds normally — it is just not
reported on the pull request. This is what the e2e cluster, which has no App
installed, runs with.
Deduplication is the hidden marker, not coordination. Every node reconciles
the same preview and every node reports, but before creating a comment the
daemon lists the PR's comments and looks for the hidden
<!-- switchboard-preview --> marker; if it finds one it updates that comment
in place. So N nodes converge on a single comment, and a later push refreshes
it rather than appending. This needs no shared state and no KV listener — the
operator keeps ePHPm's RESP listener ([kv.redis_compat]) off.
Honest limit: the find-then-create is not atomic across nodes, so two nodes that both list before either has created can momentarily post two comments. It is a sub-second window and self-heals — the next push finds one of them by its marker and updates it, converging back to one.
Every knob is a CLI flag with an SWITCHBOARD_* environment fallback, which is
what a systemd unit wants: EnvironmentFile= for deployment-specific values and
no config file to keep in sync. switchboard --help prints the same list.
| Flag | Env | Default | Meaning |
|---|---|---|---|
--state-dir |
SWITCHBOARD_STATE_DIR |
(required) | switchboard-api's .switchboard/ directory. Jobs are consumed from <state_dir>/queue/. |
--queue-interval-secs |
SWITCHBOARD_QUEUE_INTERVAL_SECS |
2 |
Seconds between queue scans. |
--max-job-age-secs |
SWITCHBOARD_MAX_JOB_AGE_SECS |
3600 |
Discard a deploy job that has waited longer than this in the queue rather than applying it. 0 disables the bound. Teardown jobs are never discarded by age. See A claimed job is re-checked against current state. |
| Flag | Env | Default | Meaning |
|---|---|---|---|
--drain-interval-secs |
SWITCHBOARD_DRAIN_INTERVAL_SECS |
2 |
Seconds between kicks. 0 disables the kick entirely. |
--drain-addr |
SWITCHBOARD_DRAIN_ADDR |
127.0.0.1:8080 |
host:port of the local ePHPm instance. |
--drain-host |
SWITCHBOARD_DRAIN_HOST |
(none) | The switchboard-api vhost, sent as the Host header. Required when the interval is non-zero. |
--drain-token-file |
SWITCHBOARD_DRAIN_TOKEN_FILE |
(none) | File holding the shared secret (the API's .switchboard/drain_secret), sent as X-Drain-Token. Required when the interval is non-zero. |
A missing or empty token file fails at startup, not silently every two seconds forever.
| Flag | Env | Default | Meaning |
|---|---|---|---|
--sites-dir |
SWITCHBOARD_SITES_DIR |
/var/www/sites |
ePHPm's sites directory. Previews land at <sites_dir>/<site-key>/. |
--preview-domain |
SWITCHBOARD_PREVIEW_DOMAIN |
preview.ephpm.dev |
Suffix appended to the label to form the preview host. |
--sites-domain-suffix |
SWITCHBOARD_SITES_DOMAIN_SUFFIX |
.<preview-domain> |
ePHPm's [server] sites_domain_suffix on this node. Decides the site key (see above). Pass "" for a node that configures no suffix. Must begin with a dot — ePHPm refuses a dotless one (ephpm#397). |
--composer |
SWITCHBOARD_COMPOSER |
composer |
Composer command or path. |
--ephpm-bin |
SWITCHBOARD_EPHPM_BIN |
ephpm |
The ephpm binary used to run build:/seed: steps sandboxed (ephpm exec --site). Must support exec (ephpm#484) — a deploy refuses otherwise rather than running steps as root. Set an absolute path if it is not on the daemon's PATH. |
--ephpm-config |
SWITCHBOARD_EPHPM_CONFIG |
/etc/ephpm/ephpm.toml |
The node's ephpm.toml, passed to ephpm exec --config. Must be the same config the running server uses, so the sandbox resolves the same per-site boundary. |
--secrets-file |
SWITCHBOARD_SECRETS_FILE |
(none) | YAML secret store for ${secret.NAME} references in a manifest's env:. |
--health-timeout-secs |
SWITCHBOARD_HEALTH_TIMEOUT_SECS |
60 |
How long to poll the manifest's health: path for a 200. 0 disables the gate. |
--health-interval-secs |
SWITCHBOARD_HEALTH_INTERVAL_SECS |
2 |
Seconds between health polls. |
--sqlite-dir |
SWITCHBOARD_SQLITE_DIR |
(none) | ePHPm's [db.sqlite].dir. Teardown removes the preview's <key>.db (+ journal files) from here. Required unless --allow-incomplete-teardown is set — see Teardown is complete or it fails. |
--site-overrides-dir |
SWITCHBOARD_SITE_OVERRIDES_DIR |
(none) | ePHPm's [server] site_overrides_dir — a directory outside sites_dir (ePHPm refuses to start otherwise). The deploy writes each preview's <key>.toml override here — its document_root and the auto_prepend_file that delivers the manifest's env: to PHP — and teardown removes it. Written atomically (temp + rename): as of ephpm#472 a half-written override takes that site to a 503, not to a warning. Required unless --allow-incomplete-teardown is set. Unset it also means a manifest's docroot: cannot be honoured (ePHPm serves the whole checkout) and its env: reaches PHP only through the generated .env. |
--allow-incomplete-teardown |
SWITCHBOARD_ALLOW_INCOMPLETE_TEARDOWN |
false |
Start without the two roots above, and let teardown report success while leaving those artifacts on disk (a WARN per teardown names them). An acknowledgement, not a feature. |
--vhost-temp-base |
SWITCHBOARD_VHOST_TEMP_BASE |
<system temp>/ephpm-vhosts |
Where ePHPm keeps per-vhost temp/session state roots. Set explicitly when the daemon and ePHPm do not share a temp dir (PrivateTmp, differing TMPDIR). |
Secrets can also come from SWITCHBOARD_SECRET_<NAME> environment variables
(folded into the default scope, lowercased; the file wins on conflict).
| Flag | Env | Default | Meaning |
|---|---|---|---|
--allow-fork-deploy |
SWITCHBOARD_ALLOW_FORK_DEPLOY |
false |
Deploy pull requests from forks. Without it a fork deploy job fails with a clear message. |
--fork-secrets |
SWITCHBOARD_FORK_SECRETS |
false |
Resolve ${secret.NAME} operator secrets into fork deploys. Requires --allow-fork-deploy; setting it alone is a startup error. |
The job file carries pull_request.fork (emitted by switchboard-api since its
first release); a schema-1 document without the field is treated as a fork,
because no legitimate producer omits it. This gate is deliberately a second one
under the API's SWITCHBOARD_ALLOW_FORKS: the API decides what gets queued,
but this daemon is the process that holds the secret store and builds the code,
so it decides again. Building untrusted code and handing it operator secrets
are two separate decisions, hence two flags — with --allow-fork-deploy alone,
a fork builds but every ${secret.NAME} expands to the empty string (with a
name-only warning). Fork teardowns are always processed; refusing them
would strand previews on disk.
| Flag | Env | Default | Meaning |
|---|---|---|---|
--app-id |
SWITCHBOARD_APP_ID |
(none) | GitHub App ID. |
--app-key |
SWITCHBOARD_APP_KEY |
(none) | GitHub App private key (PEM path). |
Omit both to run without reporting. Supplying exactly one is a startup error: a half-configured App would silently never report.
Comments are deduplicated cluster-wide by the hidden marker (see Report to GitHub); no shared state or KV listener is involved.
| Flag | Env | Default | Meaning |
|---|---|---|---|
--webhook-server-enabled |
SWITCHBOARD_WEBHOOK_SERVER_ENABLED |
false |
Run the in-process /webhook receiver. Kept for deployments that have not migrated to switchboard-api. |
--listen |
SWITCHBOARD_LISTEN |
0.0.0.0:9090 |
Listen address for that receiver. |
--webhook-secret |
SWITCHBOARD_WEBHOOK_SECRET |
(none) | HMAC secret. Required when the receiver is enabled — an unauthenticated webhook endpoint will not start. |
switchboard \
--state-dir /var/www/sites/switchboard/.switchboard \
--sites-dir /var/www/sites \
--preview-domain preview.ephpm.dev \
--sqlite-dir /var/lib/ephpm-web/db \
--site-overrides-dir /etc/ephpm/sites \
--drain-host switchboard.ephpm.dev \
--drain-token-file /var/www/sites/switchboard/.switchboard/drain_secret \
--app-id 123456 \
--app-key /etc/switchboard/app.pemThe two teardown roots must match ePHPm's [db.sqlite].dir and
[server] site_overrides_dir on the same node; the daemon will not start
without them (or --allow-incomplete-teardown).
The same invocation runs on every node in a cluster; the PR comment is deduplicated by its hidden marker, so all nodes converge on one comment.
Single node, no GitHub App, no per-site databases to reap:
switchboard --state-dir /var/www/sites/switchboard/.switchboard \
--drain-interval-secs 0 \
--allow-incomplete-teardownLogging is tracing with an RUST_LOG-style EnvFilter; the default is
info,switchboard=debug.
cargo build --release # → target/release/switchboard
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --checkCI (.github/workflows/ci.yml) runs fmt, clippy, tests, and a cargo check
pinned to the crate's MSRV on the ephpm org's self-hosted fleet.
| File | What it does |
|---|---|
src/main.rs |
Startup, the queue loop, the drain loop, GitHub token minting, the legacy receiver |
src/config.rs |
Every flag and env var, plus the cross-field validation clap cannot express |
src/job.rs |
The schema-1 job document: parse, validate, convert to a PreviewRequest |
src/queue.rs |
Scan, claim (link+unlink), coalesce per label, complete; the enqueue timestamp a claimed job carries |
src/validate.rs |
Claim-time re-validation of a deploy job: the queue-age bound and the current-PR-state check |
src/drain.rs |
The /drain kick and the shared-secret file |
src/deployer.rs |
The provisioning pipeline: fetch → manifest → env → quarantine the manifest → per-site override → atomic swap → chown to tenant → build → seed → health. build:/seed: run sandboxed via ephpm exec --site (fail-closed if unsupported). |
src/site_override.rs |
The per-site override ePHPm reads: validating docroot: and the env prepend against ePHPm's own containment rules, rendering the TOML, and writing it atomically |
src/teardown.rs |
Preview teardown: vhost dir, per-site database, override file, vhost temp/session state root, the API's applied/ marker — and the refusal to call a partial teardown a success |
src/manifest.rs |
The ephpm.yaml app manifest schema, and moving it out of the served root once read |
src/secrets.rs |
${secret.NAME} resolution from switchboard's own store |
src/github.rs |
PR comments and Deployment statuses (sticky via the hidden marker) |
src/webhook.rs |
Signature verification and payload types for the legacy receiver |