Back to blog
El bot de GitHub anunciando el preview listo con su URL y los cinco checks en verde, junto al árbol de ArgoCD con los recursos que ese pull request ha levantado

A full environment for every pull request — deleted automatically

LabGitOpsKubernetesDevOps

Reviewing a pull request by reading the diff works for the logic and not much else. If the change touches a migration, a Helm template or the behavior of a screen, approving it is an act of faith.

The alternative is for every pull request to spin up its own complete environment, and for it to be deleted automatically when it's closed.

What exactly gets spun up

In the homelab, when a PR carries the preview label, ArgoCD's Pull Request Generator detects it in under a minute and creates the entire environment.

ResourceWhat the PR gets
NamespaceIts own, with the PR number
DatabaseIts own PostgreSQL, via CNPG
SearchIts own Typesense instance
SecretsIts own, from Infisical
DomainIts own subdomain
Everything with its own name: nothing is shared with staging.

The list isn't what matters — what matters is that it shares nothing with staging. A destructive migration in a preview breaks that preview and nothing else.

The two halves meet at the SHA

The setup is two pieces that never talk to each other: GitHub Actions builds the images and ArgoCD deploys the manifests. Nobody calls anybody. What keeps them aligned is that both use the pull request's commit as the identifier.

On the GitHub side, the workflow listens for PR events —opened, updated, reopened, labeled and closed— and only acts if it carries the preview label. It builds the four services in parallel and tags each image with the PR number and the SHA of its head:

tags: ${{ env.REGISTRY }}/${{ matrix.image }}:pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}

The matrix runs without canceling its siblings when one fails. That's not an oversight: the public registry's storage returns intermittent certificate errors, and with cancellation enabled a transient failure in one service would take down the build of the other three.

On the ArgoCD side there is no webhook. A pull request generator asks GitHub every sixty seconds for PRs carrying that label:

generators:
  - pullRequest:
      github:
        owner: Zetesis-Labs
        repo: ZetesisPortal
        labels:
          - preview
      requeueAfterSeconds: 60

For each PR it finds, it materializes three applications in two waves. And each one points at the exact commit of the pull request, not its branch:

WaveApplicationWhat it creates
0pr-secretsExternalSecrets from Infisical
0pr-infraPostgreSQL with CNPG and Typesense
1pr-appThe Helm chart with its domains and its tags
Three ApplicationSets, two waves. The application won't start until its secrets and its database exist.

The application's ApplicationSet is where the circle closes: it overrides the chart parameters with the same scheme the workflow used when tagging.

targetRevision: "{{ .head_sha }}"
- name: web.image.tag
  value: "pr-{{ .number }}-{{ .head_sha }}"
- name: web.domain
  value: "pr-{{ .number }}.staging.zetesis.xyz"

Manifest and image come from the same commit without either side knowing the other. Pushing another commit to the PR changes the SHA, and with it both the tag the workflow builds and the revision ArgoCD deploys change at once.

When the pull request closes, teardown happens on both sides: ArgoCD deletes the applications —and with them the entire namespace, with its database and its secrets— because the generator stops seeing it, and a final workflow job deletes that PR's images from Harbor. No orphaned environments and no registry growing with images nobody will ever download again.

And with real data

In Konect the preview starts by copying the staging data. That difference changes what you can test: migrations run against the real schema, with its volume and its edge cases, not against an empty database where everything passes.

That's where the migrations that take twenty minutes show up, or the ones that fail because there's a row with a null nobody expected.

It deletes itself

An ephemeral environment that doesn't clean itself up stops being ephemeral and becomes a cluster full of garbage. Cleanup is automatic when the PR closes, and the portal also has a twenty-four-hour cap.

The label matters too: a preview only spins up if the PR carries it. Without that, every documentation change would drag a database along with it.

When it's not worth it

The cost isn't CPU: it's that you need the platform underneath. Without GitOps, without external secrets and without a database that knows how to provision itself, building per-PR previews means building those three things first.

If you already have them, it's one of the cheapest things you can add. If you don't, the correct order is the reverse.

Keep reading