Skip to content

A preview environment per pull request

The payoff of branch environments is that reviewers click a URL and see the change running instead of reading a diff. This guide wires that into CI so it happens on every pull request automatically: push a branch, a preview provisions, its URL lands on the PR, and the environment is torn down when the PR closes.

The provisioning itself is just git push managed — or one mf CLI command. The CI glue that posts URLs and gates merges is built on the public API and webhooks, both live today.

  1. A contributor pushes a branch and opens a PR.
  2. CI provisions an isolated preview environment for the branch, seeded from production.
  3. CI posts the preview URL back to the PR as a comment and a status check.
  4. Reviewers and QA work against the live URL; you can gate the merge on observability before promoting.
  5. When the PR closes or merges, CI tears the environment down.

Automation should run on its own narrowly scoped API key, not a personal one. Mint a key with just the scopes this workflow needs:

Mint a scoped key for the preview bot
mf keys create --name pr-preview-bot \
--scopes sites:read,environments:write,jobs:read,observability:read \
--ttl 2160h

The secret is printed exactly once — store it as a CI secret named FORGE_TOKEN. The mf CLI and the API examples below both read it from that environment variable, so nothing else needs configuring in the job.

A preview is created whenever you push a non-default branch that isn’t routed to staging or ignore — see branch routes. In CI you can rely on that, or create the environment explicitly, which is easier to script because the command blocks until provisioning finishes and hands you the environment id:

Provision a preview from CI
mf envs create --type branch --branch "$BRANCH_NAME" \
--site "$SITE_ID" --wait --json > job.json
ENV_ID=$(jq -r '.result.env_id' job.json)
PREVIEW_URL="https://$(mf envs get "$ENV_ID" --site "$SITE_ID" --json | jq -r '.subdomain')"

--wait polls the job to completion and exits non-zero if provisioning fails, so the CI step fails with it. Subsequent pushes to the same branch deploy into the existing environment through your git remote:

Deploy the PR's head commit
git remote add managed "$MANAGED_GIT_URL"
git push managed "HEAD:$BRANCH_NAME"

If you didn’t capture the URL at create time, read the environment back — the branch is on the environment object, and subdomain is the preview hostname:

Find the preview environment for a branch
curl -s "https://api.managed.dev/v1/sites/site_01J7ABCXYZ/environments" \
-H "Authorization: Bearer $FORGE_TOKEN" |
jq -r --arg b "$BRANCH_NAME" '.data[] | select(.branch == $b).subdomain'

Then post that URL as a PR comment and a commit status. A GitHub Action that does the whole loop is forthcoming as a published, versioned action; until then, the illustrative workflow below shows the shape:

.github/workflows/managed-preview.yml
name: managed.dev preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Install the mf CLI
run: go install terriblegit.com/terrible/mf/cmd/mf@latest
- name: Ensure the preview environment exists
run: |
if ! mf envs list --site "${{ vars.SITE_ID }}" --json |
jq -e --arg b "${{ github.head_ref }}" 'any(.[]; .branch == $b)' >/dev/null; then
mf envs create --type branch --branch "${{ github.head_ref }}" \
--site "${{ vars.SITE_ID }}" --wait
fi
- name: Push the head commit
run: |
git remote add managed "${{ secrets.MANAGED_GIT_URL }}"
git push managed "HEAD:${{ github.head_ref }}"
- name: Comment preview URL
run: |
url="https://$(mf envs list --site "${{ vars.SITE_ID }}" --json |
jq -r --arg b "${{ github.head_ref }}" '.[] | select(.branch == $b).subdomain')"
./scripts/post-preview-url.sh "$url"

Because previews carry the full observability pipeline, you can make “no new errors” a merge requirement instead of a vibe. Query the preview’s traces for error-status rows and fail the check if any appear:

Block promotion when the preview is throwing errors
errors=$(curl -s "https://api.managed.dev/v1/sites/$SITE_ID/environments/$ENV_ID/insights/traces?status=error&limit=1" \
-H "Authorization: Bearer $FORGE_TOKEN" | jq '.data.traces | length')
test "$errors" -eq 0 || { echo "preview has error traces — blocking merge"; exit 1; }

This is the deploy-gated-on-observability pattern: you only promote a branch that’s demonstrably clean on its own preview.

When the PR closes, delete the environment so it doesn’t linger. Deleting the branch also tears the preview down automatically, but an explicit call is cleaner in CI:

Tear down on PR close
mf envs delete "$ENV_ID" --site "$SITE_ID" --wait

Teardown is an async job like everything else; --wait confirms it finished. Production is never removed this way — this workflow only ever deletes the PR’s own branch environment.