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 |