kontrolplane
not accepting clients |
english english nederlands nederlands
← back to blog

preview environments with argocd

argocd applicationsets can spin up full preview environments for every pull request and tear them down on merge. this post covers the setup, the moving parts, and what breaks when it's not configured carefully.

share

reviewing a pull request from a diff is one thing. running the actual change in an isolated environment before merging is something else entirely. preview environments give every pull request its own namespace, its own deployment, and its own url - so changes can be tested against real infrastructure instead of screenshots and speculation.

argocd makes this possible through applicationsets. rather than defining each environment by hand, a single applicationset watches a git repository for open pull requests and generates an argocd application for each one. when the pull request is merged or closed, the application and everything it deployed is removed automatically.

the short version

an applicationset with a pull request generator creates one argocd application per open pull request. each application deploys into its own namespace using values derived from the pr metadata - branch name, pr number, commit sha. when the pr closes, argocd deletes the application and its resources.

how the pull request generator works

the pull request generator polls a git hosting provider for open pull requests on a configured repository. for each open pr, it produces a set of parameters that can be used in the applicationset template.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: preview-environments
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - pullRequest:
        github:
          owner: organisation
          repo: repository
          tokenRef:
            secretName: github-token
            key: token
        requeueAfterSeconds: 60
  template:
    metadata:
      name: "preview-{{ .number }}"
    spec:
      project: previews
      source:
        repoURL: https://github.com/organisation/repository.git
        targetRevision: "{{ .head_sha }}"
        path: deploy/helm
        helm:
          valuesObject:
            image:
              tag: "{{ .head_short_sha }}"
            ingress:
              host: "pr-{{ .number }}.preview.example.com"
      destination:
        server: https://kubernetes.default.svc
        namespace: "preview-{{ .number }}"
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

each open pull request produces an application named preview-<pr-number> that deploys into a namespace with the same name. the helm values override the image tag to the pr’s commit sha and set the ingress hostname to a predictable url.

requeueAfterSeconds controls how often argocd polls the github api for changes. 60 seconds is a reasonable default. lower values increase api calls without meaningful improvement - the bottleneck is usually the image build, not the polling interval.

goTemplate: true enables go templating, which gives access to the full set of pr parameters: {{ .number }}, {{ .branch }}, {{ .head_sha }}, {{ .head_short_sha }}, and {{ .labels }}.

filtering pull requests

not every pull request needs a preview environment. draft prs, documentation changes, and dependabot bumps are usually not worth deploying. labels are the simplest filtering mechanism:

generators:
  - pullRequest:
      github:
        owner: organisation
        repo: repository
        tokenRef:
          secretName: github-token
          key: token
        labels:
          - preview
      requeueAfterSeconds: 60

adding labels: ["preview"] restricts the generator to pull requests with that label. this keeps the cluster from spinning up environments for every branch and gives developers explicit control over when a preview is created.

for more complex filtering, argocd supports multiple label selectors and branch matching with regex filters on branchMatch.

scoping resource permissions

preview environments should be scoped to their own argocd project. this limits what the generated applications can do and where they can deploy:

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: previews
  namespace: argocd
spec:
  description: preview environments for pull requests
  sourceRepos:
    - https://github.com/organisation/repository.git
  destinations:
    - namespace: "preview-*"
      server: https://kubernetes.default.svc
  namespaceResourceWhitelist:
    - group: ""
      kind: "*"
    - group: apps
      kind: Deployment
    - group: networking.k8s.io
      kind: Ingress
  orphanedResources:
    warn: true

the destinations field restricts preview applications to namespaces matching preview-*. this prevents a misconfigured template from deploying into production namespaces. namespaceResourceWhitelist limits the resource types that preview applications can create - there’s no reason a preview should be creating clusterroles or persistent volumes.

namespace lifecycle

CreateNamespace=true in the sync options tells argocd to create the namespace if it doesn’t exist. when the pull request closes and argocd deletes the application, the namespace and all its resources are removed with it - provided the application has the correct finalizer.

argocd adds the resources-finalizer.argocd.argoproj.io finalizer to applications by default. this ensures that deleting the application also deletes the resources it created. without it, the application object is removed but the deployments, services, and namespace stay behind.

verify the finalizer is present:

kubectl get application preview-42 -n argocd -o jsonpath='{.metadata.finalizers}'

if the output is empty, resources will be orphaned on deletion.

wiring up the image build

the applicationset handles deployment, but something needs to build and push the container image for each pull request. a ci pipeline triggered on pr events handles this:

# .github/workflows/preview.yaml
name: build preview image
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/organisation/repository:${{ github.event.pull_request.head.sha }}

the image is tagged with the full commit sha of the pr head. this matches the head_sha parameter that the applicationset uses. when a new commit is pushed to the branch, ci builds a new image and argocd picks up the updated sha on its next poll cycle.

dns and ingress

each preview environment needs a routable url. a wildcard dns record pointing *.preview.example.com to the cluster’s ingress controller is the simplest approach. combined with the ingress host from the applicationset template, each pr gets its own endpoint without any dns management per environment.

*.preview.example.com → ingress controller load balancer

shared dependencies

most applications need a database, a cache, or other backing services. preview environments have two options.

dedicated instances per preview. each namespace gets its own database. this gives full isolation but increases resource consumption and startup time. for lightweight databases like sqlite or embedded postgres, this works. for heavier dependencies it’s often not practical.

shared instances with logical isolation. all previews connect to the same database server but use separate databases or schemas named after the pr. the application’s helm values inject the database name:

helm:
  valuesObject:
    database:
      name: "preview_{{.number}}"
    image:
      tag: "{{.head_short_sha}}"

a job or init container creates the database on deploy and drops it on teardown. this balances isolation with resource efficiency, but requires the application to support dynamic database names.

resource limits

preview environments should be constrained. without limits, a handful of preview deployments can consume enough resources to affect production workloads on the same cluster. apply resource quotas to the preview namespace through the helm chart:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: preview-quota
spec:
  hard:
    requests.cpu: "500m"
    requests.memory: 512Mi
    limits.cpu: "1"
    limits.memory: 1Gi
    pods: "10"

this caps each preview at a reasonable ceiling. if previews need more resources than this allows, that’s worth investigating - a preview should be running a minimal replica count with reduced resource allocation, not mirroring production sizing.

a LimitRange in the namespace provides defaults for pods that don’t specify their own limits, preventing a single unconstrained pod from consuming the entire quota.

what to watch out for

stale environments. if argocd can’t reach the github api - token expired, rate limiting, network issue - it stops seeing closed pull requests and the corresponding environments are not cleaned up. monitor the applicationset controller logs for api errors and set up alerts on applicationset reconciliation failures.

github api rate limits. each poll cycle makes api calls proportional to the number of open pull requests. with a low requeueAfterSeconds and many open prs across multiple applicationsets, the token can hit github’s rate limit. use a dedicated github app token instead of a personal access token - app tokens have higher rate limits.

sync waves and ordering. if the helm chart uses sync waves to order resource creation - database migration job before deployment, for example - ensure the preview chart respects the same ordering. a common mistake is adding the migration job to the production chart but not the preview values.

secret management. preview environments need credentials for image pulls, database access, and external services. avoid copying production secrets into preview namespaces. use separate credentials with limited scope, or external secret operators that provision per-namespace secrets from a vault.

namespace deletion hangs. if a namespace contains resources with finalizers that can’t be resolved - a persistent volume claim waiting for a volume to detach, a custom resource whose controller is not running - the namespace deletion blocks indefinitely. kubectl get namespace preview-42 -o json and check status.conditions when this happens.

cleanup automation

as an additional safety net, a cronjob can sweep preview namespaces that have outlived their pull requests. this catches environments that slipped through because of api errors or race conditions during deletion. run it daily or after hours to avoid interfering with active work.

references

[1] argo cd documentation. “applicationset pull request generator.”
argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators-Pull-Request

[2] argo cd documentation. “applicationset go template.”
argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/GoTemplate

[3] argo cd documentation. “projects.”
argo-cd.readthedocs.io/en/stable/user-guide/projects

[4] argo cd documentation. “automated sync policy.”
argo-cd.readthedocs.io/en/stable/user-guide/auto_sync

[5] kubernetes documentation. “resource quotas.”
kubernetes.io/docs/concepts/policy/resource-quotas

[6] github documentation. “rate limits for github apps.”
docs.github.com/en/apps/creating-github-apps/registering-a-github-app/rate-limits-for-github-apps

[ ready when you are ]

no forms.
no calls.
just email.