Skip to content
shippy

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

ThingWhereNotes
shippyduser shippy, unit shippyd.serviceport 7447: the API, /healthz, /readyz, /metrics (with a token)
The store/var/lib/shippy/storeblobs, results, claims, packs, sandbox scratch space
Toolchains/var/lib/shippy/rustupwhat the sandbox compiles with
Configuration/etc/shippy/shippyd.tomlroot writes it, the shippy group reads it
Tokens/etc/shippy/tokens.tomlhashes only; the one file that is not a cache
Local metrics127.0.0.1:9447/metrics, /healthz, /readyz without a token
shippy-warmthe box or another machine, user shippy-warm127.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 workeroptional worker machinessee 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.sh

It 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.

Check it is ready

curl 127.0.0.1:9447/readyz

/readyz is the proof the install works: it is true only after shippyd compiled a small crate in its sandbox, under the unit as installed. If bootstrap ends with "not ready", the journal lines it prints say why.

Hand out tokens and point Prometheus at it

See Tokens and Watch it below.

The bootstrap variables
VariableDefaultWhat it does
SHIPPY_TOOLCHAINSstableToolchains to install for the sandbox, separated by spaces.
SHIPPY_STORE_DEVICEthe root filesystemA block device for /var/lib/shippy. Formatted only when it holds no filesystem.
SHIPPY_PORT7447The port shippyd serves on.
SHIPPY_ALLOW_CIDRSnoneNetworks besides the tailnet that may reach the port, in plain HTTP. Refused without SHIPPY_ALLOW_PLAIN_HTTP=1.
SHIPPY_ALLOW_PLAIN_HTTP=1offSays the networks of SHIPPY_ALLOW_CIDRS may carry tokens unencrypted (another VPN, a private link).
SHIPPY_SSH_CIDRSanywhereNetworks that may reach SSH.
TS_AUTHKEYnoneA Tailscale auth key. Without it, run tailscale up by hand.
TS_HOSTNAMEthe hostnameThe machine's name in the tailnet.
SHIPPY_WARM=1offAlso set up the warmer on this machine.
SHIPPY_ROLE=workerboxMake the machine a worker instead (Workers).
SHIPPY_BIN_DIR../bin beside the scriptWhere the binaries are.
SHIPPY_SKIP_FIREWALL=1, SHIPPY_SKIP_TAILSCALE=1offLeave 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:

SettingDefaultWhat it does
store.max_sizeautoHow large the store may get.
store.max_ageoff (30d in the shipped file)Also remove results nobody used for this long.
exec.workershalf the coresSandboxed re-runs of claims at once.
toolchains.installnone (["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_MEMORYa quarter of memory, 2 to 32 GiBMemory of one sandboxed run. Needs a cgroup per run.
SHIPPYD_SANDBOX_CPUSno limitCPUs one run uses at once. Needs a cgroup per run.
SHIPPYD_EXEC_SLOTSone per coreRemote compilations at once.
SHIPPYD_SCRIPT_JOBShalf the CPUs, 2 to 32Jobs 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 id

The 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-warm

On 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 commands what people and CI actually run, with the same environment (for example SOURCE_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_file in its [[repo]]). Each repository has its own local store either way.
  • It checks each branch every poll (60 s) and when POST /trigger says so. One build runs at a time, in bubblewrap, from a fresh checkout.
  • A failed commit is retried after retry_min, doubling up to retry_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.log

Watch it

Loading diagram...
  • Metrics. GET /metrics on port 7447 needs a token of any role but a worker's; give the scraper its own --role metrics token. 127.0.0.1:9447 serves the same page without a token. deploy/prometheus/scrape.yml has both, alerts.yml the rules, and deploy/grafana/shippy.json a dashboard.
  • Probes. /healthz means the process answers. /readyz means 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 what shippyd admin prints.
journalctl -u shippyd -o cat | jq 'select(.level != "info")'
journalctl -u shippyd -o cat | jq 'select(.msg == "request" and .status >= 500)'

Key metrics:

MetricTells you
shippyd_http_requests_total, shippyd_http_request_duration_secondsRequests by route and status, and their latency.
shippyd_verify_queueClaims waiting for a re-run. Should drain between pushes.
shippyd_principal_claims_waitingWhose claims are waiting.
shippyd_sandbox_runs_totalSandbox runs by outcome. Only sandbox and io are the box's own fault.
shippyd_sandbox_infolimits="cgroup" is good; "rlimit" means no memory or CPU limit.
shippyd_exec_running, _waiting, _slotsRemote execution load.
shippyd_store_bytes, shippyd_evicted_bytes_totalStore size and what eviction removed.

Alerts

AlertDo
ShippydDownBuilds compile locally everywhere. systemctl status shippyd, journalctl -u shippyd -n 100. Is the machine, tailnet or disk up?
ShippydNotReadycurl 127.0.0.1:9447/readyz names which of store, tokens file or self-test.
ShippydSandboxSelfTestFailingjournalctl -u shippyd | grep self-test. Usually bubblewrap's AppArmor profile, a missing toolchain, or a noexec store. See troubleshooting.
ShippydErrorsOver 2% of answers are 5xx. "store error" means the disk.
ShippydVerifyQueueDeepClaims arrive faster than they are re-run. Raise exec.workers if cores are idle, else add cores.
ShippydExecWaitingRaise SHIPPYD_EXEC_SLOTS if cores are idle, else add cores.
ShippydSandboxNotConfinedRuns have no cgroup or no system call filter. See troubleshooting.
ShippydStoreNearFulldu -xsh /var/lib/shippy/*; lower store.max_size or store.evict_grace.
ShippydEvictingYoungResultsMore than the whole store is evicted per day. A bigger disk, or fewer branches warmed.
ShippydVerificationMismatchUsually a build that does not reproduce. One principal mismatching on crates everyone else matches is worth a look.
ShippydTrustedDivergenceTwo trusted sources disagree. Treat as a suspected poisoned result (below).
ShippydTokensFileBrokenEvery request gets 401. sudo shippyd config check says what is wrong.
ShippyWarmDown, ShippyWarmFailing, ShippyWarmBehindcurl 127.0.0.1:9448/status; logs in /var/lib/shippy-warm/logs.

Worker alerts are on the Workers page.

Troubleshooting

SymptomFix
/readyz is 503 and the journal says bubblewrap (bwrap) cannot create a sandbox on this machineUbuntu 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 foundThe 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, ShippydSandboxNotConfinedRuns 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 toolchainThe 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 cratesCargo 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 claimshippyd 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 minimal

Check 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 && ./t

Upgrade 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 do

upgrade.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_VERSION makes 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.

Stop the source

sudo shippyd token revoke <principal>

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.

Clean the clients and rotate secrets

On each machine that restored it: rm -rf ~/.cache/shippy and cargo clean. Then rotate the trusted token and the CI secrets of the machine that held it.

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_bytes plus increase(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.workers defaults to half the cores. A shippyd_verify_queue that 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 ShippydVerifyQueueDeep fires 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