Open a long-lived project after a few months of per-PR previews and you usually find the archaeology: Neon branches named pr-* from pull requests merged weeks ago, stale GitHub Environments still holding secrets, and the occasional Redis or test project nobody can attribute to anything.
Nothing is on fire. It's just rent paid for rooms nobody lives in — plus live credentials sitting in environments that should not exist anymore.
This is the normal end state of per-PR environments built by hand. Not because anyone was careless, but because the lifecycle is spread across four different places, and only half of it — the creation half — ever gets proper attention.
This post shows both ways: the manual stack most teams actually run, honestly, and then the same environment as one EnvTie blueprint — with plan → apply → verified destroy treated as one object. I built EnvTie because I got tired of being the cleanup script. The manual part stands on its own; the disclosure is at the end.
The manual way, fairly
Creating a database branch per PR is genuinely easy in 2026. Neon ships an official action for it:
# .github/workflows/preview-up.yml (condensed)
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: neondatabase/create-branch-action@v5
id: branch
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch_name: pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}
- name: Create Upstash database
run: |
curl -X POST https://api.upstash.com/v2/redis/database \
-u "${{ secrets.UPSTASH_EMAIL }}:${{ secrets.UPSTASH_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"name":"pr-${{ github.event.pull_request.number }}","region":"us-east-1","tls":true}'
Then it gets less fun.
Your app also needs a Stripe webhook endpoint pointing at this PR's preview URL. Stripe has no "branch" concept, so you create a real endpoint through the API, per PR, and get back a signing secret (whsec_...) that is shown exactly once — and that your app needs in its environment.
Then all of it — the branch connection string, the Redis URL and token, the webhook secret — has to land in a GitHub Environment named pr-<number>, so the deployment job can consume it. Writing a secret into a GitHub Environment via the API means fetching the environment's public key and encrypting the value with Libsodium before the PUT. There is no plain gh secret set shortcut for a value you computed thirty seconds ago in the same job.
None of this is hard, exactly. It's four providers' worth of API quirks in one workflow, and it works. I've run setups like this for years.
The cleanup workflow is where good intentions go to die
The second workflow — preview-down.yml, triggered on pull_request: closed — has to undo all of it, in the right order, including the Stripe endpoint and the GitHub Environment itself. In practice, this file:
- doesn't get written until the first bill, or the first "why are there so many branches?" moment;
- fails silently, because a cleanup job failing on a closed PR notifies nobody;
- gets skipped when someone closes a PR while the workflow is disabled, or edits the branch name format in the up-file and not in the down-file;
- has no way to verify deletion — it fires DELETE requests and trusts them.
So you end up with a fifth artifact: a periodic cleanup script, written by whoever got annoyed last, matching resources by name prefix and deleting on sight. That script is now load-bearing infrastructure with one author and no tests.
The deeper issue isn't effort. It's that no single artifact describes the environment. The truth about "what is pr-482?" lives in four consoles and two YAML files. Nothing can answer "does everything belonging to this PR still exist — and did it all get deleted?", because nothing ever held the whole list.
The EnvTie way: one blueprint, one lifecycle
EnvTie inverts this. You describe the whole preview once — components, how their outputs bind into GitHub Environment secrets/variables (or Vercel env), and a lifetime policy — then plan → approve → apply → destroy operates on that description.
Hosted control plane (dashboard, state, signed plans) + runner on your side (GitHub Actions, Docker, or CLI). Provider API keys stay on the runner. The control plane keeps resource IDs and statuses — not your Neon/Upstash/Stripe tokens.
Built-in adapters today: Neon, Upstash (Redis and Vector), Stripe, Sentry, Mailtrap, Resend, Vercel (Hosting and Blob), and GitHub. Custom adapters use a JSON stdin/stdout protocol with a Go SDK. The customer-side core is Apache-2.0.
You can compose a blueprint in the dashboard wizard or edit the YAML directly. Same model either way.
Step 1 — The runner holds the keys, once
Static provider keys are entered once at runner setup and never cross the control plane. For GitHub Environment secrets, EnvTie uses a workspace GitHub App (manifest install; the private key is born in your account) because managing environment secrets needs an installation token — the default GITHUB_TOKEN can't do it.
The runner mints short-lived tokens in memory, calls the providers itself, and reports back names, IDs, URLs, and statuses. Secret values are not part of what it sends home.
Step 2 — One file describes the environment
Real blueprint shape: components, bindings, and policy.
name: pr-preview
components:
- id: postgres
adapter: neon@1
ownership: managed
config:
project_id: REPLACE_WITH_NEON_PROJECT_ID
database_name: REPLACE_WITH_NEON_DATABASE
role_name: REPLACE_WITH_NEON_ROLE
- id: redis
adapter: upstash@1
ownership: managed
depends_on: [postgres]
config:
email: REPLACE_WITH_UPSTASH_EMAIL
platform: aws
primary_region: us-east-1
- id: stripe_webhook
adapter: stripe@1
ownership: managed
config:
host: "{env}.preview.example.com"
path: /api/webhooks/stripe
events: payment_intent.created,payment_intent.succeeded,checkout.session.completed,customer.subscription.updated,invoice.paid
- id: github
adapter: github@1
ownership: managed
depends_on: [postgres, redis, stripe_webhook]
config:
repository: owner/name
deployment: "true"
bindings:
- from: postgres.DATABASE_URL
to: github.environment.secret.DATABASE_URL
- from: redis.UPSTASH_REDIS_REST_URL
to: github.environment.variable.UPSTASH_REDIS_REST_URL
- from: redis.UPSTASH_REDIS_REST_TOKEN
to: github.environment.secret.UPSTASH_REDIS_REST_TOKEN
- from: stripe_webhook.STRIPE_WEBHOOK_SECRET
to: github.environment.secret.STRIPE_WEBHOOK_SECRET
- from: stripe_webhook.STRIPE_WEBHOOK_ENDPOINT_ID
to: github.environment.variable.STRIPE_WEBHOOK_ENDPOINT_ID
policy:
expires_in: 1d
protect: false
Bindings are references, not values. When the runner creates the Neon branch, it receives the connection string locally and writes it into the GitHub Environment from the runner process. The value never sits in the blueprint, in git, or in the control plane.
You don't have to write this file by hand, by the way: the dashboard wizard generates the blueprint from your existing Neon and Upstash projects, and the REPLACE_WITH_* values above are simply the project, database, and role IDs from those accounts.
Need a preview host too? Add vercel@1 (Hosting) and bind outputs into vercel.env.* the same way. Email sandbox for the PR? Mailtrap or Resend adapters follow the same pattern.
Step 3 — Plan before anything moves
Before create, EnvTie computes a plan: components to create, bindings to write, policy for lifetime. Plans are Ed25519-signed and bound to the workspace. If policy requires it, apply waits for owner approval. What gets applied is byte-for-byte what was reviewed — a changed blueprint means a new plan, not a quiet edit.
That matters in the agent era: when a coding agent opens fifty PRs a day, the plan is the checkpoint a human — or a policy — actually reads.
Step 4 — The PR (or dashboard) runs the plan
Opening a PR creates the environment from the approved plan. Your runner executes it and records what was created, resource by resource.
Your app boots with its own database branch, its own Redis, and a Stripe webhook whose signing secret matches the URL it is actually reachable at — which, if you've ever tested webhooks against a preview deploy using a secret copied from a dashboard, you will appreciate.
You can also plan and apply from the EnvTie dashboard for non-PR environments; the same runner and signed-plan path applies.
What happens when the PR closes
This is the half I actually built the product for.
Closing or merging the PR triggers destroy. Destroy runs in reverse dependency order, deletes only managed resources EnvTie owns, and then verifies absence: each resource is read back after deletion. The environment is not reported Destroyed until those reads confirm it's gone.
A failed destroy is surfaced, not swallowed by a dead workflow run. If a destroy event is missed, the environment's own expires_in policy is the backstop — same verified destroy.
That's the difference in one sentence: in the manual setup, cleanup is a hope expressed in YAML. Here, it's a state machine with a receipt.
Honest limitations
-
Something has to run on your side. The runner is real — GitHub Actions, Docker (
ghcr.io/coddicat/envtie-runner), or the CLI. If you want a hosted service that holds all your provider keys, this is deliberately not that product. Keys staying with you is the feature; it costs an install step. - If you have exactly one provider, you don't need this. A Neon branch per PR via Neon's own action is twenty lines, and it's fine. The blueprint pays for itself when the environment is three or more services, plus the wiring between them, and when someone has to answer for cleanup.
- Public beta. The adapter list is the list above; anything else means a custom adapter. The protocol is documented; it's still work. Core is Apache-2.0 at github.com/coddicat/envtie-core.
- 'Why not Terraform?' Terraform provisions resources well. What it doesn't own is the lifecycle of a pull-request environment as one object: the signed plan a human approves, the fresh outputs bound into that PR's GitHub Environment, the expiry backstop, and the read-back check that everything is actually gone. That layer is what EnvTie is.
Try it
Free is $0, no credit card: 3 active environments, signed plans with owner approval, automatic expiry with verified destroy, and all adapters — built-in and custom. Team is $49/mo per workspace when you outgrow Free; provider bills (Neon, Upstash, Vercel, Stripe, …) stay with those providers.
Docs and quick start: envtie.dev
If your Neon console also has a graveyard of pr-* branches, I'd genuinely like to hear the count.
Disclosure: I built EnvTie. The manual setup above is the kind I ran before it — and cleaning it up by hand is exactly what I got tired of.







Top comments (0)