WEBINARSandboxes for AI agents, Oct 7th.Sandboxes: give your AI agents a real machine. Live on October 7th.Register free

Branch preview environments with Buddy Sandboxes

You can review a PR by running it locally, but that often means extra work: setting up the whole environment, switching between branches, cleaning up afterwards. The alternative is reading the diff and trusting that the author checked it on their machine before sending it for review.

A sandbox in Buddy is a full machine with a public HTTP address, so every branch can get its own copy of the application. The sandbox follows the branch lifecycle:

  • a push creates it,
  • the next push updates it,
  • deleting the branch removes it,
  • a timeout puts inactive previews to sleep.

The command below creates a single sandbox that serves as the base. You take a snapshot of it and two pipelines do the rest: preview-up on push and preview-delete on branch deletion.

bash
bdy sb create --yaml @app-base.yml -p sandboxes-preview-envs $

Prerequisites

Base sandbox: a machine where the app already runs

Start with one sandbox described in YAML. The application lives in the project repository. In our example, it is a simple Dispatch Board in Node that listens on 0.0.0.0:3300 and shows the branch and commit in a banner. For a different stack you only swap build_command and the command in apps:

yaml
# app-base.yml - sandbox: app-base name: App base os: ubuntu:24.04 resources: 2x4 tags: - base app_dir: /buddy apps: - node server.js endpoints: - www: 0.0.0.0:3300 fetch: - ref: main path: /buddy build_command: npm ci --omit=dev

fetch pulls the project repository into /buddy and runs npm ci there, apps starts the server, and endpoints exposes port 3300 on a public *.buddy.app address. The sandbox comes with node, docker, go, python, git and bdy preinstalled, so there is no provisioning here.

Info

You can keep the sandbox definition in a YAML file.

The same settings can be passed as flags (--tag base --app-dir /buddy --fetch ...). The advantage of app-base.yml is that it sits in the repo next to the code, so every change is in git history, and you can update an existing machine with bdy sb update app-base @app-base.yml.

Snapshot: a preview boots in seconds

Freeze the base machine into a snapshot so every preview starts from a ready image: a configured system with a warm npm cache. The preview only pulls the code from its own branch:

bash
bdy sb snap create app-base -n dispatch-board-base --wait $

Pipeline preview-up: a push brings up the environment

One pipeline on the PUSH event handles every branch except main. It deletes the previous preview of that branch, creates a new one from the snapshot and posts the link to Slack. In spec, the endpoint uses the full form with a name, address, and region. In the sandbox file, the shorthand www: 0.0.0.0:3300 was enough:

yaml
# preview-up.yml - pipeline: preview-up refs: - "refs/heads/*" events: - type: PUSH refs: - "refs/heads/*" trigger_conditions: - trigger_condition: VAR_IS_NOT trigger_variable_key: BUDDY_RUN_BRANCH trigger_variable_value: main actions: - action: Drop the previous preview type: SANDBOX_MANAGE operation: DELETE ignore_errors: true targets: - "preview-${BUDDY_RUN_BRANCH}" - action: Build the preview type: SANDBOX_CREATE from: SNAPSHOT snapshot_name: dispatch-board-base sandbox_identifier: "preview-${BUDDY_RUN_BRANCH}" spec: sandbox: "preview-${BUDDY_RUN_BRANCH}" name: "Preview ${BUDDY_RUN_BRANCH}" timeout: 900 tags: - preview apps: - node server.js endpoints: - name: www endpoint: 0.0.0.0:3300 region: US fetch: - path: /buddy ref: "${BUDDY_RUN_BRANCH}" build_command: npm ci --omit=dev - action: Post the preview link type: SLACK integration: slack channel: general content: "Preview for `${BUDDY_RUN_BRANCH}` at ${BUDDY_RUN_COMMIT_SHORT}: ${BUDDY_SANDBOX_URLS}"

Before you save the file, replace integration and channel in the Slack action with values from your workspace. Then create the pipeline from that file with a single command:

bash
bdy pip create --yaml @preview-up.yml -p sandboxes-preview-envs $

A commit on the dispatch-eta branch moves the ETA of one shipment by half an hour. The run finishes in 9 seconds and shows it was triggered by the push, not by hand:

Image loading...Run of the preview-up pipeline marked TRIGGERED VIA GIT PUSH, three green actions: Drop the previous preview, Build the preview, Post the preview link

The public link from the action leads to the application from that branch. The banner tells you exactly what you are looking at:

Image loading...Dispatch Board on the dispatch-eta preview branch, banner with commit 1b6e2e8, ETA column showing Tue 07:30 for the shipment to Innsbruck

Info

Delete and recreate, do not update.

update_if_exists: true in SANDBOX_CREATE only reconfigures the machine, it does not pull new code, so the preview stays on the old commit. That is why the first action deletes the previous environment (ignore_errors: true covers the first push on a branch). This also applies to further commits on the same branch: the preview is deleted on every push, which at 9 seconds of boot time from a snapshot is cheaper than policing drift. With a heavier build (Next.js, Laravel with composer) or data in a database the math flips: then you create the sandbox without fetch and let the pipeline push the code on every push with the DEPLOY_TO_SANDBOX action.

Info

One event handles the entire preview lifecycle

A push to a new branch fires CREATE_REF and PUSH at the same time. If you build two pipelines, one per event, both try to create the same sandbox. A single pipeline on PUSH handles both the first push and every one after it.

Do not build the preview address by hand. The host gets shortened and salted when the name is long, and the salt changes every time the environment is recreated. SANDBOX_CREATE returns ready-made values:

BUDDY_SANDBOX = "preview-dispatch-eta" BUDDY_SANDBOX_NAME = "Preview dispatch-eta" BUDDY_SANDBOX_TAGS = "preview" BUDDY_SANDBOX_SSH_URL = "preview-dispatch-eta-wea9m.us-1.shr.io:14532" BUDDY_SANDBOX_URLS = "https://www-preview-dispatch-eta-sandboxes-preview-envs-buddy-mar-lw1mi.us-1.buddy.app"

In the dashboard you will find the same address in the Tunnels section of the sandbox:

Image loading...Row of the www tunnel in the preview sandbox: port 3300, Online status and a public buddy.app address

Info

Variables this pipeline uses.

BUDDY_RUN_BRANCH and BUDDY_RUN_COMMIT_SHORT are default variables of every run. All five BUDDY_SANDBOX* values are the output of the action that creates the sandbox, available to the following actions in the same run. Watch out for branch names with a slash: the sandbox identifier is derived from the branch name, so feature/eta will not work.

A second branch is a second environment

The same pipeline handles any number of branches. Each one gets its own machine, its own address and its own data:

Image loading...Dispatch Board on the weight-column preview branch, a Total weight tile with a sum of 4,655 kg instead of the Warehouse tile

The sandbox list then shows the base machine with the base tag and the previews with the preview tag:

Image loading...Project sandbox list: App base with the base tag, plus Preview dispatch-eta and Preview who-is-watching with the preview tag, each with the node server.js command

The settings of a single preview show exactly what it inherited from the snapshot and what the pipeline added:

Image loading...Preview sandbox settings: fetch from the branch into /buddy, app command node server.js, tunnel on port 3300 with Online status, preview tag and a sandbox timeout of 900 seconds

A forgotten preview goes to sleep on its own

timeout: 900 in the sandbox spec takes care of previews everyone forgot about. After 15 minutes without traffic the machine goes to STOPPED and stops consuming resources, while its disk stays. You do not have to wake it up: the first visit to the preview address wakes the sandbox automatically. In our case the response from a sleeping environment arrived in under 5 seconds:

bash
$ bdy sb status preview-dispatch-eta Status STOPPED $ curl https://www-preview-dispatch-eta-sandboxes-preview-envs-buddy-mar-lw1mi.us-1.buddy.app/health {"status":"ok","branch":"dispatch-eta","commit":"1b6e2e8"} $ bdy sb status preview-dispatch-eta Status RUNNING $$$$$$$$
Info

A timeout stops, it does not delete.

Traffic on the endpoint resets the countdown, so an environment someone is actually using will not fall asleep mid-review. After a timeout the sandbox stays on the list with STOPPED status, so its identifier is still taken. The next push to that branch recreates the environment anyway, because the pipeline starts with SANDBOX_MANAGE and the DELETE operation. A preview disappears for good only when the branch is deleted.

The branch goes, the environment goes too

Merging or abandoning a branch ends the life of its preview. The DELETE_REF event triggers the second pipeline:

yaml
# preview-delete.yml - pipeline: preview-delete refs: - "refs/heads/*" events: - type: DELETE_REF refs: - "refs/heads/*" actions: - action: Delete the preview type: SANDBOX_MANAGE operation: DELETE ignore_errors: true targets: - "preview-${BUDDY_RUN_BRANCH}" - action: Post the cleanup note type: SLACK integration: slack channel: general content: "Branch `${BUDDY_RUN_BRANCH}` is gone, its preview sandbox was deleted."

The Slack action has the same two fields to replace. You create the pipeline the same way: bdy pip create --yaml @preview-delete.yml -p sandboxes-preview-envs. Deleting the weight-column branch takes the pipeline a second:

Image loading...Run of the preview-delete pipeline marked TRIGGERED VIA DELETE BRANCH, green actions Delete the preview and Post the cleanup note

Takeaways

  • An environment is an event. A push creates the preview, DELETE_REF removes it. Nobody clicks, nobody cleans up.
  • The snapshot does the work. Set up once, on the base machine. A preview starts from an image with a warm npm cache.
  • Take the address from the output. BUDDY_SANDBOX_URLS shows the real host, a hand-built one breaks as soon as the name gets shortened.

Docs: sandbox configuration, endpoints, lifecycle, yaml-sandbox, plus the Create new sandbox and Manage sandbox actions. Such a preview is public, which is often unacceptable on a client project.

Jarek Dylewski

Jarek Dylewski

Customer Support

A journalist and an SEO specialist trying to find himself in the unforgiving world of coders. Gamer, a non-fiction literature fan and obsessive carnivore. Jarek uses his talents to convert the programming lingo into a cohesive and approachable narration.

Sep 28, 2026
Share