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
| Repository | Role |
|---|---|
Code repository (e.g., platform-test-api) | Source code. PR workflow triggers ephemeral deployment |
gap-workflow-dispatch-action | GitHub Action that dispatches repository_dispatch to manifest repo |
<team>-kubernetes-manifests | Kubernetes manifests. Receives dispatch, creates PR with ephemeral config |
platform-reusable-workflows | Reusable workflow that creates/updates the manifest PR |
argocd | ArgoCD ApplicationSet definitions (PR generator per team) |
gappynator | Kubernetes operator that reconciles Application CRDs into K8s resources |
terraform-modules-kubernetes-config-and-tools | Terraform modules for ArgoCD, CMP plugin, notifications, Traefik gateway |
platform-githubbot | GitHub 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:
| Event | Condition | When |
|---|---|---|
labeled | github.event.label.name == 'ephemeral-deploy' | User adds the label to a PR |
synchronize | PR already has the ephemeral-deploy label | New 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:
| Input | Required | Default | Description |
|---|---|---|---|
token | Yes | — | Repo-scoped PAT for dispatch |
owner | No | gjensidige | Target GitHub org |
repository | Yes | — | Target manifest repo name |
event-type | No | release | Custom webhook event name |
environment | Yes | — | test or prod |
deployment-type | Yes | — | frontend or backend |
container-image | No | — | Container image reference |
container-image-tag | No | — | Container image tag |
ephemeral-name | No | "" | Ephemeral env identifier (e.g., PR number) |
ephemeral-app-name | No | "" | App name override for ephemeral env |
app-name | No | "" | App name override (monorepo support) |
manifest-directory | No | "" | 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
| Aspect | Regular | Ephemeral |
|---|---|---|
| Branch name | feat/<app>-<env>-<commitSha> | feat/<app>-<env>-<ephemeralName> |
| Branch reuse | No (new per deploy) | Yes (stable per ephemeral env) |
| PR title | Deploy image tag <tag> | ephemeral deploy (name=...,app=...) |
| Labels | deploy | deploy + ephemeral-deploy |
| Auto-merge | Yes (immediate) | No (PR stays open) |
| PR update on rebuild | New PR created | Existing 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-manifestsrepo for PRs labeledephemeral-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):
| Field | Value |
|---|---|
| Name | platform-test-api.test.42.apps-int-test |
| Labels | argocd.gjensidige.io/ephemeral: "true", team: platform |
| Finalizer | resources-finalizer.argocd.argoproj.io |
| Source repoURL | https://github.com/gjensidige/platform-kubernetes-manifests.git |
| Source targetRevision | {{ .head_sha }} (PR head commit) |
| Source plugin | name: ephemeral |
| Sync policy | Automated with prune + selfHeal |
Ephemeral CMP Plugin Parameters
The Application source uses the ephemeral Config Management Plugin with these parameters:
| Parameter | Value | Purpose |
|---|---|---|
APP_PATH | apps/platform-test-api/test | Which manifest directory to render |
NAME_SUFFIX | -42 | Suffix appended to all resource names |
COMMON_ANNOTATIONS | ephemeral.gap.io/name: 42, ephemeral.gap.io/instance-of: platform-test-api | Ephemeral identity annotations |
COMMON_LABELS | app: platform-test-api | App label pointing to parent |
Regular vs. Ephemeral Applications in ArgoCD
| Aspect | Regular | Ephemeral |
|---|---|---|
| Definition | Static Jsonnet in applications/<cluster>/ | Dynamically generated by ApplicationSet |
| Trigger | Merged to main | Open PR with ephemeral-deploy label |
| Target revision | main | PR head_sha |
| Manifest rendering | Jsonnet or Kustomize directly | ephemeral CMP plugin with NAME_SUFFIX |
| Auto-sync | Optional (defaults off) | Always on with prune + selfHeal |
| Lifecycle | Persistent | Automatic — PR open = deploy, PR close = destroy |
| Labels | register-deploy=true, update-github-status=true | ephemeral=true |
| Naming | <app>.<cluster> | <app>.<env>.<ephemeralName>.<cluster> |
| Notifications | Slack + GitHub commit status + deploy registration | GitHub PR comments only |
Deployment Topology
ApplicationSets are deployed per cluster × team:
| Cluster | Environment | Ephemeral enabled |
|---|---|---|
apps-int-test | test | Yes (all teams) |
lab-int-test | lab | Yes (platform only) |
apps-int-prod | prod | No |
tools-mgt-prod | prod | No |
tools-mgt-test | test | No |
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/argocdrepo'sapplicationsets/<cluster-identifier>/path - Renders Jsonnet with
clusterIdentifieranddestinationNameexternal 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.ymlwithdiscoverandgeneratecommands) - 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
| Field | Value |
|---|---|
| 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) |
| Rule | PathPrefix: / → backend service on port 80 |
| ParentRef | traefik-gateway in system-traefik |
Route 2: Header-Based Access
| Field | Value |
|---|---|
| Name | <app-name>-<ephemeral-name>-header |
| Hostname | <parent-host> (same as parent app) |
| Rule | PathPrefix: / + Header X-Ephemeral-Name: <ephemeral-name> → backend service on port 80 |
| ParentRef | traefik-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:
| Controller | Skip Behavior |
|---|---|
| UserAssignedIdentity | No Azure identity created |
| FederatedIdentityCredential | No federation created |
| ServiceAccount | No SA created (reuses parent's) |
| SecretProviderClass | No 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:
| Service | Purpose |
|---|---|
service.github | GitHub App integration for commit statuses and PR comments |
service.slack | Slack notifications |
service.webhook.register-deploy | Deploy 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:
- Parse manifest PR body to find linked code repo and commit SHA
- Find the open code PR associated with that commit
- 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:
- Parse manifest PR body → find linked code PR
- Update code PR comment:
"deployed 🚀"→"terminated 🗑️" - Remove
ephemeral-deploylabel from code PR - Close manifest PR and delete manifest branch
Path B — Triggered from code repo:
- Find bot's comment on code PR → extract manifest PR URL
- Close manifest PR and delete manifest branch
- Update code PR comment:
"deployed 🚀"→"terminated 🗑️"
Comment States
| State | Text |
|---|---|
| Deployed | Ephemeral environment deployed! 🚀 + manifest PR link |
| Terminated | Ephemeral environment is terminated. 🗑️ + manifest PR link |
Loop Prevention
Multiple safeguards prevent infinite event loops:
closePrAndDeleteBranchcheckspr.state === 'open'before closingupdateBotCommentToTerminatedchecks the comment doesn't already contain "terminated"- The
labeledhandler only fires for manifest repos (name ends with-manifests) removeEphemeralLabelsilently 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
| Constant | Value | Location |
|---|---|---|
| Ephemeral annotation | ephemeral.gap.io/instance-of | gappynator util.go |
| Ephemeral name annotation | ephemeral.gap.io/name | gappynator util.go |
| Header name | X-Ephemeral-Name | gappynator http_route.go |
| Gateway name | traefik-gateway | gappynator http_route.go |
| Gateway namespace | system-traefik | gappynator http_route.go |
| Backend service port | 80 | gappynator http_route.go |
| Default ingress kind | HTTPRoute | gappynator application_types.go |
| Production guard | config.Environment != "prod" | gappynator util.go |
| Header route name suffix | -header | gappynator http_route.go |
| ApplicationSet label | argocd.gjensidige.io/ephemeral=true | argocd applicationset.libsonnet |
| PR label | ephemeral-deploy | All repositories |
| PR title format | feat(<path>/<env>): ephemeral deploy (name=<name>,app=<app>) | reusable workflows |
| Dispatch event type | release | gap-workflow-dispatch-action |
| GitHub App secret | repo-credentials-github-gjensidige-appset | argocd applicationset.libsonnet |
| CMP plugin name | ephemeral | argocd applicationset.libsonnet |
| Polling interval | 60 seconds | argocd applicationset.libsonnet |
Design Decisions
-
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.
-
Stable branch naming: Ephemeral branches use the identifier (PR number), not commit SHA. This enables branch/PR reuse across updates.
-
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.
-
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.
-
Dual routing: Both subdomain and header-based routing are created for every ephemeral env, giving flexibility for direct access and junction-proxied access.
-
Production guard: Ephemeral environments are blocked in production at the gappynator controller level and omitted from production ArgoCD ApplicationSet definitions.
-
Chain-reaction cleanup: All cleanup logic is centralized in the
unlabeledhandler. Closing a PR or removing a label both converge to the same cleanup path, with loop prevention safeguards. -
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.