Run a box
This is the handbook for the person who runs a production shippyd box. It covers installing one from zero, giving people access, watching it, fixing the common problems and upgrading it. If you only want to try a shared cache, start with Share a cache.
What runs where
| Thing | Where | Notes |
|---|---|---|
shippyd | user shippy, unit shippyd.service | port 7447: the API, /healthz, /readyz, /metrics (with a token) |
| The store | /var/lib/shippy/store | blobs, results, claims, packs, sandbox scratch space |
| Toolchains | /var/lib/shippy/rustup | what the sandbox compiles with |
| Configuration | /etc/shippy/shippyd.toml | root writes it, the shippy group reads it |
| Tokens | /etc/shippy/tokens.toml | hashes only; the one file that is not a cache |
| Local metrics | 127.0.0.1:9447 | /metrics, /healthz, /readyz without a token |
shippy-warm | the box or another machine, user shippy-warm | 127.0.0.1:9448: /trigger, /status, /metrics |
| Binaries | /opt/shippy/releases/<version>, linked from /usr/local/bin | /opt/shippy/previous is the release before |
shippyd worker | optional worker machines | see Workers |
Install from zero
Unpack a release
On a fresh Ubuntu 24.04 machine, unpack shippy-<tag>-x86_64-unknown-linux-gnu.tar.gz and change into it.
Preview what bootstrap would do
SHIPPY_TOOLCHAINS="1.100.0 1.99.0" SHIPPY_STORE_DEVICE=/dev/nvme1n1 \
bash deploy/bootstrap.sh --dry-run--dry-run changes nothing and prints every command it would run. Read it first on a machine that is not fresh.
Run bootstrap
sudo SHIPPY_TOOLCHAINS="1.100.0 1.99.0" \
SHIPPY_STORE_DEVICE=/dev/nvme1n1 \
TS_AUTHKEY=tskey-auth-... \
bash deploy/bootstrap.shIt installs the packages builds link with, the kernel settings and AppArmor profile bubblewrap needs, the shippy user, the toolchains, the binaries, /etc/shippy/shippyd.toml (never overwriting one that is there), the systemd unit, Tailscale and the firewall. Then it starts shippyd and waits for /readyz. You can run it again at any time; it changes only what differs.
The bootstrap variables
| Variable | Default | What it does |
|---|---|---|
SHIPPY_TOOLCHAINS | stable | Toolchains to install for the sandbox, separated by spaces. |
SHIPPY_STORE_DEVICE | the root filesystem | A block device for /var/lib/shippy. Formatted only when it holds no filesystem. |
SHIPPY_PORT | 7447 | The port shippyd serves on. |
SHIPPY_ALLOW_CIDRS | none | Networks besides the tailnet that may reach the port, in plain HTTP. Refused without SHIPPY_ALLOW_PLAIN_HTTP=1. |
SHIPPY_ALLOW_PLAIN_HTTP=1 | off | Says the networks of SHIPPY_ALLOW_CIDRS may carry tokens unencrypted (another VPN, a private link). |
SHIPPY_SSH_CIDRS | anywhere | Networks that may reach SSH. |
TS_AUTHKEY | none | A Tailscale auth key. Without it, run tailscale up by hand. |
TS_HOSTNAME | the hostname | The machine's name in the tailnet. |
SHIPPY_WARM=1 | off | Also set up the warmer on this machine. |
SHIPPY_ROLE=worker | box | Make the machine a worker instead (Workers). |
SHIPPY_BIN_DIR | ../bin beside the script | Where the binaries are. |
SHIPPY_SKIP_FIREWALL=1, SHIPPY_SKIP_TAILSCALE=1 | off | Leave those alone. |
Where the store lives
Keep the store on disk (the NVMe) at /var/lib/shippy/store, not on tmpfs:
- A reboot, kernel update or crash empties a tmpfs, and then everything compiles again for everyone.
- Reads come from memory either way: the kernel's page cache holds what is read often.
- A full tmpfs fails uploads and sandbox runs at once. A full disk degrades into eviction.
- shippyd syncs nothing, so writes cost the same as on tmpfs.
The filesystem must not be mounted noexec, and one filesystem must hold the whole store (sandbox inputs are hard links). ext4 as bootstrap makes it is fine.
Network
Inside the tailnet the box speaks plain HTTP; WireGuard encrypts the path. ufw lets port 7447 in from tailscale0 only. Every request carries a token, so bootstrap.sh opens the port to the networks of SHIPPY_ALLOW_CIDRS only when SHIPPY_ALLOW_PLAIN_HTTP=1 says they may carry tokens unencrypted. If the port must be reachable from outside the tailnet, put Caddy in front with deploy/caddy/Caddyfile: clients require a public certificate for an https remote. Behind a proxy every client has the proxy's address, so set SHIPPYD_MAX_CONNECTIONS_PER_ADDRESS to SHIPPYD_MAX_CONNECTIONS (server configuration).
Configuration basics
shippyd config print-default # every key, its default and its variable
sudo shippyd config check # read the file as a start would, start nothing
sudo systemctl restart shippyd # apply a change to shippyd.toml- One file:
/etc/shippy/shippyd.toml. Then the environment (SHIPPYD_*, also from/etc/shippy/shippyd.env) overrides it, then command-line flags. - A wrong value is an error naming the key and where it came from, and shippyd does not start.
- Sandbox limits and remote-execution quotas are
SHIPPYD_*variables. Put them in the file's[env]table. - A change to the file needs a restart. Only the tokens file is followed live.
The settings you are most likely to touch:
| Setting | Default | What it does |
|---|---|---|
store.max_size | auto | How large the store may get. |
store.max_age | off (30d in the shipped file) | Also remove results nobody used for this long. |
exec.workers | half the cores | Sandboxed re-runs of claims at once. |
toolchains.install | none (["stable"] in the shipped file) | Toolchains anyone's recipe may have installed. Through a channel, an untrusted principal's recipe gets a toolchain near the newest one of that channel here (a stable release no older than two before it, a nightly or a beta of the twelve weeks before it), and not the last quarter of the places. |
SHIPPYD_SANDBOX_MEMORY | a quarter of memory, 2 to 32 GiB | Memory of one sandboxed run. Needs a cgroup per run. |
SHIPPYD_SANDBOX_CPUS | no limit | CPUs one run uses at once. Needs a cgroup per run. |
SHIPPYD_EXEC_SLOTS | one per core | Remote compilations at once. |
SHIPPYD_SCRIPT_JOBS | half the CPUs, 2 to 32 | Jobs a build script is told to start. Lower it on a box serving many builds at once. |
Every key is in Server config.
Tokens
sudo shippyd token add alice # a person: untrusted
sudo shippyd token add alice-laptop2 --expires 90d
sudo shippyd token add ci-main --role trusted # CI on protected branches only
sudo shippyd token add prometheus --role metrics # reads /metrics, nothing else
sudo shippyd token list
sudo shippyd token revoke alice # every token of the principal
sudo shippyd token revoke 3f9a1c2e # one token, by its idThe token is printed once, alone on standard output, so it can be piped into a secret store. Changes apply with the next request; no restart. A tokens file that cannot be parsed lets nobody in (alert ShippydTokensFileBroken). See Share a cache for what each role means.
Offboard someone with shippyd token revoke <principal>, and shippyd admin purge --principal <principal> if their claims should go too.
The warmer
shippy-warm builds every new commit of the branches it watches, through shippy, so the box has a commit's crates before people pull it.
sudo install -m 0640 -o root -g shippy-warm deploy/shippy-warm.toml /etc/shippy-warm/shippy-warm.toml
sudo shippyd token add warmer | sudo install -m 0640 -o root -g shippy-warm /dev/stdin /etc/shippy-warm/token
sudoedit /etc/shippy-warm/shippy-warm.toml
sudo -u shippy-warm shippy-warm config check --config /etc/shippy-warm/shippy-warm.toml
sudo systemctl enable --now shippy-warmOn the box, SHIPPY_WARM=1 bash deploy/bootstrap.sh creates its user, toolchains and unit; it can also run on a machine of its own.
- Put in
commandswhat people and CI actually run, with the same environment (for exampleSOURCE_DATE_EPOCH). A build warms another only when rustc is invoked the same way. - The code of a build can read the token it is built with. Give a repository whose code is not to act as the others' on the box a token of its own (
token_filein its[[repo]]). Each repository has its own local store either way. - It checks each branch every
poll(60 s) and whenPOST /triggersays so. One build runs at a time, in bubblewrap, from a fresh checkout. - A failed commit is retried after
retry_min, doubling up toretry_max.
curl -s 127.0.0.1:9448/status | jq '.targets | map_values({built, pending, failures, tip})'
sudo less /var/lib/shippy-warm/logs/tempo.main.logWatch it
- Metrics.
GET /metricson port 7447 needs a token of any role but a worker's; give the scraper its own--role metricstoken.127.0.0.1:9447serves the same page without a token. deploy/prometheus/scrape.yml has both, alerts.yml the rules, and deploy/grafana/shippy.json a dashboard. - Probes.
/healthzmeans the process answers./readyzmeans it can do its work: the store's directories exist, the tokens file parses, and the last sandbox self-test passed. The self-test compiles a crate at start and every five minutes. - Logs. stderr, to the journal. With
log.format = "json"every request is one JSON line. No token or file content is ever logged, and an event is one line: line breaks and terminal escape sequences in what a principal wrote (an output's name, a compiler's message) are written out, in the log and in whatshippyd adminprints.
journalctl -u shippyd -o cat | jq 'select(.level != "info")'
journalctl -u shippyd -o cat | jq 'select(.msg == "request" and .status >= 500)'Key metrics:
| Metric | Tells you |
|---|---|
shippyd_http_requests_total, shippyd_http_request_duration_seconds | Requests by route and status, and their latency. |
shippyd_verify_queue | Claims waiting for a re-run. Should drain between pushes. |
shippyd_principal_claims_waiting | Whose claims are waiting. |
shippyd_sandbox_runs_total | Sandbox runs by outcome. Only sandbox and io are the box's own fault. |
shippyd_sandbox_info | limits="cgroup" is good; "rlimit" means no memory or CPU limit. |
shippyd_exec_running, _waiting, _slots | Remote execution load. |
shippyd_store_bytes, shippyd_evicted_bytes_total | Store size and what eviction removed. |
Alerts
| Alert | Do |
|---|---|
ShippydDown | Builds compile locally everywhere. systemctl status shippyd, journalctl -u shippyd -n 100. Is the machine, tailnet or disk up? |
ShippydNotReady | curl 127.0.0.1:9447/readyz names which of store, tokens file or self-test. |
ShippydSandboxSelfTestFailing | journalctl -u shippyd | grep self-test. Usually bubblewrap's AppArmor profile, a missing toolchain, or a noexec store. See troubleshooting. |
ShippydErrors | Over 2% of answers are 5xx. "store error" means the disk. |
ShippydVerifyQueueDeep | Claims arrive faster than they are re-run. Raise exec.workers if cores are idle, else add cores. |
ShippydExecWaiting | Raise SHIPPYD_EXEC_SLOTS if cores are idle, else add cores. |
ShippydSandboxNotConfined | Runs have no cgroup or no system call filter. See troubleshooting. |
ShippydStoreNearFull | du -xsh /var/lib/shippy/*; lower store.max_size or store.evict_grace. |
ShippydEvictingYoungResults | More than the whole store is evicted per day. A bigger disk, or fewer branches warmed. |
ShippydVerificationMismatch | Usually a build that does not reproduce. One principal mismatching on crates everyone else matches is worth a look. |
ShippydTrustedDivergence | Two trusted sources disagree. Treat as a suspected poisoned result (below). |
ShippydTokensFileBroken | Every request gets 401. sudo shippyd config check says what is wrong. |
ShippyWarmDown, ShippyWarmFailing, ShippyWarmBehind | curl 127.0.0.1:9448/status; logs in /var/lib/shippy-warm/logs. |
Worker alerts are on the Workers page.
Troubleshooting
| Symptom | Fix |
|---|---|
/readyz is 503 and the journal says bubblewrap (bwrap) cannot create a sandbox on this machine | Ubuntu 24.04 restricts user namespaces to AppArmor-allowed programs. Check sudo aa-status | grep bwrap, and reload with sudo apparmor_parser -r /etc/apparmor.d/shippy-bwrap. No restart needed; the self-test retries within 30 seconds. Do not turn the sysctl off. |
collect2: fatal error: cannot find 'ld', or rustc says linker cc was not found | The sandbox finds programs only on /usr/local/bin:/usr/bin:/bin. Install build-essential; for -fuse-ld=lld or mold, Ubuntu's lld or mold package. Then restart shippyd. |
"limits":"rlimit" in /v1/stats, ShippydSandboxNotConfined | Runs have no memory or CPU limit. cat /proc/$(pidof shippyd)/cgroup should end in /shippyd.service/main. Start shippyd through the unit (Delegate=yes, DelegateSubgroup=main), or set SHIPPYD_CGROUP. |
no Rust toolchain, or claims fail with toolchain | The unit has ProtectHome=yes, so toolchains in /home do not exist for it. Install as the shippy user into /var/lib/shippy/rustup (command below). |
| CI's builds and people's builds do not restore each other's workspace crates | Cargo builds workspace crates without -C incremental when CI is set, and that flag is in the key. Build in CI with CARGO_INCREMENTAL=1. |
| A claim stays a claim | shippyd admin who --key <key> shows the reason. retry ones re-run at the next start (up to 3 times); final ones do not. To force a final one, remove its .failed file and restart. |
Install a toolchain for the service:
sudo -u shippy env RUSTUP_HOME=/var/lib/shippy/rustup CARGO_HOME=/var/lib/shippy/cargo \
/var/lib/shippy/cargo/bin/rustup toolchain install 1.100.0 --profile minimalCheck what the sandbox's C toolchain sees:
cd "$(mktemp -d)" && echo 'int main(void){return 0;}' > t.c
env -i PATH=/usr/local/bin:/usr/bin:/bin cc -fuse-ld=lld t.c -o t && ./tUpgrade and roll back
tar -xzf shippy-<tag>-x86_64-unknown-linux-gnu.tar.gz
sudo bash shippy-<tag>-x86_64-unknown-linux-gnu/deploy/upgrade.sh
sudo bash deploy/upgrade.sh --rollback # back to the release before
bash deploy/upgrade.sh --dry-run # what it would doupgrade.sh checks the configuration with the new shippyd first, installs the binaries beside the old ones, restarts and waits for /readyz. If the new release does not get ready, it puts the old one back.
- Builds go on during the restart: a wrapper that cannot reach the box compiles locally and uploads later. No client needs restarting.
- Upgrade the box first, then the clients. A release that changes
RECIPE_VERSIONmakes old results unreachable for new clients; expect a cold cache. - After a good upgrade, it keeps the running release, the one before and the three newest others (
SHIPPY_KEEP_RELEASES=<n>to change).
A suspected poisoned result
A client got something it should not have: a wrong .rlib, a binary that misbehaves, a mismatch alert that looks deliberate.
Find the key
On the affected machine, the newest session log in ~/.cache/shippy/sessions/ has a line per crate: hit-remote <crate> ... <pre-key> <action key>. The action key is the full key of the compilation.
Ask the box who produced it
sudo -u shippy shippyd admin who --key <action-key>"Trusted, by ci-main" means an upload served as it came: that token or its machine is the suspect. "Verified, by shippyd" means the box's own compilation: check the results its inputs came from, or suspect the sandbox. journalctl -u shippyd | grep <key> shows every request that named it.
Remove it and everything made from it
sudo -u shippy shippyd admin purge --key <action-key> --dry-run
sudo -u shippy shippyd admin purge --key <action-key>For a trusted source, admin purge --principal <name> removes everything it uploaded. If the sandbox is in doubt, add --include-verified, or stop shippyd and remove the store: it is a cache.
An untrusted principal cannot make its upload anyone else's result. A poisoned public result points at a trusted token, the box itself, or a bug; the first is by far the likeliest.
Capacity
- Store. One whole dev build's results: tempo 8.7 GB, reth 4.3 GB. Weekly growth is the growth of
shippyd_store_bytesplusincrease(shippyd_evicted_bytes_total[7d]). Size the disk so eviction removes less than the store per week. - Claims. A waiting claim keeps its outputs and inputs, so untrusted uploads cost about twice their size until verified.
- Sandbox runs.
exec.workersdefaults to half the cores. Ashippyd_verify_queuethat drains between pushes is enough; one that grows all day is not. - Memory. Sandbox compilers (a large crate's rustc takes several GB) plus the page cache that serves the store.
- Network. A cold build downloads its whole target directory.
- The warmer on the same machine takes cores from the sandbox. Move it to its own machine when
ShippydVerifyQueueDeepfires during its builds.
Backups
Do not back up the store. Every blob and result can be built again; a box that lost its store is slow for a day, not broken.
Back up these, where you keep other infrastructure config:
/etc/shippy/tokens.toml: losing it locks everyone out until tokens are handed out again. It holds hashes, so a copy lets nobody in./etc/shippy/shippyd.toml/etc/shippy-warm/
Go deeper
- docs/operations.md: sandbox limits, cgroups, threads, claim verdicts, what survives what, the unit's hardening
- deploy/bootstrap.sh
- deploy/shippyd.toml
- docs/threat-model.md