all posts

A Disposable Dev Environment per Git Branch

Ajay Kumar··9 min read

"Works on my machine" is a load-bearing phrase in most engineering orgs, and the reason is uncomfortable: your machine is a haunted snowflake. It has a Node version you installed for a project two years ago, a global npm package that shadows a local one, a Postgres you `brew install`ed and forgot the password to, and an environment variable exported in a shell profile you've never opened. Every one of your teammates has a differently-haunted machine. The branch you're reviewing was written against theirs, not yours — and the gap between those two ghosts is where an afternoon disappears. This post is about a different shape: a disposable dev environment per git branch. Every branch (or PR) gets its own throwaway, fully-isolated machine — repo checked out, dependencies installed, services running — that comes up fast and dies the moment the branch is gone. I'm Ajay; I built PandaStack, and I'll be honest about where this model earns its keep and where it's overkill.

Environment drift is a state problem, and ephemerality is the cure

Environment drift is what happens to any machine you keep. You start from a clean setup, then time and a hundred small mutations pull it away from everyone else's: a dependency upgraded in place, a service left running on a port, a `.env` edited by hand during a debugging session and never reverted, a global tool installed to unblock one task. None of these are visible in git. The environment is state, and long-lived state drifts. The only durable fix isn't better discipline — it's to stop keeping the environment at all. If every branch gets a fresh machine built from the same definition and that machine is destroyed when the branch merges, there is nothing to drift. The environment becomes as disposable as the branch it serves.

Once you accept that, the interesting question is what you run these throwaway environments on, because the substrate decides whether "one per branch" is affordable and safe. A dev environment quietly wants three things that usually trade off against each other: real isolation (you're running a teammate's branch and its dependency tree's install hooks on shared hardware), fast start (nobody waits two minutes to check out a one-line fix), and cheap teardown and idle (twenty open branches shouldn't cost twenty always-on VMs). A Firecracker microVM is one of the few things that gives you all three.

The two common answers, and where each frays

Most teams reach for one of two patterns, and both have a sharp edge. The first is a Dockerfile-based devcontainer: a shared-kernel container defined in the repo. It's reproducible on the filesystem, which is genuinely good — but it shares the host kernel with every other tenant, so a poisoned `postinstall` script in a dependency is one container-escape away from a neighbor. And the fast-container promise hides a slow reality: any change to the base image means a rebuild, and a cold image on a new host means re-pulling layers and re-running the whole toolchain install. The second is an always-on cloud VM per developer: strong isolation, but you pay for it sitting idle through every meeting and lunch, and because it's long-lived, it drifts exactly like the laptop it replaced. You've moved the haunted snowflake to the cloud and added a monthly bill.

  • Isolation — Dockerfile devcontainer: shares the host kernel; a malicious or buggy dependency install hook is one escape from other tenants. Always-on cloud VM: strong (its own kernel), but shared across many branches over time if you reuse it. Disposable microVM: its own guest kernel under hardware virtualization (KVM), a fresh one per branch, so build scripts are contained to one throwaway VM.
  • Start time — Dockerfile devcontainer: fast to start the container, but a base-image change forces a rebuild and a cold host re-runs the whole install. Always-on cloud VM: already running, but you paid for that. Disposable microVM: snapshot-restore makes the VM appear in ~49ms (the restore step), and forking a warmed snapshot skips the install entirely.
  • Drift — Dockerfile devcontainer: filesystem is reproducible, but in-flight process state and hand-edits during a session still drift. Always-on cloud VM: drifts like any long-lived machine. Disposable microVM: nothing to drift — it's built fresh per branch and destroyed after.
  • Idle cost — Dockerfile devcontainer: cheap if stopped, but re-setup on next start. Always-on cloud VM: pays full freight while idle. Disposable microVM: hibernate snapshots memory + disk and stops the VM, so an idle branch env costs storage only and wakes to the exact process state.
  • Teardown — Dockerfile devcontainer: manual, and stale containers accumulate. Always-on cloud VM: someone has to remember to delete it. Disposable microVM: tie the VM's lifecycle to the branch's — destroy on merge, with a TTL backstop so a forgotten env reaps itself.
Containers aren't the villain here — a Dockerfile is a great way to build the image you bake into the environment. The argument is about the runtime boundary and the lifecycle: when the thing running is a teammate's untrusted branch plus its dependency tree, a shared host kernel isn't the boundary you want between tenants, and a long-lived machine isn't the lifecycle you want if drift is the disease.

A branch's dependencies are untrusted code the moment you install them

It's tempting to call a dev environment trusted because it's running your own company's code. But the moment you `npm install` or `pip install`, you execute hundreds of packages' lifecycle scripts — most of which you've never read, many maintained by people you've never met. Supply-chain attacks via poisoned npm and PyPI packages are a recurring, real category, not a hypothetical. If that runs in a container sharing a kernel with another developer's environment, the question becomes how well your container runtime withstands a hostile local process. In a microVM, the same poisoned package runs against its own guest kernel; to reach a neighbor it would have to escape the hypervisor itself — a far smaller, far more heavily audited surface than the full Linux syscall interface. This is the same property that lets AWS Lambda run untrusted functions from thousands of customers on shared fleets, on Firecracker, the VMM PandaStack is built on. The practical payoff: you can hand every branch its own environment without auditing the dependency tree first, because the boundary doesn't depend on the code inside behaving.

Snapshot a warmed base once, fork it per branch

Cloning a repo and running a full toolchain install on every branch is wasteful when most of that work is identical across branches. The better pattern: do the expensive setup once — check out the base ref, run `mise install` for the pinned toolchain, install dependencies, warm the build cache — then snapshot that fully warmed machine. Every per-branch environment is a fork of that snapshot, so each one starts from the identical warmed state in a fraction of the time. A same-host fork is 400–750ms and shares memory copy-on-write, so a hundred branches forking the same golden base cost far less memory than a hundred independent machines — they share the read-only pages until they write. (A cross-host fork is 1.2–3.5s, when the parent's snapshot has to be pulled from object storage first.) Then each fork does the only branch-specific work left: fetch and check out its own branch on top of the warmed tree.

from pandastack import Sandbox

# ── One-time: build the warmed "golden" base and snapshot it. ──────────
# Check out the base ref everyone branches from, install the pinned
# toolchain + deps, warm the build cache. This is the slow part, paid ONCE.
golden = Sandbox.create(template="base", ttl_seconds=1800)
golden.exec(
    "cd /workspace && "
    "git clone https://github.com/acme/webapp.git && "
    "cd webapp && "
    "mise install && "          # honours .tool-versions / .nvmrc
    "npm ci && npm run build",  # warm node_modules + build cache
    timeout_seconds=600,
)
snap = golden.snapshot()   # freeze the warmed memory + disk state
golden.kill()


# ── Per branch: fork the warmed snapshot (sub-second), check out the ──
# branch on top, and you have a ready-to-code env. Destroyed on exit.
def env_for_branch(branch: str) -> None:
    # Fork inherits the warmed node_modules + build cache copy-on-write.
    with Sandbox.fork(snap.id, ttl_seconds=3600) as env:
        env.exec(
            f"cd /workspace/webapp && "
            f"git fetch origin {branch} && "
            f"git checkout {branch} && "
            f"npm ci",           # fast: mostly cache-hits off the warmed tree
            timeout_seconds=180,
        )
        # Start the dev server detached; reachable at a tokenless preview URL
        # https://3000-<env.id>.<preview-suffix> for the VM's lifetime.
        env.exec(
            "cd /workspace/webapp && setsid sh -c "
            "'PORT=3000 npm run dev > /var/log/dev.log 2>&1' &",
            timeout_seconds=10,
        )
        print(f"{branch}: https://3000-{env.id}.preview.pandastack.ai")
    # env destroyed here — nothing from this branch leaks into the next


env_for_branch("feature/checkout-v2")

The `snapshot()` freezes a running machine, not just a filesystem — the same warmed caches, the same installed toolchain, the same everything. That's the strongest form of "identical environment for everyone": an image gives you a consistent disk, but a memory+disk snapshot gives you a consistent running machine. And because the fork is copy-on-write, the per-branch cost is only what that branch actually changes.

Re-baking the golden snapshot is the moment to refresh dependencies and the base ref — not the per-fork path. Keep secrets and per-developer credentials OUT of the golden snapshot; inject them at fork time. A snapshot freezes everything in memory, so anything you'd rather not copy into every branch's environment shouldn't be in the machine when you snapshot it.

Wiring it to git: a branch appears, an environment appears

The point of "per branch" is that nobody provisions anything by hand. You hang the environment's lifecycle off git events: a branch is pushed → fork the warmed base and check it out; the branch merges or is deleted → destroy the environment. A CI job on push is the least surprising place to put the create half, and it keeps the mapping honest — one branch, one environment, born and reaped by the same automation that already watches your repo.

# .github/workflows/branch-env.yml — provision a disposable dev env per push.
name: branch-env
on:
  push:
    branches-ignore: [main]   # main is the golden base, not a throwaway

jobs:
  spin-up:
    runs-on: ubuntu-latest
    steps:
      - name: Fork the warmed base into a per-branch microVM
        env:
          PANDASTACK_API_KEY: ${{ secrets.PANDASTACK_API_KEY }}
          GOLDEN_SNAPSHOT_ID: ${{ vars.GOLDEN_SNAPSHOT_ID }}
        run: |
          set -euo pipefail
          BRANCH="${GITHUB_REF_NAME}"

          # Fork the warmed snapshot (sub-second, CoW). ttl is a backstop
          # so a branch nobody cleans up reaps itself after a day.
          ENV_ID=$(curl -sS -X POST https://api.pandastack.ai/v1/sandboxes/$GOLDEN_SNAPSHOT_ID/fork \
            -H "Authorization: Bearer $PANDASTACK_API_KEY" \
            -H "Content-Type: application/json" \
            -d '{"ttl_seconds": 86400, "metadata": {"branch": "'"$BRANCH"'"}}' \
            | jq -r '.id')

          # Check out THIS branch on top of the warmed tree.
          curl -sS -X POST https://api.pandastack.ai/v1/sandboxes/$ENV_ID/exec \
            -H "Authorization: Bearer $PANDASTACK_API_KEY" \
            -H "Content-Type: application/json" \
            -d '{"command": "cd /workspace/webapp && git fetch origin '"$BRANCH"' && git checkout '"$BRANCH"' && npm ci", "timeout_seconds": 180}'

          echo "branch env: https://3000-$ENV_ID.preview.pandastack.ai"
          echo "$ENV_ID" > "/tmp/env-id-$BRANCH"   # stash for the teardown job

The teardown half is symmetric and belongs on the `delete` or `pull_request: closed` event: look up the environment you stashed for that branch and destroy it. Make it idempotent — if the TTL already reaped it, that's fine, the job just has nothing to do.

# Teardown, on branch delete or PR close. Destroy the per-branch env.
# Idempotent: if the TTL already reaped it, DELETE just 404s and we shrug.
ENV_ID="${1:?pass the stashed sandbox id}"

curl -sS -X DELETE "https://api.pandastack.ai/v1/sandboxes/$ENV_ID" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" || true

echo "tore down branch env $ENV_ID"

Two backstops keep this honest even when a webhook is dropped or a handler 500s. The `ttl_seconds` on fork means an environment reaps itself if the teardown job never runs — belt and suspenders against leaking VMs from branches that merged weeks ago. And if you want to keep a branch's environment warm across a quiet afternoon without paying for an idle VM, hibernate it: a snapshot-and-pause stops the VM, and the next request to its URL auto-wakes it to the exact process state — dev server and all.

Idle cost, and why "one per branch" actually scales

The dirty secret of per-branch environments is that most of them are idle most of the time. You have a dozen branches open; you're actively touching one. Paying a running VM for the other eleven is how this pattern gets a reputation for being expensive. Hibernate is the fix: when a branch env goes idle, snapshot its memory and disk and stop the VM, at which point it consumes no CPU and no RAM — just some storage. The next time someone opens that branch's URL, it wakes back to the exact state it was in. Because the create/restore path is cheap to begin with (a fresh create is p50 179ms and p99 ~203ms via snapshot-restore; a true cold boot is only ~3s on first spawn), waking from hibernate is in that same neighborhood rather than the cold-boot-plus-reinstall cliff you'd hit re-provisioning a container from scratch.

Capacity isn't the bottleneck people fear, either. An agent pre-allocates 16,384 networking slots, so the practical ceiling is host memory and CPU, not networking — and copy-on-write forks plus hibernate keep the memory bill honest even with a lot of branches in flight. The economic shape is the one you want: pay for the branch you're actually working on, pay near-zero for the ones you're not, and never make anyone re-clone and re-install just because they switched tasks for an hour.

Where this fits — and where it's overkill

A disposable microVM per branch earns its keep when environments are multi-tenant (many branches, many people, shared hardware), when the code inside is effectively untrusted (it is, the moment you install dependencies), and when drift has actually cost you — a merge that broke because two people had different toolchains, a bug that only reproduced on one laptop, an onboarding day lost to setup. If instead you're a solo developer on a single trusted project, or you keep exactly one long-lived environment you fully control, a plain Dockerfile devcontainer or a long-lived VM is simpler and you should use it — the drift you'd be paying a microVM to eliminate isn't hurting you. The line is the moment there are many branches, the code is untrusted-by-dependency, and you want each environment to be born fresh and die clean. At that point the microVM's three-way win — hardware isolation, sub-second forks, and hibernate-to-near-zero idle — is exactly the combination the use case is asking for, and "works on my machine" finally stops being a sentence anyone has to say, because there is no my machine: just a fresh environment forked from the same warmed base every time.

Treat any specific behavior of devcontainers, Codespaces, Gitpod, Coder, or a cloud-VM provider as something to verify against their current docs — they each make different substrate, pricing, and lifecycle choices, and those evolve. The argument here is about properties, not a feature scorecard: a per-branch dev environment wants isolation, a fast start, cheap idle, and clean teardown, and a Firecracker microVM is built to give you all four.

Frequently asked questions

Why give every git branch its own disposable dev environment?

Because environment drift is a state problem, and the only durable fix is to stop keeping the state. A long-lived machine — laptop or cloud VM — drifts as dependencies get upgraded in place, ports get left running, and .env files get hand-edited during debugging, none of which is visible in git. If every branch gets a fresh environment built from the same definition and that environment is destroyed when the branch merges, there is nothing to drift. On PandaStack each per-branch environment is its own Firecracker microVM, so branches are isolated by hardware and can run untrusted dependency install hooks safely.

How is a disposable microVM per branch different from a Dockerfile devcontainer?

A devcontainer is reproducible on the filesystem, which is good, but it shares the host kernel with every other tenant — so a poisoned postinstall script in a dependency is one container-escape away from a neighbor — and any base-image change forces a rebuild. A disposable microVM boots its own guest kernel under hardware virtualization (KVM), so build scripts are contained to one throwaway VM, and it comes up via snapshot-restore (~49ms restore step) or a sub-second fork of a warmed base rather than re-running the toolchain install. It's also tied to the branch's lifecycle, so it's destroyed on merge instead of accumulating as a stale long-lived machine.

How does forking a warmed base make per-branch environments fast?

Do the expensive setup once — check out the base ref, run mise install for the pinned toolchain, install dependencies, warm the build cache — then snapshot that fully warmed machine. Each per-branch environment is a fork of that snapshot, so it starts from the identical warmed state and only has to fetch and check out its own branch on top. A same-host fork is 400–750ms and shares memory copy-on-write, so many branches forking one golden base cost far less memory than independent machines (a cross-host fork is 1.2–3.5s). Keep secrets out of the golden snapshot and inject them at fork time.

How do I keep per-branch environments from running up the bill?

Hibernate the idle ones and tear down on merge. Most branch environments are idle most of the time — you're only actively touching one. When a branch env goes idle, hibernate() snapshots its memory and disk and stops the VM, so it consumes only storage; the next request to its URL auto-wakes it to the exact process state. Tie teardown to git events (branch delete or PR close destroys the environment) and set ttl_seconds on fork as a backstop so a forgotten env reaps itself. Because the restore path is already cheap (p50 179ms create, ~3s cold boot at worst), waking is fast rather than a cold-boot-plus-reinstall cliff.

When is a disposable environment per branch overkill?

When you're a solo developer on a single trusted project, or you keep exactly one long-lived environment you fully control, a plain Dockerfile devcontainer or a long-lived VM is simpler — the drift you'd be paying a microVM to eliminate isn't hurting you. The model earns its keep when there are many branches on shared hardware, the code is untrusted-by-dependency (it is, the moment you install packages), and drift has actually cost you a broken merge or a lost onboarding day. At that point hardware isolation, sub-second forks, and hibernate-to-near-zero idle are exactly what the use case needs.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.