Preview environments per pull request, before they were a checkbox
In 2017 every Freebird feature branch got its own preview environment from a GitHub webhook, a Lambda and CircleCI. Ephemeral environments, by hand.
From 2017 · 2 min read · Published September 16, 2026
Today, preview environments are a checkbox. Push a branch to a project on Vercel and you get a URL. It's easy to forget how much plumbing that used to take.
In July 2017, our team at Freebird Rides built our own version of it. Every pull request from a feature/ or hotfix/ branch got its own running environment. When the PR closed, the environment was torn down.
Wiring up ephemeral environments
It was three pieces, none of them complicated:
- GitHub sent pull request and branch delete events to a webhook.
- A Lambda (one of the receivers in our webhook service) decided whether the event mattered.
- CircleCI ran a parameterized job to deploy or tear down the environment.
The routing logic in the Lambda was essentially this:
_handlePREvents(payload) {
const branchPrefixRegEx = /^(feature\/|hotfix\/)/
const { ref } = payload.pull_request.head
if (branchPrefixRegEx.test(ref)) {
return this._handleFeatureBranches(payload)
}
return this._buildResponse(204, "We don't care about this PR event")
}
_handleFeatureBranches(payload) {
switch (payload.action) {
case 'opened':
case 'reopened':
return this._circleDeployFeatureEnvironment(payload)
case 'closed':
return this._circleTeardownFeatureEnvironment(
this._parseTeardownArgsFromPRClose(payload),
)
}
}Deploying meant triggering a CircleCI build at the PR's head commit with a deploy_feature_branch job. Tearing down triggered teardown_feature_branch with the branch name to remove.
The edge cases were the real work
The happy path took an afternoon. The details took longer.
Merged vs. closed. When a PR is merged, the teardown should run in the context of the base branch, since the head branch may already be gone. When it's closed without merging, it runs from the head branch itself. The code had to tell those apart.
Branch deletes without a PR. People delete branches directly. A separate handler caught GitHub's delete event and tore down any environment for a matching branch, so abandoned work didn't leave servers running.
Naming conventions as configuration. Only feature/ and hotfix/ branches got environments. That one regex kept experiments, dependency bumps, and throwaway branches from spinning up infrastructure. It also meant the convention was enforced by consequence, not by a wiki page.
Saying no politely. Events we didn't care about returned 204 No Content with a short reason. When something didn't deploy, the webhook delivery log in GitHub told you why.
What it bought us
Product and QA could click a link and test a feature in isolation before it merged. Reviews got better, because reviewers looked at the running thing instead of imagining it from a diff. And main stayed deployable, because nothing reached it untested.
That's exactly what preview deployments give you today, which is the point. The value was never in the plumbing. It was in the workflow: every change gets a URL, and every URL goes away when the change does.
Related: Docker layer caching for Node: copy package.json first · Webhook best practices: acknowledge fast, process later