Skip to main content

Ephemeral Environments: Technical Architecture

This document describes the complete technical implementation of GAP's ephemeral environment system. It covers every component involved from the initial PR label trigger through CI/CD dispatch, manifest generation, ArgoCD deployment, Traefik routing, identity management, notifications, and automated cleanup.

For the user-facing guide on how to use ephemeral environments, see Ephemeral Environments.

Architecture Overview

Ephemeral environments create temporary, isolated deployments of applications from pull request branches. The system spans eight repositories and multiple infrastructure components:

┌─────────────────────────────────────────────────────────────────────────────────┐
│ EPHEMERAL ENVIRONMENT FLOW │
│ │
│ Code Repo PR │
│ (label: ephemeral-deploy) │
│ │ │
│ ▼ │
│ gap-workflow-dispatch-action ──repository_dispatch──► Manifest Repo │
│ (PR created, not merged) │
│ │ │
│ ▼ │
│ ArgoCD ApplicationSet │
│ (PR Generator) │
│ │ │
│ ▼ │
│ ArgoCD Application │
│ (ephemeral CMP plugin) │
│ │ │
│ ▼ │
│ Gappynator Controller │
│ (K8s resources + HTTPRoutes)│
│ │ │
│ ▼ │
│ Traefik Gateway │
│ (subdomain + header routing)│
│ │
│ platform-githubbot ◄─────── webhooks ───────► Cleanup & PR comments │
│ ArgoCD Notifications ──────────────────────► PR sync status comments │
└─────────────────────────────────────────────────────────────────────────────────┘

Repositories Involved

RepositoryRole
Code repository (e.g., platform-test-api)Source code. PR workflow triggers ephemeral deployment
gap-workflow-dispatch-actionGitHub Action that dispatches repository_dispatch to manifest repo
<team>-kubernetes-manifestsKubernetes manifests. Receives dispatch, creates PR with ephemeral config
platform-reusable-workflowsReusable workflow that creates/updates the manifest PR
argocdArgoCD ApplicationSet definitions (PR generator per team)
gappynatorKubernetes operator that reconciles Application CRDs into K8s resources
terraform-modules-kubernetes-config-and-toolsTerraform modules for ArgoCD, CMP plugin, notifications, Traefik gateway
platform-githubbotGitHub App bot for lifecycle automation (comments, label sync, cleanup)

Phase 1: Triggering an Ephemeral Deployment

Code Repository Workflow

A code repository implements a PR release workflow that triggers on the ephemeral-deploy label. Here is the pattern (using platform-test-api as an example):

name: "Test PR Release"

on:
pull_request:
types: [labeled, synchronize]

jobs:
release:
if: >
(github.event.action == 'labeled' && github.event.label.name == 'ephemeral-deploy')
|| (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'ephemeral-deploy'))
runs-on: 'ubuntu-latest'
steps:
- uses: actions/checkout@v6
# ... build steps (compile, Docker build/scan/push) ...
- name: "GAP Workflow Dispatch"
uses: gjensidige/gap-workflow-dispatch-action@main
with:
token: ${{ secrets.KUBERNETES_MANIFESTS_TOKEN }}
repository: platform-kubernetes-manifests
environment: "test"
deployment-type: backend
container-image: ${{ steps.build-scan-push.outputs.image }}
ephemeral-name: ${{ github.event.pull_request.number }}

Two trigger scenarios:

EventConditionWhen
labeledgithub.event.label.name == 'ephemeral-deploy'User adds the label to a PR
synchronizePR already has the ephemeral-deploy labelNew commits pushed to the PR branch

The ephemeral-name is set to the PR number, creating a unique identifier for this ephemeral environment.

gap-workflow-dispatch-action

This composite GitHub Action is the bridge between code repositories and manifest repositories. It constructs a repository_dispatch event and sends it to the target manifest repository via the GitHub API.

Inputs:

InputRequiredDefaultDescription
tokenYesRepo-scoped PAT for dispatch
ownerNogjensidigeTarget GitHub org
repositoryYesTarget manifest repo name
event-typeNoreleaseCustom webhook event name
environmentYestest or prod
deployment-typeYesfrontend or backend
container-imageNoContainer image reference
container-image-tagNoContainer image tag
ephemeral-nameNo""Ephemeral env identifier (e.g., PR number)
ephemeral-app-nameNo""App name override for ephemeral env
app-nameNo""App name override (monorepo support)
manifest-directoryNo""Manifest directory override (monorepo)

Payload structure sent to the manifest repo:

{
"env": "test",
"image": "gjensidige.azurecr.io/platform/platform-test-api:tag@sha256:...",
"tag": "sha-buildid",
"type": "backend",
"ephemeralConfig": {
"name": "42",
"appName": ""
},
"meta": {
"repo": "platform-test-api",
"commitSha": "abc123...",
"runId": "12345",
"actor": "username"
}
}

The action itself does not differentiate between ephemeral and regular deployments — it always sends the ephemeralConfig object. The distinction is made downstream by checking if ephemeralConfig.name is non-empty.


Phase 2: Manifest Repository PR Creation

Reusable Workflow: gap-update-manifest-container-release.yml

Located in platform-reusable-workflows, this is the core workflow that handles both regular and ephemeral deployments. Manifest repositories (e.g., platform-kubernetes-manifests) call it via:

# platform-kubernetes-manifests/.github/workflows/release.yaml
on:
repository_dispatch:
types: [release]
jobs:
release:
uses: gjensidige/platform-reusable-workflows/.github/workflows/gap-update-manifest-container-release.yml@main
secrets: inherit

Guard

Only runs if the dispatch actor is gjensidige-github-bot[bot] or gappynator-sync[bot].

Branch Strategy

The branch naming diverges based on whether this is ephemeral:

if [[ -z "$EPHEMERAL_NAME" ]]; then
# Regular: unique per commit (auto-merged immediately)
branch_name="feat/$APP_NAME-$ENV-$COMMIT_SHA"
else
# Ephemeral: stable per PR number (reused across updates)
branch_name="feat/$APP_NAME-$ENV-$EPHEMERAL_NAME"
fi

Ephemeral branches are stable — named by PR number, not commit SHA. This means subsequent pushes to the same ephemeral environment reuse the same branch and PR, updating params.json in place.

If the branch already exists (ephemeral update), it is checked out and pulled. Otherwise, a new branch is created from main.

params.json Update

The workflow writes the manifest directory's params.json with the new container image and metadata:

{
"container_image": "gjensidige.azurecr.io/platform/platform-test-api:tag@sha256:...",
"container_image_tag": "sha-buildid",
"github": {
"workflow_actor_username": "username",
"repo_name": "platform-test-api",
"repo_commit_sha": "abc123...",
"manifest_repo_name": "platform-kubernetes-manifests",
"manifest_repo_commit_sha": "def456..."
}
}

PR Title Format

The PR title is critically structured — ArgoCD ApplicationSets parse it via regex:

# Regular:
feat(platform-test-api/test): Deploy image tag sha-buildid

# Ephemeral:
feat(platform-test-api/test): ephemeral deploy (name=42,app=platform-test-api)

Format: feat(<appPath>/<env>): ephemeral deploy (name=<ephemeralName>,app=<appName>)

PR Lifecycle Differences

AspectRegularEphemeral
Branch namefeat/<app>-<env>-<commitSha>feat/<app>-<env>-<ephemeralName>
Branch reuseNo (new per deploy)Yes (stable per ephemeral env)
PR titleDeploy image tag <tag>ephemeral deploy (name=...,app=...)
Labelsdeploydeploy + ephemeral-deploy
Auto-mergeYes (immediate)No (PR stays open)
PR update on rebuildNew PR createdExisting PR body updated

Ephemeral PRs are intentionally not auto-merged. The open PR serves as the lifecycle anchor — ArgoCD's PullRequest generator watches for open PRs to create ephemeral Applications.


Phase 3: ArgoCD ApplicationSet (PR Generator)

ApplicationSet Template

Located at argocd/templates/applicationset.libsonnet, this Jsonnet template defines the ApplicationSet that discovers and deploys ephemeral environments. Each team gets one ApplicationSet per cluster, instantiated as:

// argocd/applicationsets/apps-int-test/team-platform.jsonnet
local applicationset = import '../../templates/applicationset.libsonnet';
applicationset { team:: 'platform' }

Pull Request Generator

generators: [{
pullRequest: {
github: {
owner: 'gjensidige',
repo: '<team>-kubernetes-manifests',
appSecretName: 'repo-credentials-github-gjensidige-appset',
labels: ['ephemeral-deploy'],
},
requeueAfterSeconds: 60,
values: {
appPath: '{{ regexReplaceAll `^feat\\(([^)]+)\\).*` .title "${1}" }}',
appName: '{{ regexReplaceAll `^.*,app=([^)]+)\\)$` .title "${1}" }}',
environment: '{{ regexReplaceAll `^feat\\(.+/(test|prod)\\).*` .title "${1}" }}',
ephemeralName: '{{ regexReplaceAll `^.*\\(name=([^,]+),.*` .title "${1}" }}',
},
},
}]
  • Watches the <team>-kubernetes-manifests repo for PRs labeled ephemeral-deploy
  • Polls every 60 seconds
  • Extracts metadata by regex-parsing the PR title
  • Uses a GitHub App secret (repo-credentials-github-gjensidige-appset) for authentication

Generated Application Resource

For a PR titled feat(platform-test-api/test): ephemeral deploy (name=42,app=platform-test-api):

FieldValue
Nameplatform-test-api.test.42.apps-int-test
Labelsargocd.gjensidige.io/ephemeral: "true", team: platform
Finalizerresources-finalizer.argocd.argoproj.io
Source repoURLhttps://github.com/gjensidige/platform-kubernetes-manifests.git
Source targetRevision{{ .head_sha }} (PR head commit)
Source pluginname: ephemeral
Sync policyAutomated with prune + selfHeal

Ephemeral CMP Plugin Parameters

The Application source uses the ephemeral Config Management Plugin with these parameters:

ParameterValuePurpose
APP_PATHapps/platform-test-api/testWhich manifest directory to render
NAME_SUFFIX-42Suffix appended to all resource names
COMMON_ANNOTATIONSephemeral.gap.io/name: 42, ephemeral.gap.io/instance-of: platform-test-apiEphemeral identity annotations
COMMON_LABELSapp: platform-test-apiApp label pointing to parent

Regular vs. Ephemeral Applications in ArgoCD

AspectRegularEphemeral
DefinitionStatic Jsonnet in applications/<cluster>/Dynamically generated by ApplicationSet
TriggerMerged to mainOpen PR with ephemeral-deploy label
Target revisionmainPR head_sha
Manifest renderingJsonnet or Kustomize directlyephemeral CMP plugin with NAME_SUFFIX
Auto-syncOptional (defaults off)Always on with prune + selfHeal
LifecyclePersistentAutomatic — PR open = deploy, PR close = destroy
Labelsregister-deploy=true, update-github-status=trueephemeral=true
Naming<app>.<cluster><app>.<env>.<ephemeralName>.<cluster>
NotificationsSlack + GitHub commit status + deploy registrationGitHub PR comments only

Deployment Topology

ApplicationSets are deployed per cluster × team:

ClusterEnvironmentEphemeral enabled
apps-int-testtestYes (all teams)
lab-int-testlabYes (platform only)
apps-int-prodprodNo
tools-mgt-prodprodNo
tools-mgt-testtestNo

Ephemeral environments are test/lab only — never production.

ApplicationSet Bootstrap via Terraform

The Terraform module at terraform-modules-kubernetes-config-and-tools/modules/tools/argocd/ bootstraps ApplicationSets:

  • Only deploys when var.environment != "prod"
  • Creates an ArgoCD Application per managed cluster pointing to gjensidige/argocd repo's applicationsets/<cluster-identifier>/ path
  • Renders Jsonnet with clusterIdentifier and destinationName external vars
  • Auto-syncs with prune enabled

CMP Plugin Infrastructure

The ephemeral CMP (Config Management Plugin) runs as a sidecar container in argocd-repo-server:

variable "cmp" {
type = map(object({
image = string
env = optional(map(string), {})
}))
}

When configured (e.g., cmp = { ephemeral = { image = "..." } }), the Terraform module injects sidecar containers with:

  • The CMP image (contains plugin.yml with discover and generate commands)
  • Volume mounts for ArgoCD's var-files and plugin directories
  • RunAsUser: 10001 (matching ArgoCD's user)

The CMP handles rendering Jsonnet or Kustomize manifests and applying NAME_SUFFIX, COMMON_ANNOTATIONS, and COMMON_LABELS to create uniquely-named ephemeral resources.


Phase 4: Gappynator Controller (Kubernetes Resource Reconciliation)

The gappynator operator reconciles Application Custom Resources into Kubernetes objects. For ephemeral environments, it has specialized behavior driven by annotations on the Application CR.

Ephemeral Detection

Two annotations trigger ephemeral behavior:

const EphemeralAnnotation = "ephemeral.gap.io/instance-of"   // parent app name
const EphemeralNameAnnotation = "ephemeral.gap.io/name" // ephemeral identifier

The IsEphemeral() check also blocks ephemeral in production:

func IsEphemeral(application *v1.Application) bool {
return application.Annotations[EphemeralAnnotation] != "" && config.Environment != "prod"
}

HTTPRoute Creation (Dual Routing)

Ephemeral apps always use HTTPRoute (regardless of the parent's spec.ingress.kind). Two HTTPRoutes are created per ephemeral app:

Route 1: Subdomain-Based Access

FieldValue
Name<app-name>-<ephemeral-name> (e.g., platform-test-api-42)
Hostname<ephemeral-name>.<parent-host> (e.g., 42.platform-test-api.apps-int.testgjensidige.io)
RulePathPrefix: / → backend service on port 80
ParentReftraefik-gateway in system-traefik

Route 2: Header-Based Access

FieldValue
Name<app-name>-<ephemeral-name>-header
Hostname<parent-host> (same as parent app)
RulePathPrefix: / + Header X-Ephemeral-Name: <ephemeral-name> → backend service on port 80
ParentReftraefik-gateway in system-traefik

The header-based route uses an exact match on the X-Ephemeral-Name header:

headerMatchExact := gatewayv1.HeaderMatchExact
headers := []gatewayv1.HTTPHeaderMatch{
{
Type: &headerMatchExact,
Name: "X-Ephemeral-Name",
Value: ephemeralName,
},
}

When this header is present, Traefik routes the request to the ephemeral deployment instead of the parent. When absent, requests reach the parent deployment as usual. This is especially useful for APIs behind junctions, where subdomain-based routing would require junction reconfiguration.

Identity and ServiceAccount Reuse

Ephemeral apps do not create their own identity resources. Instead, they reuse the parent application's ServiceAccount and Azure identity. The following controllers skip for ephemeral apps:

ControllerSkip Behavior
UserAssignedIdentityNo Azure identity created
FederatedIdentityCredentialNo federation created
ServiceAccountNo SA created (reuses parent's)
SecretProviderClassNo SPC created

The Deployment uses the parent's ServiceAccount:

func GetServiceAccountName(application *v1.Application) string {
if IsEphemeral(application) {
return application.Annotations[EphemeralAnnotation] // parent app name
}
return application.Name
}

The ASO ConfigMap lookup also resolves via the parent name (aso-<parent-name>), providing the parent's clientId/principalId for Azure identity.

Why this is secure:

  • ServiceAccounts are namespace-scoped — apps in different namespaces cannot impersonate each other
  • Ephemeral is disabled in production (config.Environment != "prod")
  • An ephemeral app can only reference apps in the same namespace

Resource Reconciliation Summary

Ephemeral Application CR (with annotations)

├─► UserAssignedIdentity: SKIPPED
├─► FederatedIdentityCredential: SKIPPED
├─► ServiceAccount: SKIPPED (reuses parent's)
├─► SecretProviderClass: SKIPPED
├─► AsoGeneratedConfigMap: Reads PARENT's configmap (aso-<parent-name>)
├─► Deployment: Created with serviceAccountName = parent name
├─► Service: Created normally
├─► Ingress: SKIPPED (deleted if exists)
├─► HTTPRoute #1: Subdomain: <eph-name>.<parent-host>
├─► HTTPRoute #2: Header: parent-host + X-Ephemeral-Name
└─► Other controllers: Normal operation (HPA, PDB, NetworkPolicy, etc.)

Phase 5: ArgoCD Notifications

Notification Configuration

Configured via Terraform at terraform-modules-kubernetes-config-and-tools/modules/tools/argocd/notifications.tf:

Services:

ServicePurpose
service.githubGitHub App integration for commit statuses and PR comments
service.slackSlack notifications
service.webhook.register-deployDeploy registration for audit trail

Ephemeral-Specific Templates

github-pr-success — posted on sync success:

✅ Deployment succeeded
Application: <app-name>
Revision: <commit-sha>
Health: <health-status>
ArgoCD: <link>

github-pr-failure — posted on sync failure:

❌ Deployment failed
Application: <app-name>
Revision: <commit-sha>
Health: <health-status>
Sync Status: <sync-status>
ArgoCD: <link>

Ephemeral Triggers

trigger.on-sync-succeeded-ephemeral:
- oncePer: app.status.sync.revision
when: app.status.operationState.phase in ['Succeeded'] and
app.status.health.status == 'Healthy'
send: [github-pr-success]

trigger.on-sync-failed-ephemeral:
- oncePer: app.status.operationState.syncResult.revision
when: app.status.operationState.phase in ['Failed', 'Error']
send: [github-pr-failure]

Subscription Routing

Label selectors determine which apps get which notifications:

subscriptions:
# Regular apps: Slack + GitHub commit status + deploy registration
- recipients: [register-deploy:'']
triggers: [on-deployed]
selector: argocd.gjensidige.io/register-deploy=true

- recipients: [github:'']
triggers: [on-deployed, on-sync-failed, on-sync-running]
selector: argocd.gjensidige.io/update-github-status=true

# Ephemeral apps: GitHub PR comments only
- recipients: [github:'']
triggers: [on-sync-succeeded-ephemeral, on-sync-failed-ephemeral]
selector: argocd.gjensidige.io/ephemeral=true

Current limitation (V1): Comments are posted on the manifest repo PR, because the Application's spec.source.repoURL points to the manifest repo.

Planned V2: Encode repo=<owner/repo>,sha=<commitSha> in the manifest PR title, extract it as annotations via the ApplicationSet, and use those annotations to post comments directly on the code repo PR.


Phase 6: Lifecycle Automation (platform-githubbot)

The platform-githubbot is a Slack Bolt app with a GitHub webhook listener that automates ephemeral environment lifecycle events.

Webhook Events Handled

webhooks.on('pull_request.labeled', handlePullRequestLabeled);
webhooks.on('pull_request.unlabeled', handlePullRequestUnlabeled);
webhooks.on('pull_request.closed', handlePullRequestClosed);

PR Linking Mechanism

The bot links code repo PRs and manifest repo PRs through two independent methods:

Manifest PR → Code PR: Parses the manifest PR body for structured metadata:

- Repository: https://github.com/{owner}/{repo}
- Commit SHA: `{sha}`

Uses the commit SHA to find the associated open PR via listPullRequestsAssociatedWithCommit. Handles merge commits by using the second parent SHA.

Code PR → Manifest PR: Parses the bot's previously-posted comment for the manifest PR URL:

See details here: https://github.com/{owner}/{repo}/pull/{number}

Event: pull_request.labeled (Deployment Start)

Trigger conditions:

  • Label name is ephemeral-deploy
  • Repository name ends with -manifests
  • PR title includes "ephemeral deploy"

Actions:

  1. Parse manifest PR body to find linked code repo and commit SHA
  2. Find the open code PR associated with that commit
  3. Post (or update) a comment on the code PR:
    Ephemeral environment deployed! 🚀

    See details here: <manifest_pr_url>

Event: pull_request.closed (Cleanup Chain Initiator)

Simple trigger — if the closed PR has the ephemeral-deploy label, the bot removes the label. This fires the pull_request.unlabeled event, which runs the actual cleanup.

This pattern centralizes all cleanup logic in the unlabeled handler.

Event: pull_request.unlabeled (Cleanup Executor)

If the removed label is ephemeral-deploy, runs cleanupEphemeralEnvironment():

Path A — Triggered from manifest repo:

  1. Parse manifest PR body → find linked code PR
  2. Update code PR comment: "deployed 🚀""terminated 🗑️"
  3. Remove ephemeral-deploy label from code PR
  4. Close manifest PR and delete manifest branch

Path B — Triggered from code repo:

  1. Find bot's comment on code PR → extract manifest PR URL
  2. Close manifest PR and delete manifest branch
  3. Update code PR comment: "deployed 🚀""terminated 🗑️"

Comment States

StateText
DeployedEphemeral environment deployed! 🚀 + manifest PR link
TerminatedEphemeral environment is terminated. 🗑️ + manifest PR link

Loop Prevention

Multiple safeguards prevent infinite event loops:

  1. closePrAndDeleteBranch checks pr.state === 'open' before closing
  2. updateBotCommentToTerminated checks the comment doesn't already contain "terminated"
  3. The labeled handler only fires for manifest repos (name ends with -manifests)
  4. removeEphemeralLabel silently catches "label not found" errors

Cleanup Flow Diagrams

Code PR closed or label removed:

Code PR unlabeled/closed
→ Bot removes "ephemeral-deploy" label (if closed)
→ unlabeled handler fires
→ Finds manifest PR URL from bot comment
→ Closes manifest PR + deletes manifest branch
→ ArgoCD ApplicationSet removes Application (finalizer cleans up K8s resources)
→ Updates code PR comment to "terminated 🗑️"

Manifest PR closed or label removed:

Manifest PR unlabeled/closed
→ Bot removes "ephemeral-deploy" label (if closed)
→ unlabeled handler fires
→ Finds linked code PR via PR body metadata
→ Updates code PR comment to "terminated 🗑️"
→ Removes "ephemeral-deploy" label from code PR
→ Closes manifest PR + deletes manifest branch
→ ArgoCD ApplicationSet removes Application (finalizer cleans up K8s resources)

Complete End-to-End Flow

Deployment

1. Developer adds "ephemeral-deploy" label to Code PR

2. Code repo workflow triggers (test-pr.yml → labeled event)
├── Builds application (e.g., mvn package)
├── Docker build, scan, push to Azure Container Registry
└── Calls gap-workflow-dispatch-action with ephemeral-name=<PR#>

3. gap-workflow-dispatch-action sends repository_dispatch to manifest repo
└── client_payload includes ephemeralConfig.name = <PR#>

4. Manifest repo release.yaml receives dispatch
└── Delegates to gap-update-manifest-container-release.yml (reusable workflow)

5. Reusable workflow:
├── Creates branch: feat/<app>-<env>-<PR#>
├── Updates params.json with new container image
├── Creates PR titled: feat(<app>/<env>): ephemeral deploy (name=<PR#>,app=<app>)
├── Adds labels: deploy + ephemeral-deploy
└── Does NOT auto-merge (PR stays open)

6. ArgoCD ApplicationSet detects PR (polls every 60s)
├── PR Generator matches: label=ephemeral-deploy
├── Regex-parses title → appPath, appName, ephemeralName, environment
└── Creates ArgoCD Application:
├── targetRevision = PR head SHA
├── plugin = "ephemeral" CMP
└── syncPolicy = automated + prune + selfHeal

7. ArgoCD syncs Application via ephemeral CMP
├── Renders Jsonnet/Kustomize with NAME_SUFFIX
└── Applies COMMON_ANNOTATIONS + COMMON_LABELS

8. Gappynator controller reconciles Application CRD:
├── Skips identity creation (reuses parent's ServiceAccount)
├── Creates Deployment with parent's ServiceAccount
├── Creates Service
├── Creates HTTPRoute #1: <PR#>.<parent-host> (subdomain)
└── Creates HTTPRoute #2: <parent-host> + X-Ephemeral-Name header

9. Traefik Gateway serves traffic:
├── Subdomain: https://<PR#>.<parent-host>
└── Header: https://<parent-host> with X-Ephemeral-Name: <PR#>

10. platform-githubbot posts comment on code PR:
"Ephemeral environment deployed! 🚀"

11. ArgoCD Notifications post sync status on manifest PR:
"✅ Deployment succeeded" or "❌ Deployment failed"

Update (New Commits to PR)

1. Developer pushes new commits to Code PR (label still present)

2. Code repo workflow triggers (synchronize event)
├── Rebuilds application
└── Dispatches to manifest repo (same ephemeral-name)

3. Reusable workflow:
├── Detects existing branch → checks out and pulls
├── Updates params.json with new image
└── Updates existing PR body (no new PR)

4. ArgoCD detects updated head_sha → re-syncs Application

Cleanup

1. Developer closes Code PR (or removes ephemeral-deploy label)

2. platform-githubbot:
├── Removes ephemeral-deploy label (triggers unlabeled event)
├── Finds manifest PR from bot comment
├── Closes manifest PR + deletes branch
└── Updates comment: "Ephemeral environment is terminated. 🗑️"

3. ArgoCD ApplicationSet:
├── PR no longer matches (closed/missing label)
└── Deletes ArgoCD Application

4. ArgoCD Application finalizer:
└── Deletes all managed Kubernetes resources
(Deployment, Service, HTTPRoutes, HPA, PDB, NetworkPolicy, etc.)

Key Constants and Configuration Reference

ConstantValueLocation
Ephemeral annotationephemeral.gap.io/instance-ofgappynator util.go
Ephemeral name annotationephemeral.gap.io/namegappynator util.go
Header nameX-Ephemeral-Namegappynator http_route.go
Gateway nametraefik-gatewaygappynator http_route.go
Gateway namespacesystem-traefikgappynator http_route.go
Backend service port80gappynator http_route.go
Default ingress kindHTTPRoutegappynator application_types.go
Production guardconfig.Environment != "prod"gappynator util.go
Header route name suffix-headergappynator http_route.go
ApplicationSet labelargocd.gjensidige.io/ephemeral=trueargocd applicationset.libsonnet
PR labelephemeral-deployAll repositories
PR title formatfeat(<path>/<env>): ephemeral deploy (name=<name>,app=<app>)reusable workflows
Dispatch event typereleasegap-workflow-dispatch-action
GitHub App secretrepo-credentials-github-gjensidige-appsetargocd applicationset.libsonnet
CMP plugin nameephemeralargocd applicationset.libsonnet
Polling interval60 secondsargocd applicationset.libsonnet

Design Decisions

  1. PR as lifecycle anchor: Ephemeral PRs in the manifest repo stay open, serving as the source of truth for ArgoCD's PullRequest generator. Closing the PR triggers automatic cleanup.

  2. Stable branch naming: Ephemeral branches use the identifier (PR number), not commit SHA. This enables branch/PR reuse across updates.

  3. PR title as structured data: The title format serves as a machine-readable contract between the reusable workflow and ArgoCD's regex-based PR generator.

  4. Identity reuse over recreation: Ephemeral apps inherit the parent's ServiceAccount and Azure identity instead of creating new ones. This avoids the overhead of Azure identity provisioning and is secure due to namespace isolation.

  5. Dual routing: Both subdomain and header-based routing are created for every ephemeral env, giving flexibility for direct access and junction-proxied access.

  6. Production guard: Ephemeral environments are blocked in production at the gappynator controller level and omitted from production ArgoCD ApplicationSet definitions.

  7. Chain-reaction cleanup: All cleanup logic is centralized in the unlabeled handler. Closing a PR or removing a label both converge to the same cleanup path, with loop prevention safeguards.

  8. CMP plugin for manifest rendering: Using a Config Management Plugin sidecar allows the same manifest directory to serve both regular and ephemeral deployments with different name suffixes and annotations.