Docs/Sandboxes from a pushed image (Beta)
#

Sandboxes from a pushed image (Beta)

Run your app in Cofactor sandboxes from a Docker image you build and push, with no repository access.

Your team builds the app's image, as it already does, and pushes it to Cofactor with its cofactor.sandbox.yaml. Cofactor gets no access to your repository. Every push becomes a version of the application, and anyone (a person, a test run, a coding agent) can claim a live copy of that version: the app running on its own https URLs, seeded with known data, with a working login for every role your tests need.

This guide uses one invented app throughout: Project Tracker, a project-tracking SaaS with workspaces, members (roles: owner, member, viewer), projects and tasks, a cookie-session JSON API, and a Postgres database. It is built as one image that serves its UI and API on port 8080. Substitute your own app's objects, roles and endpoints.

Sandboxes v2 is enabled per organization. If cofactor sandbox envs answers "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:

Set up Cofactor sandboxes for my app

Or read on to do it yourself.

01How it works

  1. You build the image the way you already do, for linux/amd64.
  2. cofactor sandbox push adds Cofactor's sandbox helper to it (plus an object storage server, if the definition asks for one) and a small layer holding your definition, then uploads only the layers Cofactor doesn't already have for your organization.
  3. Cofactor boots the image once until every service is healthy. Then each environment in the definition becomes a sandbox environment of the application, and each persona one of its personas.
  4. The first claim of an environment builds its ready snapshot: prepare runs, then the environment's seeds (about a minute). Later claims copy that snapshot in seconds. Each copy gets its own URLs and freshly generated persona passwords.

There is no build step in a pushed sandbox. Everything the app needs is already in the image.

02Before you start

  • The cofactor CLI, logged in (cofactor login), and Docker running locally.
  • An application in Cofactor for your app (Applications). An application's sandboxes come either from pushed images or from a connected GitHub repository, not both: the first push makes it a pushed-image application.

03Step 1 — Make the image sandbox-ready

What the image must be

  • linux/amd64. On an Apple Silicon Mac, build with --platform linux/amd64. push refuses any other platform.
  • glibc-based, with bash. Debian, Ubuntu, python:*-slim, non-alpine node:* and similar work. Alpine/musl and distroless images do not: the helper is a glibc binary, and every command runs through bash -lc.
  • A WORKDIR of its own, such as /app. Not /, and not a system directory. Services, prepare commands and actions run from it, and the sandbox makes it writable by the app.
  • Built already. Dependencies, compiled assets and, if the app uses one, a database server are all in the image.

How your image runs in a sandbox

  • Its ENTRYPOINT and CMD are not used. Start each process yourself with a services.<name>.run command (often your existing entrypoint script).
  • Its ENV and WORKDIR are applied, as docker run would.
  • Everything runs as an unprivileged user, uid 1000. The sandbox adds that account if your image has none, and gives it the WORKDIR tree. Anything the app writes must be under the WORKDIR or /tmp, not in another root-owned path.
  • One machine, no Docker inside. A database is a process you run as a service (Postgres installed in the image, or SQLite), not a container. For S3 or Google Cloud Storage, declare object_storage and the sandbox runs the server for you (see Object storage).
  • Each public port gets its own https URL, unique per sandbox. You hand that URL to your app through a param (see below).

Order the Dockerfile for fast re-pushes

A push uploads only the layers that changed. Put what changes most last: system packages, then the dependency manifest, lockfile and install step, then your source. A code change then rebuilds, and uploads, only the last layers.

dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl jq
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

jq and curl are there for the action and prepare scripts below; slim base images lack them.

04Step 2 — Write cofactor.sandbox.yaml

The definition for a pushed image is the same file as for a repository, minus everything about building: no setup, no lifecycle.build, no after_build. A minimal one for Project Tracker:

yaml
version: 1
resources: { vcpus: 2, memory_mib: 4096 }
ports:
  web: { port: 8080, access: public }
params:
  web_url: { port: web }
env:
  PUBLIC_URL: ${params.web_url}
services:
  app:
    run: exec ./start.sh
    health: { http: 8080, path: /health }
    reload_on_prepare: true
personas:
  owner: { email: owner@example.test }
lifecycle:
  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 }
    output:
      workspace_id: { type: string }
environments:
  default:
    description: Workspace "Acme", owned by the owner persona.
    seeds:
      - id: acme
        action: create-workspace
        input: { name: Acme, as: owner }

What each part does:

  • resources: the machine every sandbox gets.
  • ports: each named port becomes its own URL. public is reachable from outside the sandbox; private only from inside it.
  • params and env: { port: web } resolves to this sandbox's own public URL for web, and ${params.web_url} passes it to the app as PUBLIC_URL. See Ports and params.
  • services: the long-running processes. health passes when the HTTP path answers below 400 (or the command exits 0) within 5 minutes of the service starting.
  • reload_on_prepare: true: services start when the image is first booted, before any sandbox exists, with params at placeholder values. A service that needs the sandbox's own values (its URL, a { secret: true } param) sets reload_on_prepare: true, so each sandbox's prepare starts (and restarts) it instead. If in doubt, set it on your app's service.
  • personas, lifecycle.prepare, actions and environments: covered in the next steps.

Keep the file anywhere in your repo: validate --upload <file> and push --definition <file> read it from where you point them, and the push puts it in the image. Paths inside it (an action's run:, prepare commands) are relative to the image's WORKDIR, so ship your sandbox scripts in the image (COPY . . above does; otherwise COPY sandbox/ sandbox/).

Check it before every push. It's instant and offline:

bash
cofactor sandbox validate --upload cofactor.sandbox.yaml

Every problem is reported with a dotted path to where it is, for example environments.default.seeds[0].input.as: must be a declared persona (one of owner). The full field list is in the Sandbox definition reference.

05Step 3 — Make persona logins work

A persona is a named login identity. Declare one per role your tests need. Only the email is fixed in the definition; the password is generated fresh for every persona in every sandbox:

yaml
personas:
  owner:  { email: owner@example.test }
  member: { email: member@example.test }
  viewer: { email: viewer@example.test }

The COFACTOR_PERSONAS contract

Your lifecycle.prepare commands receive the logins as COFACTOR_PERSONAS, a JSON map:

json
{ "owner": { "email": "owner@example.test", "password": "…" }, "member": { "email": "…", "password": "…" } }

Prepare must make each login work in your app: create or update that user with that password, through your app's API, its CLI, or its database. A persona whose login doesn't work breaks every test that signs in as it.

A persona's email is yours to choose. Pick an account your app already has (from its seed data) and set its password, or have prepare create the account.

The application's persona of the same name selects this identity. Native flow login also needs the authentication script described below. Logins are shown by cofactor sandbox show <id>; they are never printed anywhere public.

Native flow authentication

For native Cofactor flows, add authentication: { run: sandbox/auth.sh }. When a Login node requests a persona, the private script creates an app session inside that run's isolated copy. A later Login node can switch personas in the same copy. The script returns cookies and/or local storage for that copy; tests do not share a browser session. See the authentication contract and run cofactor sandbox guide v2 authentication for a script example.

For ordinary role-based tests, create the app session directly using the app's existing login API or supported session creation mechanism. If a test checks Google sign-in itself, start signed out and exercise the actual button and callback using provider emulation. Google currently uses one fixture identity rather than selecting a persona.

Read logins per request

Prepare runs while your app is up: the reload_on_prepare services are running and healthy, and the others have been running since the image was booted. So write the logins somewhere the running app reads on every sign-in: its database, or its own API.

If your app loads its accounts once at startup (a file or config read into memory), change it to read them per request, or the new passwords won't take. For an app whose logins are fixtures, a common fix is a small JSON file the prepare script writes from COFACTOR_PERSONAS, which the app checks on each sign-in before its fixtures.

Roles come from seeds, not personas

A persona having a login is separate from what it may do. Workspace membership and role are set by seed actions (an add-member action, below), or already present when the persona's email is an existing account with the right role. That way the same persona can be "just a login" in one environment and a workspace owner in another.

06Step 4 — Add actions and seeds

An action is a script in the image with a typed input and output. It is the one way data gets into a sandbox: an environment's seeds call actions, and so do tests and cofactor sandbox run.

The action contract

  • Inputs arrive as INPUT_<NAME> environment variables (a persona input carries the persona's name, such as owner).
  • The script also gets the definition's env:, every param as COFACTOR_PARAM_<NAME>, and COFACTOR_PERSONAS.
  • It prints its result as a JSON object on the last stdout line that starts with {. Send everything else to stderr.
  • It runs inside the sandbox, so it reaches your app on localhost.

An action that signs in as a persona and creates a workspace through Project Tracker's own API (the port, paths and JSON fields are the example app's; use your app's):

bash
#!/usr/bin/env bash
# sandbox/actions/create-workspace.sh
set -euo pipefail
login=$(jq -c --arg p "$INPUT_AS" '.[$p] | {email, password}' <<<"$COFACTOR_PERSONAS")
jar=$(mktemp)
curl -fsS -c "$jar" -H 'content-type: application/json' \
  -d "$login" http://127.0.0.1:8080/api/auth/login >&2
id=$(curl -fsS -b "$jar" -H 'content-type: application/json' \
  -d "$(jq -nc --arg n "$INPUT_NAME" '{name: $n}')" \
  http://127.0.0.1:8080/api/workspaces | jq -er '.id')
jq -nc --arg id "$id" '{workspace_id: $id}'

If your app's session cookie is Secure (or __Host- prefixed) and your HTTP client won't send it back over plain http, call the app through its public port URL instead.

Seeds

An environment's seeds call actions in order, once per version, before the snapshot every claim copies. ${<step id>.<output>} passes one step's output to a later step:

yaml
environments:
  default:
    description: |
      Workspace "Acme": owner is its owner, member a member,
      viewer a read-only viewer. No projects yet.
    seeds:
      - id: acme
        action: create-workspace
        input: { name: Acme, as: owner }
      - id: add_member
        action: add-member
        input: { workspace_id: "${acme.workspace_id}", persona: member, role: member, as: owner }
      - id: add_viewer
        action: add-member
        input: { workspace_id: "${acme.workspace_id}", persona: viewer, role: viewer, as: owner }

Keep seeds thin and generic: the handful of things nearly every test needs. Data only one test needs belongs in that test, as its own action call. Every action that performs a real operation takes an explicit as: <persona> input, rather than silently acting as an admin. See Seeds and actions for the full rules and anti-patterns.

07Step 5 — Add environments for plans and flags

An environment (a "flavor") is the same sandbox started with different data and config. default is required, and it's what a claim gets when you don't name one.

Add another environment only when many tests need a starting state default can't give them cheaply, typically a different plan or feature-flag combination:

yaml
environments:
  default: { … as above … }
  free-plan:
    extends: default
    description: Same as default, but Acme is moved to the Free plan (3-project limit).
    seeds:
      - id: downgrade
        action: set-plan
        input: { workspace_id: "${acme.workspace_id}", plan: free }
  beta-features:
    extends: default
    description: Same as default, with the timeline view turned on.
    env:
      FEATURE_FLAGS: timeline
  • extends runs the parent's seeds first, then the child's own. env merges the same way, the child's values last.
  • Set a flag with a seed action when your app stores it in its database (free-plan), or with the environment's env: when your app reads it from an environment variable (beta-features). An environment's env: reaches only reload_on_prepare services, prepare commands and actions.
  • Write each description for the agent choosing between environments: what exists, and who can do what.

08Step 6 — Try it locally first

Run the app as usual and call your scripts in it with the variables they read (a sandbox also sets the definition's env: and runs them as uid 1000):

bash
docker run -d --name try -p 8080:8080 tracker:sandbox
docker exec -e COFACTOR_PERSONAS='{"owner":{"email":"owner@example.test","password":"pw-1"}}' \
  try bash sandbox/personas.sh
docker exec -e COFACTOR_PERSONAS='{"owner":{"email":"owner@example.test","password":"pw-1"}}' \
  -e INPUT_NAME=Acme -e INPUT_AS=owner try bash sandbox/actions/create-workspace.sh

docker exec starts in the image's WORKDIR.

09Step 7 — Push, claim and verify

bash
docker buildx build --platform linux/amd64 -t tracker:sandbox --load .
cofactor sandbox validate --upload cofactor.sandbox.yaml
cofactor sandbox push --application "Project Tracker" tracker:sandbox \
  --definition cofactor.sandbox.yaml --tag nightly --wait

push reads the image out of your local Docker, uploads what Cofactor doesn't already have, and reports what it sent:

Pushing 3f6c…: uploading 3 of 14 blobs (12.4 MB); 398 MB already in Cofactor
Importing (Cofactor boots the image until its services are healthy)...
Push 3f6c…: ready [nightly]
  Claim it: cofactor sandbox claim --application 'Project Tracker' --environment default --wait

Then browse what the push offers and claim a sandbox:

bash
cofactor sandbox envs  --application "Project Tracker"
cofactor sandbox claim --application "Project Tracker" --environment default --wait

The claim prints the sandbox's URLs, its persona logins, and the seed outputs (here acme.workspace_id). Check that:

  • the URL serves the app over https;
  • each persona signs in through the app's real login, with its role;
  • the seeded data is there.

Run an action against the live sandbox, then tear it down:

bash
cofactor sandbox run <id> create-workspace --input name=Scratch --input as=owner
cofactor sandbox destroy <id>

To prove every environment's seed chain actually runs, not just that it's well-formed:

bash
cofactor sandbox verify-env --application "Project Tracker"

It claims each environment of the current version in turn, reports every seed output and timing, and destroys it.

Versions and tags

  • Each push is a version. Claims use the newest ready push unless they name another with --at <push id or tag>.
  • A tag names the newest ready push that carries it. Pushing --tag nightly again moves the tag to the new push; older pushes stay claimable by their id.
  • Tags are letters, digits, ., _ and -, at most 64 characters, starting with a letter or digit. A push takes up to 10.

10Run it from CI

Push from CI whenever you have a new build: nightly, per release, per merge, or per pull request.

An API key with only what the job does

Create a scoped API key in Settings > API Keys and store it as a CI secret, exposed as COFACTOR_API_TOKEN. Grant only what the job does:

The job…Scopes
pushes imagessandbox:read, sandbox:publish, property:read
also claims and destroys a sandboxadd sandbox:create, sandbox:delete
also runs actionsadd sandbox:run

property:read is needed because the CLI lists your applications to resolve --application, even when you pass an id. sandbox:read includes the sandboxes' persona logins, so treat the key like any other secret. See API Keys.

Keep the build cache

A CI runner that starts with an empty cache rebuilds every step, and every rebuilt layer is new to Cofactor even when nothing in it changed. With GitHub Actions:

bash
docker buildx build --platform linux/amd64 --load -t app:sandbox \
  --cache-from type=gha --cache-to type=gha,mode=max .

Or use type=registry with a cache image of your own.

A GitHub Actions job, end to end:

yaml
name: Push sandbox image
on:
  push:
    branches: [main]
jobs:
  push:
    runs-on: ubuntu-latest
    env:
      COFACTOR_API_TOKEN: ${{ secrets.COFACTOR_API_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - run: npm install -g @runcofactor/cli
      - run: |
          docker buildx build --platform linux/amd64 --load -t app:sandbox \
            --cache-from type=gha --cache-to type=gha,mode=max .
      - run: |
          cofactor sandbox validate --upload cofactor.sandbox.yaml
          cofactor sandbox push --application "Project Tracker" app:sandbox \
            --definition cofactor.sandbox.yaml --tag nightly --tag "$(date -u +%F)" --wait

--wait makes the job fail if the image doesn't boot.

11Fast re-pushes

A push uploads only the layers that changed since an earlier push in the last 30 days, and says how much it sent and how much Cofactor already had. Two things keep a re-push small:

  • A Dockerfile that copies the source last (Step 1).
  • A CI build cache that survives between runs (above).

If every push uploads the whole image, one of those two is missing.

12Pull requests

Push each pull request with its own tag and claim it by that tag:

bash
cofactor sandbox push  --application "Project Tracker" app:sandbox --tag pr-123 --wait
cofactor sandbox claim --application "Project Tracker" --environment default --at pr-123 --wait

A pull request's push shares every layer below the source with main's, so it uploads little.

A pull request's push is also the application's newest push, and a claim without --at gets the newest push, whatever branch it came from. Where you need main's build, claim it by its tag (for example --at nightly, pushed from main).

13Troubleshooting

SymptomWhat to do
push says the image is not linux/amd64, or its WORKDIR can't be usedRebuild as the message says (--platform linux/amd64; WORKDIR /app in the Dockerfile).
"Cofactor could not start this image"A service exited or never turned healthy. The message ends with that service's log. Reproduce with docker run --rm -it --entrypoint bash <image> and run the service's run command from the WORKDIR. In the sandbox it runs as uid 1000 with the WORKDIR made theirs; plain docker run --user 1000 leaves the WORKDIR root-owned, so a write failure there is not the sandbox's.
A claim fails in prepare or a seedThe error carries the command's output (everything the script printed to stderr). Fix the script, rebuild and push again. Each new claim builds the snapshot again, so a seed that fails every time fails every claim of that push until you push a fix.
"push the image again" / "the sandbox helper changed since this push"Cofactor's sandbox helper changed since that push; a fresh cofactor sandbox push picks up the current one.
"This Cofactor takes pushes from a newer cofactor CLI"Upgrade the CLI (npm install -g @runcofactor/cli) and push again.
"this Cofactor takes pushes from an older cofactor CLI"Your CLI is newer than the Cofactor it talks to; use the CLI version that Cofactor ships with.
"Some of the image's layers never arrived"Run the push again; it uploads only what is still missing.
A layer is over 5 GBA push takes at most 5 GB per layer. Split or slim it.
Every push uploads the whole imageThe build cache is missing, or the Dockerfile copies the source before installing dependencies.
"This application's sandboxes are built from its GitHub repository"The application is connected to a repository, so it takes no pushed images. Use a different application.
A persona can't sign inPrepare didn't make the login work, or the app read its accounts once at startup. See Read logins per request.

cofactor sandbox logs and cofactor sandbox diagnostics are for the older Sandboxes (Beta). For these sandboxes, cofactor sandbox show <id> has the status and any error. To debug a failing seed step by hand, see When a seed fails.

14What to build first

  1. One persona per role your tests need. Project Tracker has three roles, so it declares owner, member and viewer. Each persona's role comes from a seed action such as add-member, or from prepare when the persona is an existing account with the right role. With one persona per role, tests can sign in as each and check what that role may and may not do.
  2. One environment per plan or feature-flag combination your tests care about: default, free-plan, beta-features. Don't add one speculatively.
  3. Actions for the few core objects nearly every test needs, each taking as: <persona>: create-workspace, add-member, create-project, create-task. Keep the shared seeds thin; tests create the rest themselves with these actions.
  4. If your app stores files, declare object_storage and seed the few files every test needs (a logo, an import template) with an action. See Object storage.