Hangar Platform · Airframe · Developer Guide · Part 2

A service with a database

Part 1 took a NodeJS service from nothing to a canary. This part does the same for a Spring Boot service that owns a PostgreSQL database, then connects part 1's boarding-api to it so the gate board shows real data. We build flight-api: 24 flights and their gates in Postgres, a REST API, and a simulator that keeps moving flights around.

What has and hasn't been verified

Verified: the service (17 unit tests in the platform's own Java build image, 4 integration tests and the full app against a real PostgreSQL, and the build: build.sh compiles natively in the exact Java agent image in about 30 seconds, and the thin image built from it starts and answers against a real PostgreSQL), and boarding-api calling it end to end. The scaffold's own Containerfile, which compiles inside the image build, did finish, but took about 17 minutes under amd64 emulation (the amd64 leg alone took 11), which is why this guide replaces it. Verified on both clusters: the database component (Ready, data kept across a restart, clean teardown), and on prod, which enforces network policy, a control run without the component's operator policy never became healthy. Verified by rendering only: the chart passing the database Secret into the container. Verified by walking it (2026-09-25): create → pipeline → ground deploy on the dev cluster; boarding-api on dev serves from flight-api (live status from the simulator); flight-api runs on prod staging with a two-instance database. Walking it found and fixed the slow emulated Java build and a liveness probe pointed at the readiness endpoint. Not yet confirmed: boarding-api on prod staging still uses its built-in lookup (FLIGHT_API_URL not set there), so the staging call path has not been exercised.

Needs airframe v0.3.88 or later

env: entries with valueFrom arrive in v0.3.88. Do part 1 first: this guide assumes boarding-api is running in its dev environment and links back rather than repeating the mechanics.

THE PATH

Six steps, one new idea

The new idea is that a database is a component, a block in the environment's values, not a Tower form. Everything else is part 1's mechanics.

Path from a new service to flight-api running on both clusters A flowchart of six steps: create the app, merge the pipeline pull request, pull in the code, add a ground environment with a database component, point boarding-api at flight-api, then add the flight environment on the prod cluster. New service Create the app SpringBootApplication · Tower Merge the .tekton PR Pipelines-as-Code Pull in the code examples/skyport/flight-api Ground env + database components: [postgresql] Point boarding-api at it FLIGHT_API_URL Flight env on prod cluster same, in app-flight-api-staging

01 — WHAT YOU'RE BUILDING

Two namespaces, one dedicated database

flight-api and its database live in one namespace. The database is a dedicated PostgreSQL cluster for this app environment, not a shared one: deleting the environment deletes its data, and nothing outside the namespace can reach it.

flight-api, its database and how boarding-api reaches it A browser calls boarding-api, which caches in Redis and calls flight-api. flight-api reads and writes its own PostgreSQL database, and gets its credentials from a Secret the database component creates. The CloudNativePG operator, in its own namespace, manages the database through a network policy that allows it in. APP-BOARDING-API-DEV APP-FLIGHT-API-DEV HTTP GET /flights GET / SET SQL VALUEFROM CREATES MANAGES · :8000 Gate board boarding-api NodeJS · part 1 2 replicas Redis cache-master:6379 component flight-api Spring Boot · Java 21 port 8080 flight-db PostgreSQL · CNPG database flight_db flight-db-app Secret · host, password… CNPG operator cnpg-system Runtime traffic Created / managed

One database, one role

The component creates a database and the role that owns it with the same name (flight_db). Its pg_hba lets a role connect only to the database named for it, so the app cannot reach any other database, postgres or template1.

Credentials, not copies

CNPG writes flight-db-app (host, port, dbname, username, password, uri…). The container reads it directly with valueFrom.secretKeyRef. Nothing is copied into Infisical.

The operator gets in

Namespaces deny cross-namespace ingress by default, which would cut the operator off. The component adds a policy that admits cnpg-system and nothing else.

02 — CREATE THE APP

SpringBootApplication in Tower

Tower → Create → SpringBootApplication

FieldValue
Nameflight-api
devClusterdev
descriptionSkyport system of record: flights and gates in Postgres
javaVersion21
buildToolmaven
groupIdio.skyport.flight
port8080
visibilityprivate

Two values that matter

dev exactly, even if your dev cluster goes by another name (see part 1, section 02: the dev ApplicationSets hard-code that name). io.skyport.flight for groupId: the scaffold uses it as the app's one Java package, and the code you copy in sits in io/skyport/flight/.

Merge the PR Tower opens, then check the XR:

$ kubectl get springbootapplication flight-api -n app-flight-api-cicd
NAME         SYNCED   READY   COMPOSITION                              AGE
flight-api   True     True    springbootapplications.catalog.idp.io    3m

The source repo starts with a hello-world Application.java, a pom.xml (web and actuator only), an application.properties and a Containerfile. If a previous flight-api was decommissioned, finish decommissioning first (including its Infisical projects).

03 — PIPELINE ONBOARDING

Merge the .tekton pull request

Same as part 1, section 03: Glidepath opens a PR adding .tekton/. Merge it, or pushes do nothing.

04 — PULL IN THE CODE

Replace the hello-world

The real flight-api lives in the airframe repo under examples/skyport/flight-api/.

git clone https://github.com/jfillman/airframe.git /tmp/airframe   # skip if you still have it
git clone https://github.com/jfillman/flight-api.git && cd flight-api

cp -R /tmp/airframe/examples/skyport/flight-api/. .
git add -A && git commit -m "flight-api: flights and gates in Postgres"

This overwrites the scaffold's pom.xml, Application.java, application.properties and Containerfile, and adds the other classes, the SQL migrations, the tests, test.sh, build.sh and the Maven Wrapper. The wrapper matters: the platform builds Java in a plain JDK image with no Maven, so test.sh runs ./mvnw, which downloads Maven on first use.

Why the Containerfile is replaced (unlike part 1)

The scaffold's Java Containerfile compiles the app inside the image build. That build runs for two architectures, and the amd64 leg runs under QEMU emulation on the arm64 build node, where Maven on a JVM took about 11 minutes, while the native arm64 leg took about five (the legs run one after the other: about 17 minutes in all). Java bytecode is identical on both, so the fix is to compile once, natively: build.sh compiles inside the pipeline's Java agent, and the new Containerfile only copies the jar into a JRE image. Measured: the emulated amd64 packaging leg takes about 30 seconds. FROM --platform=$BUILDPLATFORM, the usual answer, does not work here: this platform's builder (kaniko) ignores it, so the build stage still ran emulated.

./test.sh                # 17 passing (needs a JDK 21 locally, or run it in a container)
EndpointPurpose
GET /api/flights/{flight}Gate, status, scheduled and estimated departure, capacity, boarding group
GET /api/flightsAll 24 flights
GET /api/flights/{flight}/eventsThe change log for one flight, newest first
PUT …/gate · PUT …/delayMove a flight, or delay it
GET /api/whoamiVersion and pod
GET /actuator/health/readinessReady only when the database answers

Every gate or delay change is written to a flight_events table in the same transaction as the change, so the log can never disagree with the flights. Phase 2's message broker will publish from that table. A simulator (every 30 s) moves a random flight or grows a delay so there is something to watch.

05 — GROUND

The service and its database

Same two-part shape as part 1 (a file that creates the environment, a pipeline stage that deploys to it) with a database added.

Declare the environment

flight-api/platform/envs/dev.yaml ground
envName: dev
rollout: null            # explicit; the deploy stage fills in the image later

components:
  - type: postgresql
    name: flight-db      # the Secret this creates is named flight-db-app
    spec: { size: small, instances: 1, storageSize: 1Gi }

env:
  # Read straight from the Secret the database component creates - no copying into Infisical.
  - { name: DB_HOST,     valueFrom: { secretKeyRef: { name: flight-db-app, key: host } } }
  - { name: DB_PORT,     valueFrom: { secretKeyRef: { name: flight-db-app, key: port } } }
  - { name: DB_NAME,     valueFrom: { secretKeyRef: { name: flight-db-app, key: dbname } } }
  - { name: DB_USER,     valueFrom: { secretKeyRef: { name: flight-db-app, key: username } } }
  - { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: flight-db-app, key: password } } }
  - { name: SIMULATOR_INTERVAL_MS, value: "30000" }

networkPolicy:
  allowIngressFrom:      # let boarding-api's namespace reach us (section 06)
    - { namespace: app-boarding-api-dev, ports: [8080] }

The database is created before the app runs. With rollout: null there is no workload yet, so the first thing this file produces is the PostgreSQL XR and its cluster, within about a minute:

kubectl get postgresql -n app-flight-api-dev              # READY True
kubectl get cluster.postgresql.cnpg.io -n app-flight-api-dev

Let the pipeline deploy to it

flight-api/cicd.yaml ground
apiVersion: platform/v1
kind: PipelineConfig

build:
  agent: openjdk-21
  script: ./build.sh            # compiles natively in the Java agent (section 04)
  containerfile: ./Containerfile  # thin: just copies the jar in
  unitTest:
    enabled: true         # runs ./test.sh

deploy:
  lowerEnvironments: [dev]

pipelines:
  ci:
    trigger: { source: git, event: push, branch: main }
    steps:
      - stage: build
      - stage: test
        env: dev
      - stage: deploy
        env: dev

Commit both, merge the .tekton PR, push

As in part 1 the deploy stage commits the image straight into platform/envs/dev.yaml on main, so git pull before your next push. The first start is slower than a NodeJS app: the JVM starts, then Flyway waits for the database if it isn't up yet (it retries for about two and a half minutes) and runs the two migrations.

Add health probes

Once the deploy stage has written the image, add probes to the same file. (Adding them earlier would make rollout non-null with no image.)

rollout:
  image: { … }          # keep what the deploy stage wrote
  resources:
    requests: { cpu: 500m, memory: 512Mi }   # a JVM starts CPU-bound
    limits: { memory: 1Gi }                  # memory only: no CPU limit
  readinessProbe:
    httpGet: { path: /actuator/health/readiness, port: 8080 }
    initialDelaySeconds: 30
    periodSeconds: 10
    timeoutSeconds: 5
    failureThreshold: 6
  livenessProbe:
    httpGet: { path: /actuator/health/liveness, port: 8080 }   # NOT /readiness
    initialDelaySeconds: 60
    periodSeconds: 20
    timeoutSeconds: 5
    failureThreshold: 6

Readiness is what makes the pod wait for the database: it isn't sent traffic until Postgres answers.

Two mistakes that make a healthy app restart

Liveness must use /actuator/health/liveness, not /readiness. Readiness includes the database, so a brief database or CPU stall makes a healthy pod look dead and Kubernetes kills it. This was hit for real: both dev pods restarted 5–6 times (exit 137, Liveness probe failed) until the path was corrected, after which the same pods started in 24–33 seconds and stayed up. Give it a CPU request. Startup is CPU-bound, and pods without a request are BestEffort, the lowest CPU priority. While a pipeline build shared the node, the same pods had not finished starting after about 100 seconds. (That link is inferred from the timings, not isolated with a controlled test.) timeoutSeconds: 5 matters too: the 1-second default fails whenever the JVM is briefly busy.

See it

kubectl port-forward -n app-flight-api-dev svc/flight-api 8081:8080
curl -s localhost:8081/api/flights/AC123
curl -s localhost:8081/api/flights/AC123/events

The simulator writes an event about every 30 seconds; watch events grow. And because the component labels everything it creates, one selector finds the app, its database, its Secret and its volume together:

kubectl get all,pvc,secret,networkpolicy -n app-flight-api-dev -l hangar.io/app=flight-api

Why the component adds a NetworkPolicy

This was tested on prod, which enforces network policy, with the same database cluster in two namespaces that both carry the app baseline (ingress only from the same namespace).

With the operator policy

Ready in about a minute

What's in the namespace
Baseline policy plus flight-db-cnpg-operator, which admits cnpg-system on 8000 and 5432.
Result
Cluster in healthy state. The XR goes Ready=True. A pod in another namespace is blocked.

Without it (control)

Never becomes healthy

What's in the namespace
Baseline policy only.
Result
Stuck at Instance Status Extraction Error: HTTP communication issue: the operator can't reach the instance's manager, so the cluster never reports ready.

06 — POINT BOARDING-API AT IT

The gate board shows real data

Copy the updated gate board over part 1's

cd boarding-api
cp /tmp/airframe/examples/skyport/boarding-api/{app.js,index.js,reservations.js} .
cp /tmp/airframe/examples/skyport/boarding-api/public/index.html public/
cp /tmp/airframe/examples/skyport/boarding-api/test/app.test.js test/
npm test                                     # 11 passing

Then add one line to its platform/envs/dev.yaml, commit and push:

env:
  - { name: REDIS_URL, value: "redis://cache-master:6379" }
  - { name: FLIGHT_API_URL, value: "http://flight-api.app-flight-api-dev.svc.cluster.local:8080" }

With FLIGHT_API_URL unset the app falls back to its built-in stand-in, so it still runs anywhere. Set, it never invents an answer: an unknown flight is a 404, and flight-api being down is a 502 that is not cached.

Look up AC123

The source row now says flight-api (Postgres) and there is a status row (ON_TIME, or DELAYED (+15 min) once the simulator has touched it). The first lookup takes tens of milliseconds because it really hit a database; the second is a cache hit.

Now see the problem Phase 2 fixes

curl -s -X PUT -H 'Content-Type: application/json' -d '{"gate":"B2"}' localhost:8081/api/flights/AC123/gate

Look up AC123 on the gate board again: it still shows the old gate, from the cache, and is right only after the three-minute TTL. Waiting for a TTL is the wrong way to keep a gate board accurate. The fix is for flight-api to publish flight.gate-changed and for boarding-api to evict the entry, which is Phase 2 of Skyport. The flight_events table is already there for it.

07 — FLIGHT

The same service on the prod cluster

Works exactly as part 1, section 07. Only what differs is here. prod has the CloudNativePG operator and the PostgreSQL XRD installed. It runs Kubernetes 1.37, which CloudNativePG 1.30 lists as tested but not supported upstream, so the component was verified there rather than assumed.

Declare it and create the environment

Add upperEnvironments: [{ name: staging, cluster: prod }], governance.allowedCommitSigners and a release stage to cicd.yaml (as in part 1), merge the .tekton/ PR, then Tower → Create → ApplicationEnvironment named flight-api-prod-staging. The environment starts as rollout: null, so the database is created first, in app-flight-api-staging.

Configure it in a pull request

gitops-flight-api/prod/staging/values.yaml flight
rollout:
  replicas: 2
  ports: [{ name: http, containerPort: 8080 }]
  resources:
    requests: { cpu: 500m, memory: 512Mi }
    limits: { memory: 1Gi }
  readinessProbe:
    httpGet: { path: /actuator/health/readiness, port: 8080 }
    initialDelaySeconds: 30
    timeoutSeconds: 5
    failureThreshold: 6
  livenessProbe:
    httpGet: { path: /actuator/health/liveness, port: 8080 }   # NOT /readiness
    initialDelaySeconds: 60
    timeoutSeconds: 5
    failureThreshold: 6

components:
  - type: postgresql
    name: flight-db
    spec: { size: small, instances: 2, storageSize: 5Gi }    # a primary and a replica

env:
  - { name: DB_HOST,     valueFrom: { secretKeyRef: { name: flight-db-app, key: host } } }
  - { name: DB_PORT,     valueFrom: { secretKeyRef: { name: flight-db-app, key: port } } }
  - { name: DB_NAME,     valueFrom: { secretKeyRef: { name: flight-db-app, key: dbname } } }
  - { name: DB_USER,     valueFrom: { secretKeyRef: { name: flight-db-app, key: username } } }
  - { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: flight-db-app, key: password } } }
  - { name: SIMULATOR_INTERVAL_MS, value: "60000" }

networkPolicy:
  allowIngressFrom:
    - { namespace: app-boarding-api-staging, ports: [8080] }

instances: 2 gives a real failover pair; on a small single-node cluster 1 is fine. The image is not set here: the release pipeline sets it.

Release, then point boarding-api at it

Release as a signed merge, as in part 1. In boarding-api's staging values, set FLIGHT_API_URL to http://flight-api.app-flight-api-staging.svc.cluster.local:8080. Deleting this environment deletes its database and both volumes.

Tower and valueFrom

Tower's Environment variables section is a name/value form. Older builds saved the whole env list back as name/value pairs, which replaced a valueFrom entry with an empty value (the app then started with a blank DB_HOST). Builds from Backstage commit 366ea8c on show valueFrom rows read-only (← Secret flight-db-app / host) and keep them on save. If your Tower predates that, edit this section in the PR directly. Either way, read the PR diff before merging.

CHEAT SHEET

What will actually bite you

dev, and io.skyport.flight

devCluster: dev exactly, and groupId: io.skyport.flight: the code you copy in assumes that package.

A database is a component

A block in the environment's components:, not a Tower Create form. Read its credentials with valueFrom.secretKeyRef to <name>-app.

Build Java with build.script

Not in the Containerfile. The amd64 image leg runs under emulation: a Maven build there took about 11 minutes (17 for the whole image), the packaging-only leg about 30 seconds.

Liveness is /liveness, never /readiness

Give the pod a CPU request too. Symptom of getting it wrong: repeated restarts (exit 137, Liveness probe failed) though the database is fine.

Add probes after the first deploy

rollout must stay null until the deploy stage has written an image.

Cross-namespace traffic needs a rule

Namespaces deny ingress from others by default; networkPolicy.allowIngressFrom opens exactly one.

Deleting the environment deletes the database

It is dedicated, and its volume goes with it. Database name equals role name; the component enforces it.

One selector, and older Tower

-l hangar.io/app=<app> finds everything. Older Tower builds drop valueFrom when you edit env variables; read the PR diff. test.sh uses ./mvnw because the Java agent has no Maven.