Deploy Surf for an internal team
This runbook deploys the current repository sessions, prompt queue, agent runtime, and web UI. It assumes trusted team members, one management machine, a private network, and x86-64 Linux hosts with KVM. Hosting provider, addresses, credentials, and capacity are deployment inputs; the example files are not ready to start.
The first deployment accepts a management-machine outage. Session records and timelines are durable, but the VM workspace is disposable: push commits to Git before idle release, expiry, or shutdown. There is no workspace restore. The default idle release is 30 minutes and the Surf session lifetime is four hours. All users share the deployment's repository credential and model account.
Layout
| Location | Process or data | Access |
|---|---|---|
| Management machine | nginx: Surf UI/API, scheduler API, static image files | HTTPS from team VPN and hosts |
| Management machine | Beachball scheduler on celld 0.6.0 | Worker 127.0.0.1:8787; internal 127.0.0.1:8788 |
| Management machine | Surf control plane and web assets on celld 0.6.0 | Worker 127.0.0.1:8790; internal 127.0.0.1:8791 |
| Management machine | One Surf gateway on Node 24, with bb | Outbound HTTPS and direct UDP to hosts |
| Each VM host | Packaged bb agent, Firecracker 1.17.0, jailer | Outbound scheduler HTTPS; UDP 51820 from gateway |
| Object storage | Separate scheduler and Surf buckets | Separate credentials scoped to each bucket |
| Build machine | Source checkout, image builder, signing keys, packages | No signing private keys on runtime machines |
The scheduler and Surf are two separate celld fleets. Never deploy them to the same bucket root. The templates use separate buckets. celld also supports separate prefixes. A single node per fleet waits for object storage on every acknowledged write; measure prompt and timeline latency during the canary. See celld storage and deployment.
Use a store celld qualifies, such as S3, R2, or Tigris. The development Silo
configuration is not a production default. Verify the chosen endpoint with
celld diagnose, including its write probe, before using it.
See celld storage guarantees.
Keep celld's unauthenticated internal listeners on loopback in this layout.
TLS terminates at nginx, which overwrites forwarded host and protocol headers;
celld must have CELLD_TRUST_FORWARDED_HEADERS=1. Otherwise scheduler requests
behind the proxy can fail its HTTPS check.
See celld security.
No relay service is needed when the gateway can reach each host's advertised
UDP endpoint. Set networking.relay.required to false and relays to [].
The gateway's user-space bb proxy needs neither root nor a host TUN interface.
Guest port 7700 stays inside the granted tunnel.
1. Choose the deployment inputs
Record these before provisioning:
- Management machine and host addresses, region, host OS, and KVM availability.
- Private routing from the gateway to every host, including UDP 51820.
- Three names with trusted TLS certificates:
surf,scheduler, andimages. They can resolve to the same management address. Hosts and the management machine must also resolve and reach them. - Two object-store buckets, region, endpoint, and scoped credentials; a separate backup destination and recovery procedure.
- One workload class and its concurrency budget. The current medium class uses 4 vCPUs, 8 GiB RAM, and 20 GiB scratch per VM. Reserve additional host resources for the OS, agent, image cache, and VM overhead.
- A repository credential for the allowed team repositories, one model provider account, and a model supported by the image's OpenCode runtime. The example uses a provider API key; change its guest environment variable for your provider.
- Working audit and alert receivers. The scheduler manifest's example URLs are placeholders; setting a URL does not install a receiver.
Use a new RFC 4193 ULA /48 consistently in the scheduler and hosts. Block guest
access to management networks, host control endpoints, cloud metadata, and
object-store control access using host policy and network firewalls. Preserve
guest access to DNS, GitHub, required package registries, and the model provider.
2. Build and record a release
Use a recorded source revision containing all intended changes. Build on Linux
x86-64 with the repository's Rust toolchain, Node 24, pnpm 11.18.0, Docker,
SquashFS tools, and the package-building prerequisites. Do not run
setup:firecracker, surf:prepare, surf:register, or the development launchers
on production machines: they manage development state and credentials.
From the checkout:
pnpm install --frozen-lockfile
pnpm check
pnpm test
pnpm --recursive build
cargo build --locked --release --manifest-path agent/Cargo.toml \
--target x86_64-unknown-linux-gnu --bin bb
RUSTFLAGS='-C target-feature=+crt-static' cargo build --locked --release \
--manifest-path agent/Cargo.toml --target x86_64-unknown-linux-gnu \
--package beachball-guestAlso run uv sync --frozen --extra dev and uv run pytest -q in surf/guest.
The web build writes surf/control-plane/public; it must finish before
deploying Surf. Preserve the built gateway with the release checkout at
/opt/beachball/releases/REVISION, owned by root. Point
/opt/beachball/current at that release when installing the gateway service.
Install and verify celld 0.6.0, record its SHA-256, and install it as
/usr/local/bin/celld on the management machine. Do not substitute the latest
celld release without checking compatibility. The local .tools/bin/celld
can be a build input after verifying its version and provenance.
Publish one Surf image
Build surf/image/Dockerfile with surf/guest as the context. Push it to a
registry accessible to the image builder, record its OCI digest, and scan that
digest. Use the scan policy for this release; the development script contains
dated vulnerability exceptions that require review.
With SURF_OCI_REF set to the digest reference, SURF_IMAGE_DIR to an absolute
output directory, and the kernel, guest binary, and image-signing key paths set:
sudo agent/target/x86_64-unknown-linux-gnu/release/bb image build "$SURF_OCI_REF" \
--kernel "$SURF_KERNEL" \
--guest "$SURF_GUEST_BINARY" \
--signing-key-file "$SURF_IMAGE_SIGNING_KEY" \
--output-dir "$SURF_IMAGE_DIR" \
--name surf-linux-x64 --wait-for-bootstrap --user surf --workdir /workspace \
--no-inputThe image key is 32 raw Ed25519 bytes or base64, kept in a mode-0600 file. It is
different from the PEM release-signing key and the scheduler connection key.
Use a pinned, verified Firecracker-compatible kernel; the repository's current
kernel pin is in agent/scripts/prepare-ubuntu-24.04.sh. That script builds an
Ubuntu image, so do not use its root filesystem as the Surf image.
bb image build emits a signed manifest containing local file URLs. Before
registering it for remote hosts:
-
Copy each kernel, rootfs, and initrd artifact to the HTTPS image server under an immutable path such as
/blobs/ARTIFACT_SHA256. Preserve its bytes. -
In a copy of the generated manifest, change each artifact's
urlto the correspondinghttps://images.example.com/blobs/ARTIFACT_SHA256. Leave size and SHA-256 unchanged. Setsignatureto an empty string. -
Sign the edited manifest and record the output digest and public key:
bb image sign "$SURF_UNSIGNED_MANIFEST" "$SURF_SIGNED_MANIFEST" \ --signing-key-file "$SURF_IMAGE_SIGNING_KEY" --json -
Publish the exact signed bytes as
/manifests/MANIFEST_SHA256.json, after the blobs are available. Usesurf-linux-x64@sha256:MANIFEST_SHA256as the workload class'simageRef. Changing a URL changes this digest. -
Verify HTTPS downloads and hashes from a VM host. Keep
allow_file_imagesdisabled. The image downloader has no HTTP credential-header configuration; restrict access through networking and use stable URLs.
The nginx template serves /srv/beachball-images for this purpose. A CDN or
object-store HTTPS origin can serve the same layout. Never upload private keys
or runtime credentials with the image files.
Package the host runtime
Complete the scheduler manifest in the next section, then fill
release-metadata.example.json with
the actual commit, versions, manifest hash, and runtime/image hashes. Install
cargo-audit, cargo-cyclonedx, and the native package tools required by
agent/scripts/build-native-packages.sh. Generate the report and package:
cargo audit --file agent/Cargo.lock --format json > scheduler/dist/cargo-audit.json
BEACHBALL_RELEASE_METADATA_FILE="$SURF_RELEASE_METADATA" \
BEACHBALL_RELEASE_SIGNING_KEY_FILE="$SURF_RELEASE_SIGNING_KEY" \
BEACHBALL_DEPLOYMENT_MANIFEST_FILE="$SURF_SCHEDULER_MANIFEST" \
bash agent/scripts/package.sh x86_64-unknown-linux-gnuAll three paths must be absolute; the signing key is an Ed25519 PEM file with
mode 0600. Resolve audit failures before packaging. Follow
package signature verification and installation on every
host and on the gateway machine, which needs bb but does not enroll as a host.
Packaging is not evidence that the live deployment passed the canary.
3. Prepare configuration and secrets
Copy these templates to a private configuration directory outside the checkout:
| Template | Installed destination |
|---|---|
| Scheduler manifest | Input to scripts/scheduler-deploy-config.mjs |
| Surf manifest | Input to surf/scripts/deploy-config.mjs |
| Scheduler environment | /etc/beachball/scheduler/runtime.env |
| Surf environment | /etc/beachball/surf/runtime.env |
| Gateway config | /etc/beachball/gateway/gateway.json |
| Gateway bb profile | /etc/beachball/gateway/bb.toml |
Replace all placeholders, dates, domains, and example identities. For the scheduler manifest:
- Keep
admissionsEnabled: falsefor initial setup. - Use one controller, for example tenant
internal-team, clientsurf, andallowedWorkloadClasses: ["surf-linux-x64-medium"]. - Use one matching class, with
allowedClients: ["surf"], the publishedimageRef,privateIngress: true,allowedNetworks: ["default"], andrequiredHostLabels: ["standard"]. Register hosts with--label standard; the current scheduler requires at least one host label in a class. - Give the class
maxCpuMillis,maxMemoryMiBandmaxEphemeralDiskMiBat least as large as Surf'sbeachball.machine.limits(maxVcpus× 1000 for CPU), so every machine size an administrator saves fits the class. Without them the class's maximums equal its own size and larger sessions are refused withRESOURCES_EXCEED_CLASS. - Set
allowedRegionsto the host region, and use that region for the Surf gateway in both manifests and the gateway config. - Leave Surf's
beachball.networkIdasnull. The scheduler creates the controller tenant's default network on its first allocation. - Set
networking.relay.required: falseandnetworking.relay.relays: []. - Bound controller and deployment allocation limits to the planned capacity.
Keep
maxActiveHostsabove the enrolled pool size: current scheduler readiness returnsACTIVE_HOST_LIMITat equality. This limit is not a desired host count. - Fill runtime metadata, retention, backup, audit, alert, and runbook settings.
Generate independent random bearer tokens for the scheduler operator, Surf
controller, Surf operator, and gateway. Hash the trimmed token bytes with
SHA-256 for each manifest's sha256; do not hash a trailing newline. Use
actual creation times and finite expiry dates, and record when to rotate. The
controller token is shared by Surf and the gateway's bb profile, so both act
as the same scheduler client.
People sign in to Surf with GitHub (spec section 5.1a):
- In the GitHub App Surf uses (or a separate OAuth App), add the callback URL
https://surf.example.com/api/auth/callback/github, grant the Email addresses account permission (read), and generate a client secret. Setauth.publicUrlto Surf's URL andauth.githubto the App's client ID andSURF_GITHUB_CLIENT_SECRET. - Generate
SURF_AUTH_SECRETwithopenssl rand -base64 48. It signs session cookies and encrypts stored OAuth tokens; rotating it signs everyone out. - List who may sign in in
auth.allow(github:<login>,email:*@example.com) and the administrators inauth.administrators. Give each manifest useridentities: ["github:<login>"]instead of a token. - Sign-in state lives in the
surf-authD1 database of Surf's fleet bucket; back it up with the bucket. The Worker creates its tables on first use. - For a script or CLI without a browser, an administrator operator mints an
API key:
POST /v1/users/{userId}/api-keyswith{ "name": "…" }.
To move an existing deployment from user tokens, add auth with
legacyTokens: true and the users' identities, let everyone sign in once,
then set legacyTokens: false and remove the users' sha256 fields.
Generate the scheduler's root secret with openssl rand -base64 32. Every
scheduler secret derives from it: internal authentication, the credential
issuer, the bootstrap credential, and the Ed25519 connection-signing key. Print
the matching connection verification key for the gateway bb profile and each
host's connection-verification key:
node --input-type=module -e '
import { readFileSync } from "node:fs";
import { connectionVerificationKey } from "./scripts/lib/derived-secrets.mjs";
const { keyId, x } = connectionVerificationKey(
readFileSync(process.argv[1], "utf8").trim());
console.log(`key_id = "${keyId}"\npublic_key = "${x}"`);
' /path/to/scheduler/root.secretThese are not the image-signing keys.
celld 0.5 and later have no variables file or secret store. Each app's Worker
variables are written into a private copy of its Wrangler config and uploaded
with celld deploy, so they are stored in that app's deployment record in its
bucket. Bucket access already controls the app's state, so this does not widen
who can act as it. Keep the variables files readable only by the operator who
deploys:
| App | Deploy-config input |
|---|---|
| Scheduler | --secret-file root secret, --manifest scheduler manifest, and --vars-file with every secret the manifest names by reference, such as AUDIT_EXPORT_TOKEN |
| Surf | --vars-file with SURF_MANIFEST as compact JSON and every secret it names: its own INTERNAL_OBJECT_SECRET, BEACHBALL_CLIENT_TOKEN, SURF_GIT_TOKEN, SURF_MODEL_API_KEY, SURF_AUTH_SECRET, SURF_GITHUB_CLIENT_SECRET |
Use the exact secret-reference names in your manifests. Each value must occupy
one KEY=value line. The Surf script warns about any referenced secret that the
file does not set. The API-key template runs opencode; select a real model and
the provider's matching environment variable in agent.credentialReferences.
For example, prepare surf.json and secret files named by the table under
secrets/scheduler/ and secrets/surf/. This writes new vars files beside the
manifests and refuses to overwrite existing files:
python3 - "$SURF_CONFIG_DIR" <<'PY'
import json
import os
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
for app, names, manifest in [
("scheduler", ["AUDIT_EXPORT_TOKEN"], None),
("surf", ["INTERNAL_OBJECT_SECRET", "BEACHBALL_CLIENT_TOKEN",
"SURF_GIT_TOKEN", "SURF_MODEL_API_KEY", "SURF_AUTH_SECRET",
"SURF_GITHUB_CLIENT_SECRET"], "surf.json"),
]:
values = {}
if manifest:
values["SURF_MANIFEST"] = json.dumps(
json.loads((root / manifest).read_text()), separators=(",", ":"))
for name in names:
value = (root / "secrets" / app / name).read_text().strip()
if not value or "\n" in value or "\r" in value:
raise ValueError(f"{app}/{name} must contain one nonempty value")
values[name] = value
fd = os.open(root / f"{app}.vars", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as output:
output.write("".join(f"{key}={value}\n" for key, value in values.items()))
PY4. Install the management services
Create system users beachball-scheduler, beachball-surf, and surf-gateway,
each with its own group and no interactive login. Create /etc/beachball/
subdirectories scheduler, surf, and gateway, owned by root with their
respective service groups and mode 0750. Install:
runtime.env: root-owned, mode 0600; systemd reads it.- The scheduler's root secret, manifest, and
scheduler.vars, and Surf'ssurf.vars: readable only by the operator who deploys, mode 0600. They are uploaded with each deployment rather than read by the services. - Gateway JSON, bb profile,
gateway.token, andbeachball-client.token: owned bysurf-gateway, mode 0600. The bb credential reader checks ownership. - celld template unit and
gateway unit: root-owned under
/etc/systemd/system/, mode 0644.
Confirm /usr/bin/node, /usr/bin/bb, /usr/local/bin/celld, and the gateway
release path exist. The units create private persistent state directories under
/var/lib/. The gateway needs no Linux capabilities.
Install the nginx template, using your names
and certificate paths. Apply the network restrictions from the layout section,
including access for the management machine itself. Verify with nginx -t
before reloading nginx. The proxy settings preserve long polls and streamed
responses; see nginx proxy documentation.
Deploy from the release checkout using the storage credentials for each app.
In each command, CELLD_BUCKET, S3_ENDPOINT, AWS_REGION, and AWS credentials
must identify that app's bucket. Supply them through your secret system; the
systemd environment files are not shell scripts to source.
# Scheduler storage environment
CELLD_ESBUILD="$PWD/scheduler/node_modules/.bin/esbuild" \
/usr/local/bin/celld diagnose --bucket "$CELLD_BUCKET" \
--endpoint "$S3_ENDPOINT" --region "$AWS_REGION"
# The root secret, manifest, and referenced secrets travel in the deployed
# config's `vars`.
deploy_config="$(node scripts/scheduler-deploy-config.mjs \
--secret-file /path/to/scheduler/root.secret \
--manifest /path/to/scheduler/manifest.json \
--vars-file /path/to/scheduler.vars)"
CELLD_ESBUILD="$PWD/scheduler/node_modules/.bin/esbuild" \
/usr/local/bin/celld deploy "$deploy_config" \
--bucket "$CELLD_BUCKET" --endpoint "$S3_ENDPOINT" --region "$AWS_REGION"
rm -f -- "$deploy_config"
# Switch to the Surf storage environment before these commands.
/usr/local/bin/celld diagnose --bucket "$CELLD_BUCKET" \
--endpoint "$S3_ENDPOINT" --region "$AWS_REGION"
deploy_config="$(node surf/scripts/deploy-config.mjs \
--vars-file /path/to/surf.vars)"
CELLD_ESBUILD="$PWD/surf/control-plane/node_modules/.bin/esbuild" \
/usr/local/bin/celld deploy "$deploy_config" \
--bucket "$CELLD_BUCKET" --endpoint "$S3_ENDPOINT" --region "$AWS_REGION"
rm -f -- "$deploy_config"
sudo systemctl daemon-reload
sudo systemctl enable --now beachball-celld@scheduler beachball-celld@surf
sudo systemctl enable --now surf-gatewaydiagnose may report no deployed application or active nodes on an empty
bucket; its storage checks must pass. Repeat it after startup. Inspect logs:
sudo journalctl -u beachball-celld@scheduler -u beachball-celld@surf \
-u surf-gateway --since '10 minutes ago'
curl --fail https://scheduler.example.com/v1/health/live
curl --fail https://surf.example.com/v1/health/liveReadiness is expected to fail while admissions are disabled. Do not use
/v1/health/ready to remove the scheduler from proxy routing: operators need
access while admissions are closed. Use /.well-known/celld/health for runtime
health, and monitor application readiness separately.
5. Enroll one canary host
Install the verified host package. Create a scheduler operator bb profile with
audience = "operator", the scheduler HTTPS endpoint, and the operator token
file. From that profile, issue a short-lived, host-bound token:
bb --profile operator host enrollment-token create host-01 \
--expires-at "$SURF_ENROLLMENT_EXPIRES_AT" --reason 'initial Surf host' \
--token-output "$SURF_ENROLLMENT_TOKEN_FILE"Transfer the token securely to the host. Follow host setup,
using the actual capacity, image URL and public key, ULA prefix, management
blocks, and connection-verification key. Set
--connectivity-listen-endpoint PRIVATE_HOST_IP:51820 to a specific reachable
address. Direct private networking does not require a candidate-discovery
server. Do not advertise 0.0.0.0 as the reachable address.
Run bb agent doctor, start beachball-agent.service, and inspect its logs.
Enrollment starts the host draining. When healthy, activate only this host:
bb --profile operator host activate host-01 --reason 'Surf canary'Set scheduler admissionsEnabled to true in a new reviewed manifest, with a
small allocation limit and only the canary user enabled in Surf. Worker
variables live in the deployment, so redeploy each changed app with its
deploy-config script as in section 4, then restart its unit:
sudo systemctl restart beachball-celld@scheduler beachball-celld@surf
curl --fail https://scheduler.example.com/v1/health/ready
curl --fail https://surf.example.com/v1/health/readyHost activation and admission must precede an ordinary canary allocation. A draining host or a scheduler with admissions disabled cannot run that test.
6. Pass the launch checks
Run this against the intended host kernel, private network, and model provider:
- Sign in with GitHub as the canary user, select an allowed repository, and submit a small real task. Verify clone, VM boot, model output, and timeline streaming.
- Ask the agent to commit and push a test branch. Verify the branch in the remote repository before releasing the VM.
- Submit a follow-up prompt in the same live session. Then exercise stop and release. Confirm allocation cleanup and restored host capacity.
- Restart the gateway during a disposable task. Check tunnel recovery and timeline continuity. Kill a disposable VM and verify an explicit failure, cleanup, and the ability to start another session.
- Restart each celld service. Verify existing session records, fresh gateway polling, and new session creation. A management outage can end a running workload when leases expire; do not expect workspace recovery.
- Verify rejected credentials, a GitHub account outside
auth.allowbeing turned away, access restrictions, audit delivery, alert delivery, and host disk/capacity monitoring. Check CPU, RAM, disk, and prompt latency at the intended concurrency. - Back up the configuration, keys, and both celld fleets using the chosen provider's recovery procedure. Restore into isolation with admissions off and production hosts unreachable. Verify records and record recovery time.
Enable the remaining user identities, enroll and activate the remaining hosts, and raise the reviewed concurrency limit only after these checks pass. Keep a record of the revision, image digest, package signatures, checks, and operator.
The existing local E2E report does not establish these results for this deployment. The broader MPR verification and release evidence describe the full production qualification; several runners still require infrastructure hooks.
Portals (optional)
Portals serve a session's sandbox port at a public URL, like Amp's. They run on Beachball public Services and an edge (ADR 0005). They need a separate registrable domain for portals, never a subdomain of Surf's own, and a small edge node with a public address.
-
Scheduler manifest. Add the edge and the domain:
edges: [{edgeId, domains: ["portals.example.com"], sha256, …}];edgeDomains: [{domain, tenantId: "<Surf's tenant>", access: {loginUrl, keys, identityHeaderPrefix: "x-surf-"}, publicWarning: true}];publicIngress: trueon the Surf workload class.
loginUrlishttps://<surf>/portal-login.keysholds the base64 public half of Surf's portal signing key. -
Surf manifest. Add
portals: {domain, keyId, signingKeyReference: "SURF_PORTAL_SIGNING_KEY"}. Put the Ed25519 PKCS#8 private key, base64 without PEM armor, in that secret. -
Edge node.
- Install
bb, thendeploy/beachball-edge.service,deploy/edge.example.tomlas/etc/beachball/edge.toml, anddeploy/edge-bb.example.tomlas/etc/beachball/edge-bb.toml. - Run
bb edge dns-synconce to point*.<domain>at the node. - Start the service. It obtains the wildcard certificate through ACME DNS-01 on first start.
- Run
bb relayon the same node, and list it in the relay map. Hosts behind NAT then reach the edge without inbound ports.
- Install
-
Public Suffix List. Submit the portal domain, so portals cannot share cookies with each other. Until it lands, the edge's cookies are host-only.
Check: open a session, ask the agent to start a dev server and share a portal
(surf-portal open <port>), and open the link. You should go through
/portal-login and see the app.
Updates and recovery
Deploy Worker code with celld deploy from a tested checkout. Rebuild web assets
before the Surf deploy. Update gateway code by switching the release path and
restarting surf-gateway. Change a manifest or secret by regenerating the
deploy config, redeploying, and restarting the matching beachball-celld@
unit; celld 0.5 and later have no variables file to reload.
Before a disruptive update, disable admissions and let active work finish or save its commits. Drain each host and verify no placements remain before an agent upgrade. Keep the prior signed image available for new allocations if the new image fails.
Roll back application code only when it understands the current stored schema. The Surf API cleanup removes schema columns, so do not assume a build from before that change can read a new database. Never restore a backup over a live fleet. A backup destination in the manifest does not create a backup job.
If measured availability or write latency requires another management machine, add a second node to each celld fleet, using each fleet's existing bucket and reachable private peer addresses. Move the internal listeners off loopback only onto a trusted network. Two application processes on the same machine do not provide redundancy for either fleet. Recheck gateway recovery and routing as part of that change.