DEV Community

Rodion Shlomo Solomonyk
Rodion Shlomo Solomonyk

Posted on Edited on

The hard part of preview environments isn't creating them. It's deleting them.

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.

Every team has this screenshot. The only question is how old the oldest branch is.

Every team has this screenshot. The only question is how old the oldest branch is.

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}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

One file holds the whole environment: components, bindings, and the policy that ends it.

One file holds the whole environment: components, bindings, and the policy that ends it.

This is the 24-second version of the point above: a preview database branched from real data, not a three-row `seed.sql`

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.

Nothing runs unsigned. The plan is the checkpoint.

Nothing runs unsigned. The plan is the checkpoint.

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.

The environment as one object — not four consoles.

The environment as one object — not four consoles.

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.

Approval to destroy an environment

Approval to destroy an environment

The receipt: destroyed, and read back from the providers to prove it.

The receipt: destroyed, and read back from the providers to prove it.

Before / after. The branch doesn't fade away; it's deleted, and the deletion is checked.

Before / after. The branch doesn't fade away; it's deleted, and the deletion is checked.

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)