> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boxd.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Preview environments

> Every pull request gets its own machine, created from your golden image, live at its own URL.

Every pull request gets its own machine running the branch's exact code, live at a predictable `*.boxd.sh` URL that gets posted on the PR. Reviewers click it and land on a full Linux VM, so two reviewers can poke at two different PRs without stepping on each other's writes. Close the PR and the machine is destroyed.

The whole thing is two small SDK scripts on top of one primitive: creating a machine from a snapshot.

## How it works

You maintain a [golden image](/guides/golden-image): a snapshot of a machine with your app installed and running, re-saved on every push to main so it always reflects your latest code. That guide is the prerequisite for this page.

On every PR, a script creates a machine from the latest snapshot, named for the PR:

```bash theme={"theme":"github-dark"}
boxd machine new myapp-pr-482 --from-snapshot myapp-main
```

The machine wakes with the app already serving main's code, since a snapshot captures memory and disk together. The script then checks out the PR branch inside it, rebuilds what the diff needs, and the preview is live at `https://myapp-pr-482.boxd.sh`. On new pushes the machine is recreated from the latest snapshot, and because the name is reused, the URL stays stable across the PR's whole life. On close, one command removes it.

## The scripts

Two small scripts cover the full lifecycle. The recommended way to run them is through the [TypeScript](/reference/typescript-sdk) or [Python](/reference/python-sdk) SDK, which reads a `BOXD_API_KEY` from the environment and handles authentication for you.

Bring a preview up, or refresh it after a push:

<Tabs>
  <Tab title="TypeScript">
    ```typescript preview-up.ts theme={"theme":"github-dark"}
    import { Boxd } from "@boxd-sh/sdk";

    const [prNumber, branch] = process.argv.slice(2);
    const name = `myapp-pr-${prNumber}`;

    const boxd = new Boxd();                       // reads BOXD_API_KEY

    // Recreate from the latest golden snapshot. The reused name keeps the URL stable.
    await boxd.machines.delete(name).catch(() => {});
    const machine = await boxd.machines.create({
      name,
      fromSnapshot: "myapp-main",
      config: { autoSuspendTimeout: 300 },
    });
    await boxd.machines.waitUntilReady(machine.id);
    await boxd.machines.setAutoHibernateTimeout(machine.id, 1800);   // park on disk after 30 min idle

    await boxd.machines.exec(machine.id, {
      command: `cd ~/myapp && git fetch origin ${branch} \
        && git checkout -B preview FETCH_HEAD \
        && npm ci && sudo systemctl restart myapp`,
    });

    console.log(`Preview: ${machine.access.url}`);
    await boxd.close();
    ```
  </Tab>

  <Tab title="Python">
    ```python preview_up.py theme={"theme":"github-dark"}
    import sys
    from boxd import Boxd, NotFoundError

    pr_number, branch = sys.argv[1:3]
    name = f"myapp-pr-{pr_number}"

    with Boxd() as boxd:                       # reads BOXD_API_KEY
        # Recreate from the latest golden snapshot. The reused name keeps the URL stable.
        try:
            boxd.machines.delete(name)
        except NotFoundError:
            pass
        machine = boxd.machines.create(name, from_snapshot="myapp-main", auto_suspend_timeout=300)
        boxd.machines.wait_until_ready(machine.id)
        boxd.machines.set_auto_hibernate_timeout(machine.id, 1800)   # park on disk after 30 min idle

        boxd.machines.exec(machine.id, (
            f"cd ~/myapp && git fetch origin {branch} "
            "&& git checkout -B preview FETCH_HEAD "
            "&& npm ci && sudo systemctl restart myapp"
        ))
        print(f"Preview: {machine.access.url}")
    ```
  </Tab>
</Tabs>

Tear it down when the PR closes:

<Tabs>
  <Tab title="TypeScript">
    ```typescript preview-down.ts theme={"theme":"github-dark"}
    import { Boxd } from "@boxd-sh/sdk";

    const boxd = new Boxd();
    await boxd.machines.delete(`myapp-pr-${process.argv[2]}`).catch(() => {});
    await boxd.close();
    ```
  </Tab>

  <Tab title="Python">
    ```python preview_down.py theme={"theme":"github-dark"}
    import sys
    from boxd import Boxd, NotFoundError

    with Boxd() as boxd:
        try:
            boxd.machines.delete(f"myapp-pr-{sys.argv[1]}")
        except NotFoundError:
            pass
    ```
  </Tab>
</Tabs>

Swap the rebuild command (`npm ci && sudo systemctl restart myapp`) for whatever your stack needs. With the [GitHub integration](/guides/integrations/github) connected, the `git fetch` inside the preview machine works against private repos with nothing extra to configure.

## Wire it into CI

You can wire this into GitHub Actions (or any CI) with two small jobs: run the first script on `pull_request` opened, synchronize, and reopened, and the teardown on closed, passing the PR number and branch as arguments. Store the key as a repo secret first:

```bash theme={"theme":"github-dark"}
boxd auth keys create "previews" | gh secret set BOXD_API_KEY --repo you/myapp
```

Post the printed URL back on the PR with `gh pr comment`, and scope concurrency per PR number so rapid pushes don't race each other.

You can of course also run the boxd part on a boxd VM, for example on a self-hosted runner or a small webhook listener living there. Inside a machine, `new Boxd()` authenticates automatically, so the key disappears entirely and the scripts run unchanged.

## Patterns

### A stable URL per PR

The machine is named for the PR number, so each PR keeps `https://myapp-pr-<n>.boxd.sh` across every push. That makes it a steady target for screenshot diffs and Playwright runs, and reviewers keep refreshing the same link.

### Preview data without a staging database

Run a local database on the golden machine before you snapshot it. Every preview then inherits a copy of that data, and writes stay inside that one preview. Reset comes free with the next recreate.

### More than a URL

Each preview is a full machine you own. `ssh myapp-pr-482.boxd` gets you a shell in the exact environment the reviewer is looking at, and `boxd machine exec` lets other jobs run tests against it.

## FAQ

<AccordionGroup>
  <Accordion title="What does a preview cost while nobody is clicking it?">
    Very little, on two levels. The 5-minute auto-suspend freezes an idle preview in RAM at near-zero cost, and clicking the link resumes it in sub-millisecond time. The 30-minute auto-hibernate then parks it on disk, where it costs effectively nothing beyond storage, and the next click wakes it in about 85ms. A PR sitting open over the weekend costs effectively nothing.
  </Accordion>

  <Accordion title="What happens if 5 PRs get previews at once?">
    You get 5 machines, one per PR. The default cap is 50 machines per organization, extendable on request, and the golden machine counts toward it too.
  </Accordion>

  <Accordion title="How fresh is the code under the branch?">
    The preview is created from the latest golden snapshot, which your [refresh workflow](/guides/golden-image#3-refresh-it-on-every-push-to-main) re-saves on every push to main. The PR branch is then checked out on top, so the diff against main is exactly what the rebuild has to cover.
  </Accordion>

  <Accordion title="Why recreate instead of reusing the machine on every push?">
    Recreating from the snapshot gives every push a clean, known starting state for the price of one boot, and the reused name keeps the URL stable. If your rebuild is expensive and pushes are frequent, reuse the machine and run the checkout step alone.
  </Accordion>
</AccordionGroup>
