Docs · Airframe
Airframe quickstart
A hands-on walkthrough that ends with a running application: create a service, get its pipeline running, pull in the code, give it a ground environment to iterate in and a flight environment to run in, add a Redis cache, and finish with a real canary rollout you can watch.
We build boarding-api, the gate board of Skyport — a small
NodeJS service that looks up a flight’s gate and caches the answer in Redis so a
busy gate screen doesn’t hammer the reservations system. It also has a page built
to make canary and blue/green deployments visible.
An illustrated version of this guide, with diagrams, is at Airframe Quickstart.
What has and hasn’t been verified.
- Verified: the app itself. Its 11 tests pass, and it was run against a real Redis container, including two instances sharing one counter.
- Verified live: the create → onboard → ground environment mechanics, and the
Redis component (section 08): it has been provisioned through the catalog on both
clusters, the gate board reports
cache: redis, and the password is read straight from the component’s Secret. Walking this guide for the first time found and fixed several real problems (the Redis image, thedevClustername, the label on the cache’s resources), which is why the text names them. - Not verified: the canary (section 09). The mechanism is Argo Rollouts driven by the chart’s own values, but no canary has been run with this app yet.
Starting over? If a previous boarding-api was decommissioned, finish
decommissioning first — in particular delete its Infisical
projects (boarding-api-dev, boarding-api-prod) and any
boarding-api key in the prod cluster’s secretstore-provisioner ConfigMap, or the new
app tries to adopt a dead project. Also move any old local boarding-api checkout
out of the way: section 04 clones into that directory name.
Two architectures. The dev cluster is arm64 and the prod cluster is
amd64. Leave build.platforms unset in cicd.yaml so your image is built for both;
if you set it to one, the other cluster gets exec format error.
01 — Ground & flight
Every Airframe app can have two kinds of environment, built by different mechanisms with different review bars.
| Ground | Flight | |
|---|---|---|
| What it is | A disposable environment on the dev cluster | A governed environment on a real upper cluster |
| Declared in | platform/envs/<name>.yaml in your source repo |
An ApplicationEnvironment XR, rendering gitops-<app>/<cluster>/<env>/values.yaml |
| Gets its image from | Your pipeline’s deploy stage, committed straight to main — no PR |
Your pipeline’s release stage, as a reviewed pull request |
| Configured with | Editing platform/envs/<name>.yaml by hand |
Tower’s App Configuration tab (each save is a PR) |
| Cluster | Dev cluster only | Any cluster the registry marks type: upper |
Both render through the same Helm chart, airframe-application. “Ground” and
“flight” name the tier, not a field value — you’ll still type real names like
dev and staging below.
02 — Create the app
Tower → Create → NodeJSApplication. Every Bootstrap-tier stack (NodeJS, Spring Boot, Go, Python, InfraService) is a Backstage Scaffolder template generated from its XRD. Fill in:
| Field | Value |
|---|---|
| Name | boarding-api |
devCluster |
dev |
description |
Skyport gate board: Redis-cached flight lookups and a canary visualizer |
nodeVersion |
20 |
packageManager |
npm |
port |
8080 |
visibility |
private |
Use dev exactly, even if your dev cluster goes by another name. The dev ApplicationSets hard-code cluster: dev when they render an environment’s ExternalSecret, so it reads the store <app>-dev. An app created with any other devCluster value gets a store named <app>-<that value>, its ExternalSecret never syncs, and its pods sit in CreateContainerConfigError. And don’t remove dev from the cluster-registry: every existing app is pinned to it, and removing it turns their stores InvalidProviderConfig fleet-wide.
Submitting opens a pull request into gitops-cluster-dev-tenants at
tenants/boarding-api/xr-requests/boarding-api.yaml. Merge it — the
xr-requests ApplicationSet applies that file to the dev cluster as a
NodeJSApplication XR:
$ kubectl get nodejsapplication boarding-api -n app-boarding-api-cicd
NAME SYNCED READY COMPOSITION AGE
boarding-api True True nodejsapplications.catalog.idp.io 3m
kubectl describe shows the two custom conditions this catalog adds:
DevClusterReady (the dev cluster passed the registry check) and CicdOnboarded
(Glidepath has picked the app up).
Crossplane then creates the real boarding-api source repo and an empty
gitops-boarding-api repo, and registers the app with Glidepath. The source repo
starts with a hello-world index.js, a package.json, a Containerfile, and a
minimal cicd.yaml (build only, agent: nodejs-20, unit tests off). You now have a
repo — but no running pipeline yet, and nothing deployed.
03 — Merge the pipeline onboarding PR
Pipelines run through Pipelines-as-Code, which reads pipeline definitions from a
.tekton/ folder in the source repo. You don’t write those files; Glidepath does.
But they have to get into your repo, and that takes one pull request that you
merge:
- Once the app is registered, Glidepath’s onboarding hook notices your repo has no
.tekton/onboarding-resync.yamland opens a PR againstboarding-api(and againstgitops-boarding-api) adding the generated.tekton/*.yamlfiles. - Merge it. Find it in GitHub, or in Tower’s Pull Requests tab.
- Until this is merged, pushing to
maindoes nothing — PaC finds no pipeline to run.
These files are never hand-edited. From then on, every push that changes
cicd.yaml opens a fresh PR regenerating .tekton/ if anything changed —
merge those too. (You will meet this again in sections 06 and 07.) If nothing
happens after you push, check first that the platform’s GitHub App has access to
the repo.
Once merged, a push to main runs the scaffolded build stage and publishes a
multi-arch image to ghcr.io/<owner>/boarding-api.
04 — Pull in the code
The scaffold’s index.js is hello-world. The real boarding-api lives in the
airframe repo, under examples/skyport/boarding-api/. Copy it over the scaffold:
git clone https://github.com/jfillman/airframe.git /tmp/airframe
git clone https://github.com/jfillman/boarding-api.git && cd boarding-api # fails if ./boarding-api already exists
cp -R /tmp/airframe/examples/skyport/boarding-api/. .
git add -A && git commit -m "boarding-api: gate board with Redis-backed lookups"
This overwrites index.js and package.json and adds app.js, store.js,
reservations.js, public/, test/ and test.sh. Keep the scaffolded
Containerfile — it already does what this app needs (npm install, COPY . .,
CMD ["node", "index.js"]). Keep cicd.yaml for now too; you’ll change it in
section 06, and push then.
Try it locally before pushing:
npm install && npm test # 6 passing
PORT=8080 node index.js # open http://localhost:8080
You’ll see the gate board. With no REDIS_URL set the header says cache: memory —
the app falls back to an in-process store so it runs anywhere. That fallback is a
teaching aid: with two replicas each pod keeps its own counts, which is the exact
problem Redis fixes in section 08.
What the app does:
| Endpoint | Purpose |
|---|---|
GET / |
The gate board — flight lookup, boarding-pass scans, and the version tally |
GET /api/boarding/:flight |
Boarding info for a flight like AC123; cached for 3 minutes; reports cached and latencyMs |
POST /api/boarding/:flight/scan |
Increments that flight’s boarded counter |
GET /api/whoami |
Version, pod and cache mode — what the canary tally polls |
GET /healthz |
Liveness / readiness |
05 — The app starts with no deployment
At this point boarding-api has an image and no environment: nothing is running
anywhere. Onboarding creates the app, its repos, and its pipeline; it does not
create a Deployment. Environments are separate objects, added next. There are two
places to configure them, matching the two tiers:
- Ground — you edit
platform/envs/dev.yamlyourself (section 06). - Flight — you use Tower’s App Configuration tab (section 07). That tab
edits
gitops-<app>/<cluster>/<env>/values.yamlthrough pull requests, and it only lists flight environments — the ones yourcicd.yamldeclares underdeploy.upperEnvironments. It can’t see a ground env, and it can’t see a flight env untilcicd.yamldeclares it.
06 — Ground: a running environment on the dev cluster
A ground environment is two things: a file that creates the namespace, and a pipeline stage that puts an image in it.
Step 1 — declare the environment. Create platform/envs/dev.yaml in the
boarding-api repo:
# envName, not env — env is a reserved key in the chart's container env-var list.
# rollout: null must be explicit — omitting it makes the chart render its own
# default rollout with an empty image (two InvalidImageName pods).
envName: dev
rollout: null
A dev-cluster-only ApplicationSet (boarding-api-lower-envs) watches
platform/envs/*.yaml in your repo. Within a sync interval you get an empty
namespace, app-boarding-api-dev, with the baseline ServiceAccount and
NetworkPolicy and no workload.
Step 2 — let the pipeline deploy to it. Replace the scaffolded cicd.yaml with
a flow that builds, tests, and deploys to dev:
apiVersion: platform/v1
kind: PipelineConfig
build:
agent: nodejs-20
unitTest:
enabled: true # runs ./test.sh, which the code you copied in provides
deploy:
lowerEnvironments: [dev]
pipelines:
ci:
trigger: { source: git, event: push, branch: main }
steps:
- stage: build
- stage: test
env: dev
- stage: deploy
env: dev
Step 3 — commit both files and merge the .tekton/ PR. Changing cicd.yaml
triggers section 03’s resync: a new PR regenerates .tekton/. Merge it.
Separately, ArgoCD reads the new cicd.yaml and provisions the deploy RBAC for
app-boarding-api-dev.
Step 4 — run it. Push to main. The deploy stage commits
rollout.image.repository and rollout.image.tag directly into
platform/envs/dev.yaml on main — no branch, no PR. git pull before your
next push, or you’ll be rebasing over the bot’s commit.
# platform/envs/dev.yaml after the first deploy
envName: dev
rollout:
image:
repository: ghcr.io/jfillman/boarding-api
tag: 0.1.0-a1b2c3d
The chart’s defaults apply for everything else: two replicas, container port
8080 named http, no probes. boarding-api listens on 8080, so this works
as-is. Two replicas matters later: it’s what makes the in-memory counter visibly
wrong.
$ kubectl get pods -n app-boarding-api-dev
NAME READY STATUS RESTARTS
boarding-api-6c8f9d4b7-x2k9p 1/1 Running 0
See it. Reach it with a port-forward (find the Service name with
kubectl get svc -n app-boarding-api-dev):
kubectl port-forward -n app-boarding-api-dev svc/boarding-api 8080:8080
Open http://localhost:8080. Look up AC123 twice — the second answer is a
cache hit, a few milliseconds instead of ~400. Then press Scan a boarding pass
several times: the counter jumps around, because the two pods each count on their
own (counter: memory). Keep that in mind for section 08.
To change ground settings (replicas, ports, probes, resources), edit
platform/envs/dev.yaml directly — the App Configuration tab does not cover it.
07 — Flight: a governed environment on an upper cluster
Step 1 — declare the environment in cicd.yaml
Add the flight env and a release stage. The release stage is what supplies
the image to a flight env, and Tower’s App Configuration tab builds its
environment list from this upperEnvironments block:
deploy:
lowerEnvironments: [dev]
upperEnvironments:
- { name: staging, cluster: prod }
governance:
allowedCommitSigners:
- you@example.com # release gates are enforced; with no signers every
# release fails the commit-signature gate
pipelines:
ci:
trigger: { source: git, event: push, branch: main }
steps:
- stage: build
- stage: test
env: dev
- stage: deploy
env: dev
- stage: release
env: staging
Merge the .tekton/ PR this push opens (section 03). See Glidepath’s
docs/user/examples/04-multi-env-promotion.yaml and
10-production-grade.yaml for the governance options.
Step 2 — create the ApplicationEnvironment
Tower → Create → ApplicationEnvironment. The form has these fields:
| Field | Value | Notes |
|---|---|---|
| Name | boarding-api-prod-staging |
The XR’s own name. Convention is <app>-<cluster>-<env>, as with every existing env. |
| Namespace | app-boarding-api-cicd |
The app’s tenant namespace, app-<app>-cicd. The xr-requests AppProject only permits this namespace. |
| Owner | group:default/jfillman |
Same as every existing env. |
appName |
boarding-api |
Required. Labels the env and drives a deletion-protection Usage on the app. Not live-checked: a typo silently creates an env for an app that doesn’t exist. |
cluster |
prod |
Required. Live-checked against the cluster registry: must be type: upper and crossplaneReady. A dev cluster is rejected, and the XR reports ClusterReady: False and creates nothing. |
env |
staging |
Required. A DNS label, max 20 characters (^[a-z0-9](https://github.com/jfillman/airframe/blob/main/docs/user/[-a-z0-9]*[a-z0-9])?$) — it becomes part of the namespace app-boarding-api-staging and a git path. |
configMapGenerator |
off | Opt-in to a Kustomize configMapGenerator source for this env’s config files. Leave off unless you need it. |
The Crossplane Settings page can stay at its defaults.
Submitting opens a PR into gitops-cluster-dev-tenants
(tenants/boarding-api/xr-requests/boarding-api-prod-staging.yaml). Merge
it. Crossplane then commits a bootstrap values.yaml to
gitops-boarding-api/prod/staging/ and an onboarding entry to
the prod cluster’s own tenants repo, so the prod cluster’s own ArgoCD picks the env up — no
cross-cluster credential is involved.
$ kubectl get applicationenvironment -n app-boarding-api-cicd
NAME SYNCED READY COMPOSITION AGE
boarding-api-prod-staging True True applicationenvironments.catalog.idp.io 2m
kubectl describe shows ClusterReady: True and WorkloadDeployed: False. The
bootstrap file is rollout: null: a namespace, no workload. WorkloadDeployed
stays False until one exists.
Step 3 — configure it in Tower’s App Configuration tab
Tower → boarding-api → App Configuration → environment staging. The active
environment is shown prominently at the top; check it before saving. Turn on the
Deployment switch (an env with rollout: null deploys no Rollout, Service,
HPA or PDB) and fill in:
| Section | For boarding-api |
|---|---|
| Deployment | On |
| Scaling | Replicas 4 (a canary splits by replica count, so 4 gives readable 25% steps) |
| Resources | Requests 100m / 128Mi, limits 500m / 256Mi |
| Service | port name http, containerPort 8080 |
| Health checks | Liveness and readiness: HTTP GET /healthz on 8080 |
| Canary steps | Leave the default, or build a weight/pause/analysis sequence |
Save opens a pull request into gitops-boarding-api — never a direct commit,
in any environment. The tab validates the change against the chart’s own
values.schema.json before it lets you submit. Merge the PR.
There is no image field. The image is set by the release pipeline in the next step, so until it runs the Rollout has no image to start.
Step 4 — release an image
Push to main. After build, test and deploy to dev, the release stage
opens a PR against gitops-boarding-api setting rollout.image for staging.
Merge it — as a signed commit; GitHub’s merge button produces an unsigned one,
see Glidepath’s docs/admin/commit-signing.md (“Merge strategy matters”). The prod cluster’s
ArgoCD syncs it and WorkloadDeployed flips to True once a real Rollout exists.
08 — Add a Redis cache
This section is verified live: the component has been provisioned on both clusters and the gate board reads and writes through it. It needed one platform fix along the way (Bitnami removed its versioned images, so the Composition now pins the last chart and image that still pull).
What kind of thing is Redis?
Redis is an Attached-tier component. It is not a Bootstrap object (you don’t
create it from Tower’s Create menu — there is no form for it) and not an Embedded
setting. You add one by putting an entry in the components: list of an
environment’s values; the airframe-application chart renders that entry into a
Redis XR, whose Composition renders a provider-helm Release of Bitnami’s
redis chart (standalone, one instance per entry, in the same namespace as your
app, password-protected).
Where it can work
A component only works where Crossplane can reconcile it — provider-helm and the
Redis XRD must both be installed on that cluster. As of 2026-09-23:
| Cluster | provider-helm |
Redis XRD |
|---|---|---|
| dev (arm64) | installed, healthy | installed |
| prod (amd64) | installed, healthy | installed (delivered by the idp-service-catalog-redis Application) |
Both tiers have what they need, and both clusters run airframe v0.3.83, which includes the Redis naming fix. All the images involved are multi-arch, checked against the registries.
Ground: add it to platform/envs/dev.yaml
Name the component whatever you like; we use cache. The Composition sets the
Bitnami chart’s fullnameOverride to the component name, so the Service is
<name>-master (here cache-master) and the password Secret is <name> (here
cache), exactly what the connection secret expects. (Before airframe v0.3.79 this
was broken and the name had to be redis; every cluster is past that now.)
envName: dev
components:
- type: redis
name: cache
spec: { size: small, persistence: false } # environmentRef is stamped for you
env:
- { name: REDIS_URL, value: "redis://cache-master:6379" }
# The password is in the Secret the component creates, named after it (here `cache`).
# Read it directly - there is nothing to copy into Infisical.
- name: REDIS_PASSWORD
valueFrom: { secretKeyRef: { name: cache, key: redis-password } }
(Keep the rollout: block the deploy stage wrote; only add the keys above. env: entries with
valueFrom need airframe v0.3.88 or later. On an older pin, the alternative is to copy the
password once into boarding-api’s Infisical project as redis-password and list it under
secrets: — kubectl get secret cache -n app-boarding-api-dev -o jsonpath='{.data.redis-password}' | base64 -d.)
See it
Once the pods restart, the gate board header says cache: redis. Scan a boarding
pass repeatedly: the counter now climbs by exactly one each time, no matter which
pod answers — and it survives a pod restart if you set persistence: true. That’s
the difference between section 06’s scattered counts and one shared truth.
Shared Redis: not supported yet
One Redis serving several apps is not a feature of this catalog. Redis has no
attach mode, and the obvious construction — a standalone InfraService hosting the
instance — can’t yet give a flight environment a cluster that has no Redis support.
Skyport’s plan uses InfraService for RabbitMQ and the OAuth server instead, once
those components exist.
09 — Roll out a canary
boarding-api is built for this. The gate board polls /api/whoami twice a second
and draws a bar of which version answered; the header colour comes from that
version. Version 2 also adds a “boarding group” line to lookups, so a canary changes
behaviour as well as colour.
Start with v1 in flight (section 07 done, image released). Port-forward to the
staging Service and open the board — one solid bar, v1.0.0: 100%.
Make v2. In the repo, set "version": "2.0.0" in package.json, commit and push
to main. The pipeline builds, tests, deploys to dev, and opens a release PR for
staging; merge it as a signed commit.
Watch the canary. The Rollout does not replace v1. It steps: with the chart’s default canary steps (or the ones you set in App Configuration → Canary steps) and four replicas, the bar goes from all-v1 to roughly a quarter v2, then half, then all. The chart has no traffic router, so the split is by pod count, not by request weight — percentages round to whole pods, which is why four replicas reads better than two. A step that pauses without a duration waits for you to promote it.
kubectl argo rollouts get rollout boarding-api -n app-boarding-api-staging --watch
kubectl argo rollouts promote boarding-api -n app-boarding-api-staging # if paused
Press Reset tally between steps to see the current mix rather than a running
average. Look up AC123: only the v2 pods show the boarding-group line, and the
answered by row says which version you got.
Blue/green instead. Set the env’s rollout.strategy: blueGreen. The chart then
renders two Services, boarding-api (active) and boarding-api-preview. Port-forward
to the preview Service to see v2 at 100% before promotion, while the active Service
still shows v1 at 100%; after promotion the active bar flips in one step.
Roll back by promoting nothing and aborting the rollout, or by reverting the release PR; the bar returns to all-v1.
This is the one unverified section of the guide: the mechanism is Argo Rollouts driven by the chart’s own values, but no canary has been run with this app yet.
Cheat sheet
- Merge the
.tekton/PR — after creating the app, and again after everycicd.yamlchange. No merge, no pipeline. - Copy the demo code over the scaffold, keep the scaffolded
Containerfile. - Nothing is deployed after onboarding. Ground needs
platform/envs/dev.yamlplus adeploystage; flight needs anApplicationEnvironment, App Configuration, and areleasestage. - App Configuration is flight-only, and only lists envs declared in
deploy.upperEnvironments. envName, neverenv, as the environment’s name key inplatform/envs/*.yaml.rollout: nullmust be explicit.- The ground deploy commits to
mainitself — pull before you push. ApplicationEnvironmentrejects dev clusters —clusteris live-gated totype: upper.- Redis is a component, not a Bootstrap object: add it to
components:; its Service is<name>-master. - Two architectures — don’t narrow
build.platforms. - Canary splits by pod count, so use four replicas.