Hangar

Docs · Tower

Configure › Environments

One table of every environment of a service, in promotion order, for Kubernetes and cloud services alike. Design, decisions and the phased plan: glidepath docs/admin/envs-overhaul-requirements.md and docs/admin/adr/0019-environments-as-one-config-model.md.

What it shows

Each row: the environment, its tier (Ground deploys on every push, Flight only through an approved release), the target (Kubernetes, AWS Lambda, …), where it runs (the cluster, or the function / service and region), health, the live image and when it was deployed. An environment cicd.yaml declares but nothing has deployed to says “not deployed yet”. Preview (pr-N) environments are not listed. The order and tiers come from the app’s own cicd.yaml, in either shape (deploy.environments, or the older lowerEnvironments / upperEnvironments / promotionOrder).

Editing: staged, then one pull request

Edits do not submit anything. They are staged, and the Pending changes panel lists them all, with anything that would be refused (the same rules as the chart and schema: names, duplicates, no cluster on a Ground environment, no Flight on a cloud target, an override only for the app’s own target). Open pull request turns the staged changes into one change to cicd.yaml (the existing Glidepath change route, reviewed and merged like any other). Nothing is written until you merge it.

What can be staged so far:

Change What it does
Add environment (Ground) Adds the environment to deploy.environments. An InfraService (kind:infraservice) gets no onboarding PR and has no build: its environment is its values file, so a new one gets its glidepath/envs/<name>.yaml on gitops-infra-<name> in the same PR: the values of the environment it is a Duplicate… of, or else a starter file (appType: infra, rollout: { enabled: false }, components: []) that deploys nothing until a component is added on the Components sub-tab. An InfraService environment that exists in cicd.yaml (or is implied: no deploy.environments means dev) but has no values file shows Create glidepath/envs/.yaml on its Values sub-tab instead of the form; that PR adds the starter file and writes the environment list into cicd.yaml as it stands (an implied dev becomes explicit). Before 2026-10-21 a new one with nothing to copy was refused and a missing file could only be added by hand (skyport-auth). For an app, after the PR merges, Glidepath’s onboarding resync opens a second PR that adds glidepath/envs/<name>.yaml (the panel says so up front); the row’s provisioning steps link that PR while it is open and wait on it. That PR also seeds glidepath/envs/<name>.release.yaml from the Ground environment before it (its own “Deploy … to ” commit), so the new environment runs that image as soon as the PR merges instead of waiting for the next source push; the pipeline’s deploy step overwrites the file from then on. A cloud environment has no such file. By default the same cicd.yaml change also adds a deploy step for it to the app’s main pipeline, right after the step for the environment before it: without one nothing in CI ever deploys there (Argo CD creates the namespace and waits for an image that never comes). Untick Also add a deploy step to edit the pipeline yourself Configure › Pipeline; the Deployed step then says that no pipeline deploys to it.
Add environment (Flight, Kubernetes apps) Needs the cluster it runs on, picked from a list of the registered upper clusters that can host one (cluster registry type: upper + crossplaneReady: "true", the same gate the ApplicationEnvironment composition applies), each with its zone and roles and the environments of this app already on it; the only one is chosen for you. Read live from Backstage’s cluster-registry backend plugin. A cluster the registry says cannot host one (a duplicated environment’s, say) is refused before any pull request. When the registry cannot be read it falls back to a text box suggesting the clusters this app already uses. Opens two pull requests, in this order: an ApplicationEnvironment request on the tenants repo (through Backstage’s existing template, launched via the scaffolder API as you), then the cicd.yaml change. Merge the request first, so the environment exists when cicd.yaml names it; Crossplane then adds its gitops directory and the Application. If the environment’s request already exists (on the tenants repo’s main branch, or as an open pull request), it is reused rather than opened again and the result says so; one on main for a different cluster is refused. If the request fails, nothing is changed in cicd.yaml and what you staged is kept. If cicd.yaml fails after the request opened, retrying does not open the request again. By default the same cicd.yaml change also adds a release step for it to the app’s main pipeline (the one with the most deploy and release steps), right after the step for the environment before it (at the end if that one has no step). Untick Also add a release step to edit the pipeline yourself Configure › Pipeline. Nothing is added when the service has no pipeline with steps, or a pipeline already has a step for the environment; the panel says so. Not offered for a cloud target (it has no approval path for Flight yet).
Reorder (drag the handle, or Move earlier / later in the row menu) Changes the declared promotion order, for Ground and for Flight environments. A Ground environment never moves past a Flight one. Reordering Flight environments does not reorder the pipeline’s release steps (the panel says so): edit those Configure › Pipeline.
Remove (✕; Ground, and a cloud app’s Flight) A dialog previews the impact and asks you to type the environment’s name. On a Kubernetes app the same pull request removes the environment from cicd.yaml and deletes its glidepath/envs/<name>.yaml and <name>.release.yaml (and the glidepath/ equivalents), whichever exist. After it merges Argo CD prunes the Application <app>-<name> and the namespace app-<app>-<name>, deleting everything running in it. Refused while a pipeline step still names the environment (remove the step Configure › Pipeline first); the backend re-checks this and only accepts envs/<env>[.release].yaml paths. On a cloud app a Ground removal changes only cicd.yaml; a Flight removal also deletes its release pin glidepath/releases/<name>.yaml and its promote flow .tekton/flow-promote-<name>.yaml in the same pull request (the flow runs on any change to the pin, and PaC reads .tekton/ from the merge commit, so deleting both together starts no deploy). The cloud resource is not deleted. Removing an environment you only just staged simply un-stages it.
Not in cicd.yaml (callout above the table, Kubernetes apps) A Flight environment whose ApplicationEnvironment exists on the control-plane cluster but that cicd.yaml does not name: created with the Backstage template, which stops before cicd.yaml, so nothing releases to it and it has no row. Add to cicd.yaml stages it with its name and cluster (and a release step); opening the pull requests reuses the existing request, so only the cicd.yaml change opens. Hidden while its cicd.yaml pull request is open.
Still running, not in cicd.yaml (callout above the table, Kubernetes apps) A Ground environment live on the control-plane cluster that cicd.yaml does not name and no pull request is adding or removing: taken out of cicd.yaml by hand instead of with Remove, so its glidepath/envs/<name>.yaml is still in the repo and the lower-envs ApplicationSet keeps its Application. Delete its files stages a change that deletes glidepath/envs/<name>.yaml and <name>.release.yaml (the environment list is unchanged); after it merges Argo CD prunes the Application and the namespace. Flight environments are not listed here (they are listed from their ApplicationEnvironment).
Duplicate (row menu) Opens the Add dialog as a copy of the row: same tier, same cluster (Flight) and same settings; you give it a name. A cloud environment keeps its other settings but not the resource it points at (two environments on one function would deploy over each other). Copy the values (Kubernetes apps): for a Ground copy its glidepath/envs/<name>.yaml is created in the same pull request from the source’s values (its image excluded); Glidepath’s onboarding resync only scaffolds a missing file, so it never overwrites it. A Flight copy’s values file is written by Crossplane after the request merges; once it exists use Copy values from in its Values tab.
Copy values from (Values tab) Loads another environment’s values into the form as staged edits (nothing is saved until the pull request merges; Discard all undoes it). rollout.image is never copied. Works between any two Kubernetes environments, Ground or Flight.
A cloud environment’s own resource (click the row) Sets this environment’s lambda / ecs / azureContainerApps fields (for example the function name). An empty field uses the app-level value, shown as its hint.

| Values (click the row, Kubernetes apps) | The environment’s chart values as a form in seven sub-tabs: Workload (deployment, scaling, resources, service, health checks, availability, rollout strategy and pod template), Release (canary steps, notifications, custom AnalysisTemplates, SLOs), Networking, Config (environment variables, config maps, volumes), Components (attached Redis, RabbitMQ, PostgreSQL and the rest as a form drawn from airframe’s own component schemas: a card per component, its mode, its required fields, and what it hands the app; the raw YAML stays underneath. + Add component also adds From component environment variables for its outputs. First choice is the name the app’s own code reads: the backend’s GET /api/glidepath/app/env-reads scans the source repo’s default branch (process.env.X/env.X/destructuring, os.environ/os.getenv, os.Getenv, System.getenv, Spring ${X} placeholders; tests, vendored and built trees skipped; up to 200 files, cached per tree) and an output takes a read name of the form <PREFIX>_<WORD>, the word one of the output’s (username: USER, USERNAME, LOGIN; database: DATABASE, DB, DBNAME, NAME; …) and the prefix ending in the component’s name or one of its type’s words (RABBITMQ, AMQP, MQ; OAUTH, OIDC; DB, PG; …). So boarding-api’s rabbitmq is wired as RABBITMQ_USER because events.js reads that, and flight-api’s database as DB_HOST/DB_USER/DB_NAME from its application.properties. When the code reads some of a component’s outputs, the rest are left unwired; the note names the files the names came from and calls out any added name the code does not read. On the Config tab a From component variable the code never reads (and no framework or driver reads on its own) is marked The app’s code never reads … (only when the whole repo was scanned). Without the scan (it failed, or an InfraService with no source repo) the names come from the naming profile, the way the app’s framework or driver reads them with no code: a Spring Boot app gets Spring’s own names (PostgreSQL: SPRING_DATASOURCE_URL, _USERNAME, _PASSWORD; RabbitMQ: SPRING_RABBITMQ_*; Redis: SPRING_DATA_REDIS_HOST, _PORT, _PASSWORD; MongoDB: SPRING_MONGODB_URI and SPRING_DATA_MONGODB_URI, for Boot 4 and Boot 3), which outrank the app’s packaged application.properties; a Node.js, Python or Go app’s PostgreSQL gets libpq’s PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE plus DATABASE_URL. Outputs those names already carry (host, port and database inside SPRING_DATASOURCE_URL) are left unwired, and the note says which. Everything else, and any app Tower cannot classify, gets <COMPONENT>_<OUTPUT> (redis gives REDIS_HOST, REDIS_PORT, REDIS_URL, REDIS_PASSWORD). A suggested name already in use is never overwritten: it falls back to <COMPONENT>_<OUTPUT> (a second database in a Spring app), or is left alone and the note says so. The names are suggestions: rename them on the Config tab if the app reads others. Each card’s What it gives the app table shows which variable reads each output, and offers + Add environment variables for … for any that nothing reads. Renaming a component points its From component variables at the new name when the field loses focus. Remove on a card also removes the environment variables that read from that component, both fromComponent ones and Secret/ConfigMap refs to its outputs by name, and says which ones it removed. Editing the raw YAML leaves env vars alone), Access (service account, secrets) and Advanced (cron jobs, one-off jobs, extra manifests, the full committed YAML). A dot marks a sub-tab that holds a change. A Ground environment’s values are glidepath/envs/<name>.yaml; a Flight environment’s are gitops-<app>/<cluster>/<name>/values.yaml. Either file has its own pull request (a different file from cicd.yaml), so the form has its own Pending changes panel under it, pinned to the bottom of the screen, and is not part of the page’s panel. A Ground environment you only just staged has no file yet (the onboarding pull request creates it), so the row says so instead. | | Full values (annotated) | View full values (annotated), above the sub-tabs of any values form (an environment’s row, Shared values), shows the environment’s values as Argo CD renders them: the chart’s defaults, then the shared base (base.yaml), the environment’s own file, its release file, and the values its Argo CD Application sets (appName, cluster, clusterType, envName, namespace), later layers winning. Each field carries its description from airframe’s schema and a mark saying where its value came from: # set here, # from base.yaml, # from <env>.release.yaml or # set by the platform (Argo CD Application); an unmarked value is the chart default. The layers come from backstage GET /glidepath/environment/values-layers, read when the view is opened. A shared values file (Configure › Shared values) shows just its own file over the defaults. Your staged changes are included as you make them. It reads the chart from airframe’s main branch, so it can run slightly ahead of the version an environment is pinned to. | | Deployment switch and status (Workload tab) | The switch says whether the environment runs a service at all (rollout.enabled, airframe v0.3.140+). Off writes rollout.enabled: false and keeps the rest of rollout:, so turning it back on restores the settings; an older rollout: null also reads as off. A file that does not set it inherits the shared values (<cluster>/base.yaml or glidepath/base.yaml), and the default is on. Glidepath refuses to release or deploy to an environment that is off. Beside the switch, the status shows what the release file asks for (Not deployed yet, Released: , No workload, or Misconfigured when a release file names an image for an environment that is off) and what the live Rollout is running, so a canary in progress or an unsynced change shows as a difference rather than being hidden. | | What the form edits (beyond the basics) | Environment variables are a value, a ConfigMap key, a Secret key, or From component (fromComponent: {name, output}, with the outputs each component type offers). A hand-written reference that is really a component’s own output (airframe-validate’s AF-COMP-003 warning) gets a one-click Use component X.output instead. Networking has the NetworkPolicy gateway namespace, extra ingress sources and egress destinations (namespace or CIDR, pod labels, ports) and the raw extra rules; annotations for Ingress and HTTPRoute; ServiceMonitor labels. Pod template (Workload) and Rollout strategy (Release) are separate raw-YAML panels, each with an example. Empty fields show ghost text (italic): e.g. for an example, default: for what applies when it is left empty. | | Labels, templates and SLOs | Labels and annotations (Workload tab): extra labels and annotations on the pods, the Rollout and the Service (chart v0.3.115+); a label the chart owns (app.kubernetes.io/*, hangar.io/*, helm.sh/chart) or a checksum/* annotation is refused before a pull request opens. Canary analysis templates: each template a step uses is either declared in this file (under Custom AnalysisTemplates) or a cluster template (clusterScope: true); Argo looks a reference up in the app’s namespace unless it is marked, and rejects the whole Rollout when it is missing. Tower checks every reference (steps, canaryAnalysis, blueGreen analyses) against the file and the cluster’s ClusterAnalysisTemplates, shows a standing warning on the Release tab and blocks the pull request. Cluster checks (under the canary steps): every ClusterAnalysisTemplate on the environment’s cluster with an Add button that appends an analysis step for it, clusterScope: true and the namespace/app args filled in. A check from airframe’s catalog is offered only when Prometheus has data for the metric it reads (its query passes when there is none): on the dev cluster the pod, restart, OOM and probe checks are; the error-rate, success-rate and latency checks need Traefik request metrics and show no data yet. A template outside the catalog is offered as not checked. Common SLOs (Release tab): toggles for readiness availability, liveness availability and health-check latency, each offered only when Prometheus has data for the workload (checked live; a cluster without Prometheus, so they show cannot check there). Turning one on adds its entry to the slos: YAML below it. Request-based SLOs need request metrics that are not scraped on these clusters, so they are not offered. | | Flight settings | A Flight environment’s Settings sub-tab also holds its catalog resource (ApplicationEnvironment’s configMapGenerator) and its configmap source files. | An app still on the older shape is converted by its first change. Tower always writes deploy.environments, so the first staged change to such an app also replaces lowerEnvironments, upperEnvironments and promotionOrder with one list (keeping the same environments and order). That conversion is shown as the first line of the panel, never done silently.

A Kubernetes app’s Flight environments are not removed by Tower. (A cloud app’s are: see Remove above; it has no ApplicationEnvironment, tenants directory or gitops repo, only the cicd.yaml entry, the release pin and the promote flow.) Deleting the ApplicationEnvironment XR deletes none of the files it wrote (they deliberately have no delete policy, after an earlier data-loss incident), and the file in the tenants repo is what keeps the Application alive, so a partial automated removal could leave a live Application. The row’s Danger zone lists the manual steps in order.

The values form is the one Shared values (then App Configuration) used to hold alone; it now takes a source (src/values/sources.ts) so the same form edits a Ground or a Flight environment, or one of the app-wide files (glidepath/base.yaml, each Flight cluster’s gitops-<app>/<cluster>/base.yaml, glidepath/pr-env.yaml) on Configure › Shared values. Configure › Pipeline (formerly the Glidepath tab) is the same shape for cicd.yaml: sub-tabs (Build, Test, Deploy, Preview environments, Governance, Notifications, Secrets, Pipelines, Advanced), a dot on the ones with a staged change, and one Pending changes panel for its single pull request. Governance has no commit-signer field: since glidepath ADR-0027 (2026-10-20) who may sign or merge a released commit is the platform admins’ release-signing policy, not cicd.yaml, and the form carries governance.allowedCommitSigners (ignored) and governance.githubMerge (the team’s tighten-only knobs) through unchanged. The ApplicationEnvironment launcher is src/environments/applicationEnvironment.ts.

The table

Filter chips (All / Ground / Flight / Cloud, with counts) sit above a panel of rows: drag handle (Ground rows that can move), name with a staged chip, tier, target, where, health, live image with its age, and an actions menu (Edit details, Move earlier / later, Remove, Undo removal). A row staged for removal stays in place, struck through, until the change is opened. Clicking a row opens four sub-tabs: Settings (a cloud environment’s resource fields with a “set here” / “app-level” tag; for Kubernetes the read-only facts), Values, Promotion (how it deploys and which pipeline step reaches it) and Danger zone. Drag a handle onto another Ground row to reorder, or use the menu. The layout follows the mockup; the shared pieces are in the design system.

After you open a pull request

Opening the pull request does not make the environment appear: it exists when the change merges. Tower remembers what you submitted (in the browser, per service) and keeps it visible until cicd.yaml shows the result:

  • The new environment stays in the table, marked PR open; an environment with a removal pull request open is marked removal PR open.
  • The Open pull requests panel lists each pull request (the cicd.yaml one and, for a Flight environment, its ApplicationEnvironment request) with Check again and Dismiss. The tab asks again every half minute while any is open, and Refresh in the header asks now.
  • Expanding such a row shows provisioning progress: for a Flight environment the cicd.yaml change, the request pull request, Crossplane applying it, the files written to GitHub (n of m), the Infisical secret store (with a link to the project once ready), the values file, and the first deploy; for a Ground one the cicd.yaml change, the onboarding pull request, the values file and the deploy. Links go to the pull requests, the GitOps repo, the file and the Infisical project. An environment that exists but is not fully provisioned shows the same steps collapsed above its sub-tabs.
    • The request pull request is looked up on the tenants repo the ApplicationEnvironment XR names in its terasky.backstage.io/source-info annotation (app-config’s tower.clusters.<control plane>.tenantsRepo before the XR exists). Once the XR exists the request step is done whether or not the pull request is found: the XR is only applied after it merges.
    • The secret store is read on the environment’s own cluster, in <appType>-<app>-xrs, where that cluster’s xr-requests ApplicationSet applies the secretstore-<env>.yaml request the XR commits: the SecretStore <app>-<cluster>-<env>. The Infisical project link comes from the shared store’s Project <app>-<cluster> there, when Tower’s read identity may read Infisical Projects on that cluster.
  • The Values tab waits for the file. Until the environment’s values file exists (a Flight environment’s is written by Crossplane, a Ground one’s by the onboarding pull request) the tab says so instead of showing a form, for Ground and Flight alike.

Where the code is

  • src/values/: ValuesForm.tsx (the form, its sub-tabs and its pending panel), sources.ts (Flight / Ground), FlightPanels.tsx, styles.ts.
  • src/environments/: submitted.ts (remembered pull requests), lifecycle.ts (the provisioning steps, pure) and useEnvLifecycle.ts (reads GitHub, the ApplicationEnvironment XR, its files and the SecretStore). A Ground row re-reads its values file every 45 s while its steps are unfinished, so the “file exists” step and the Values tab follow the onboarding PR’s merge without a reload.
  • src/environments/stagedChanges.ts: the pure model (read either shape, apply staged changes, validate, build the deploy block, describe the changes). Heavily tested; the rules live here, not in the component.
  • src/tabs/EnvironmentsTab.tsx: state and handlers; src/tabs/environments/: RowDetail (the sub-tabs), PendingChanges, dialogs, shared, PreviewsPanel.

Previews

Under the environment list, Previews shows the app’s preview environments, one per pull request labelled preview (v0.116.0). Each row shows the PR, Argo CD’s health and sync, the namespace (app-<app>-pr-<n>), the image tag, when it was created, and the kubectl port-forward that reaches it (a preview has no route). The panel reads glidepath-api’s GET /v1/apps/{app}/previews through the backend’s /previews route and is read only: adding or removing the label on the PR starts or ends a preview. It is hidden when the app has none.

  • src/ui/: the shared kit.
  • src/environmentRows.ts: the read-only row builder.