Sandboxes from GitHub (Beta)
Build your app's sandboxes from a connected GitHub repository, with a preview for every pull request.
Connect a GitHub repository to an application and Cofactor builds its sandboxes from source: the cofactor.sandbox.yaml in the repository describes how to install the toolchain, build the app, start it and seed it. Every commit is a version you can claim, and every pull request gets its own preview sandbox, kept at the pull request's head.
If your team doesn't give Cofactor access to its repository, push built images instead: see Sandboxes from a pushed image. Everything about running, preparing and seeding a sandbox is the same in both; this guide covers what's specific to GitHub.
The examples use one invented app: Project Tracker, a project tracker with a web front end on port 3000, an api server on port 3001 and Postgres. Substitute your own.
Sandboxes v2 is enabled per organization. If
cofactor sandbox envsanswers "Sandboxes v2 is not enabled for this organization", ask us to turn it on.
Don't want to read this? Copy this prompt and give it to your agent. It checks the CLI and your sign-in, reads the sandbox guide, asks you how you want it set up, then builds and proves a sandbox:
Or read on to do it yourself.
01How it works
A sandbox is a VM restored from a stack of snapshot layers, not a container stood up from scratch for every claim. The expensive work happens once and is reused:
| Layer | What it holds | Rebuilt when |
|---|---|---|
| Toolchain | Your setup script's packages, runtimes and database clusters, on the declared resources and ports | setup, resources or ports change |
| Repo base | The repository checked out and built at a commit (lifecycle.build) | Per commit, rerunning only the build steps whose inputs changed |
| Ready snapshot | One commit and one environment: prepared, seeded, captured with its services running | Built once, the first time anything asks for that commit and environment |
Every claim is a copy of a ready snapshot. "The same commit, again" is nearly free, and a new commit reruns only the build steps whose inputs changed.
02Before you start: connect GitHub
- In Settings → GitHub, install the Cofactor GitHub App and grant it access to the repository.
- Have an application in Cofactor for the app (Applications).
Connecting fails with "Connect GitHub before browsing or claiming Sandboxes v2 definitions" until the app is installed.
03Step 1 — Write cofactor.sandbox.yaml
Put it at the repository root, or under the app's own directory in a monorepo. A GitHub-delivered definition has everything a pushed image's has, plus two things that build the checkout: setup and lifecycle.build.
version: 1
resources: { vcpus: 2, memory_mib: 4096 }
setup: |
apt-get update -qq
apt-get install -y -qq postgresql-16 nodejs npm jq curl >/dev/null
ports:
web: { port: 3000, access: public }
api: { port: 3001, access: public }
params:
web_url: { port: web }
api_url: { port: api }
session_secret: { secret: true }
env:
DATABASE_URL: postgres://app@127.0.0.1:5432/app
PUBLIC_URL: ${params.web_url}
API_URL: ${params.api_url}
SESSION_SECRET: ${params.session_secret}
services:
db:
run: exec sandbox/start-postgres.sh
health: { command: pg_isready -h 127.0.0.1 }
api:
run: cd api && exec npm start
health: { http: 3001, path: /health }
after_build: true
reload_on_prepare: true
web:
run: cd web && exec npm run serve
health: { http: 3000, path: / }
after_build: true
reload_on_prepare: true
personas:
owner: { email: owner@example.test }
member: { email: member@example.test }
viewer: { email: viewer@example.test }
lifecycle:
build:
- name: install
run: npm ci
inputs: [package-lock.json, "**/package.json"]
- name: build
run: npm run build
inputs: ["web/**", "api/**"]
prepare:
- bash sandbox/personas.sh
actions:
create-workspace:
description: A new workspace, created by the given persona, who becomes its owner.
run: sandbox/actions/create-workspace.sh
input:
name: { type: string, required: true }
as: { type: persona, required: true, description: Who creates it }
output:
workspace_id: { type: string }
environments:
default:
description: |
Workspace "Acme", owned by the owner persona. No projects yet;
create them with create-project.
seeds:
- { id: acme, action: create-workspace, input: { name: Acme, as: owner } }
Validate it before every commit that changes it. It's offline and instant:
cofactor sandbox validate
Without --upload, validate checks a definition built from a GitHub checkout: setup and at least one lifecycle.build step are required.
setup
A shell script, run as root, that installs the toolchain: apt packages, language runtimes, database clusters. It runs once per toolchain layer. Changing its text, resources or ports builds a new toolchain; nothing else does.
Before your setup runs, Cofactor installs what it needs itself to clone and fetch: git, ca-certificates, curl and openssl.
lifecycle.build
Ordered steps that build the checkout into the repo base. Each has:
name: unique, lowercase letters, digits and-;run: a shell command;inputs: git pathspecs over tracked files (glob patterns such as**/package.jsonwork), relative to the definition's directory.
A step reruns only when the files its inputs match changed since the layer it builds on, or when an earlier step ran. Precise inputs are what make a new commit cheap: if install lists only the lockfile and manifests, a source-only change skips it.
Build steps run once per commit, before any environment is chosen, so an environment's env: doesn't exist at build time. A value your build bakes into its output (a front-end bundle's API URL or feature flags) can't vary by environment: read it at serve time instead, or set it with a seed action. Build steps see params at layer values: a { port } param is http://127.0.0.1:<port>, and a { secret } param is a placeholder.
after_build
after_build: true holds a service back until lifecycle.build has produced its output (here, api and web need the build; db doesn't). It applies only to GitHub-delivered definitions.
Where commands run
Commands run from the definition's directory: an action's run: path, build steps and services resolve relative to where cofactor.sandbox.yaml lives, not the repository root.
The rest of the file
Personas, prepare, actions, seeds and environments work exactly as for a pushed image. Read these parts of Sandboxes from a pushed image for the details:
- Make persona logins work:
COFACTOR_PERSONAS, and reading logins per request; - Add actions and seeds: the action contract;
- Add environments for plans and flags.
The complete field list is in the Sandbox definition reference.
04Step 2 — Connect the repository
Commit the definition to the default branch, then:
cofactor sandbox connect --application "Project Tracker" --repo acme/project-tracker
For a monorepo, name the definition's directory:
cofactor sandbox connect --application "Tracker Web" --repo acme/platform --dir apps/tracker
connect reads the definition at the default branch's head and refuses to save anything until it's there and valid. Then each environment in the definition becomes a sandbox environment of the application, and each persona one of its personas. It prints what the definition offers, the same as cofactor sandbox envs.
- One application per definition directory. A monorepo holds several, each with its own
--dir. Two applications can't use the same repository directory. - Connecting sets the application's delivery to GitHub. From then on it takes no pushed images.
- Connecting again with a different repository or directory starts the application's builds over from the new location.
05Step 3 — Claim and verify
cofactor sandbox envs --application "Project Tracker" cofactor sandbox claim --application "Project Tracker" --wait
The first claim of a commit builds everything it needs (toolchain, repo base, ready snapshot), so it takes longest. Later claims of the same commit and environment copy the snapshot.
The claim prints the sandbox's URLs (one per public port), its persona logins and the seed outputs. Check the app serves, each persona signs in with its role, and the seeded data is there. Then:
cofactor sandbox verify-env --application "Project Tracker" # every environment's seed chain, end to end cofactor sandbox destroy <id>
06Versions are commits
A GitHub-delivered application's versions are commits.
- Without
--at, a claim copies the default branch's current ready snapshot of the environment. While an environment is in use (claimed in the last two weeks), Cofactor rebuilds that snapshot at the default branch's head every 6 hours, so it can be up to 6 hours behind the head. A snapshot more than 12 hours old counts as stale: the claim builds the head instead (as it does when the environment has gone idle). - With
--at <sha>, a claim uses that commit. Give the full 40-character commit SHA.
cofactor sandbox claim --application "Project Tracker" --at 4e1f0c2a9b7d3e5f6a8b0c1d2e3f4a5b6c7d8e9f --wait
When a commit's snapshot fails (a build step, prepare or a seed), the claims waiting on it fail. A later claim builds it again, so a bug that fails every time keeps failing claims of that commit until you commit a fix. See When a seed fails.
07Pull request previews
Every pull request whose head has the application's cofactor.sandbox.yaml gets a preview sandbox of the default environment.
- Opened or reopened: Cofactor builds the preview at the pull request's head.
- Pushed to: the same sandbox is moved to the new head in place, rebuilding only what changed.
- Closed: the preview is destroyed.
- Pull requests from forks don't get previews.
Cofactor keeps one comment on the pull request up to date: status (building, ready, failed), the commit, and a table of the preview's URLs. A failed preview says so; the next push retries.
The comment never carries a login or any command output, because anyone who can read the pull request reads it. Org members get the preview's logins from the CLI:
cofactor sandbox preview --application "Project Tracker" --pr 123
#123: ready commit 4e1f0c2a9b7d web https://… api https://… owner owner@example.test <password> member member@example.test <password> expires 2026-10-05T14:02:11.000Z
Each push to the pull request extends the preview's lease, for up to two weeks.
08Authentication for native flows
Declare authentication.run for each native flow Login node to obtain a session inside its own copy. Prepare creates the users and roles; the auth script creates and verifies the requested persona’s app session. See Native flow authentication and Google sign-in setup.