Previews: an environment for every merge request
A preview is an ephemeral copy of a product, deployed for one merge request so that the people reviewing a change can try it in a browser before it merges. Airframe deploys it only once the right people have approved the change, gives it an address of its own, and destroys it when the merge request is merged or closed. This page explains what a preview contains, who deploys it, when it goes away and what it costs.
What a merge request gets
Section titled “What a merge request gets”An approved merge request of a product gets three things.
Its own hostname. The address is <product>-eph-<iid>.<domain>.<journey>.projectairframe.dev, where <iid> is the merge request’s number. Merge request 42 of a product called invoicing in the billing domain of the settle journey would be at invoicing-eph-42.billing.settle.projectairframe.dev. Because a preview has a hostname rather than a path under another one, cookies, redirects and links work as they do in production. Like every product hostname, it sits behind single sign-on, and only the product’s users group gets in. The domain’s wildcard certificate and record already cover every preview, so nothing is issued for each one.
The preview router. Each product has one small service on its non-production project, created by the golden path’s preview-substrate module. It reads the merge request’s number from the hostname and forwards each request to that preview’s services. Each preview service accepts calls from the router alone, so the hostname, with its sign-on, is the only way in.
A database. The catalog entry’s preview_db field chooses how a preview gets one:
sidecar, the default: a PostgreSQL container that runs beside the preview’s service in the same Cloud Run revision, seeded on start from the product’s.airframe/eph/files. It costs nothing while idle and forgets its data when its instance goes away. The golden path’s Cloud Run module runs a preview’s service as one instance at most, since the data lives in that instance, and the policy pack warns about a service with such a database that could run more.shared: one Cloud SQL instance per product, sized by the entry’spreview_concurrency, in which each preview gets a database of its own. It needs the product’s non-production project to be on the platform’s shared network; until it is, a shared preview cannot be applied.
What a preview contains is the product’s own choice, in its infra/eph Terraform root, composed from the same modules as production. The lifecycle around it is the platform’s.
Who deploys it, and when
Section titled “Who deploys it, and when”A merge request’s own pipeline never deploys a preview: it runs code nobody has reviewed yet, so it holds no cloud credential. The preview comes from the executor, at the approval.
When the gate approves a product’s merge request, it starts the executor’s runs for that commit: the plans, the image build and the preview. The preview run waits for the build of the merge request’s latest commit, for up to 25 minutes, and stops if the build failed. Then, with the product’s non-production apply identity, it plans infra/eph, runs the policy pack on the plan, prices it and applies it. It posts a note on the merge request with the preview’s address and the time it will expire.
A new push resets the merge request’s approvals. The next approval applies the same preview again with the new commit, which is how a preview follows the change. The production identity is never used, so a preview cannot touch production. A product whose catalog status is retiring gets no preview, since it serves nothing.
When it is destroyed
Section titled “When it is destroyed”A preview is destroyed at the first of three moments:
- The merge. The gate learns of the merge from GitLab and starts the executor’s destroy run. The executor checks for itself that the merge request is merged or closed, destroys the preview’s resources with the same non-production identity, and posts a note. The preview’s Terraform state is kept, empty, as a record.
- The close. A closed merge request is handled the same way.
- The time to live. The entry’s
preview_ttl_hours, 72 hours unless the entry says otherwise and at most 168, counted from the preview’s latest apply.
The time to live, a missed notification and a destroy that failed are all caught by the collector: preview:collect, a job of the control-plane repository’s daily scheduled pipeline. It lists every preview the executor’s records say is still up, checks each merge request’s state and expiry, and destroys the merged, the closed and the expired ones, with a note on each merge request.
Each preview has a record that holds what was applied, and a lock, so an apply and a destroy never run at the same time. A merge request that is closed and reopened starts a new generation of the record, so a late destroy cannot remove the new preview.
What it costs
Section titled “What it costs”The router and the preview’s services scale to zero, and a sidecar database costs nothing while idle; a running preview service carries about 512 MiB more memory for its database. A shared instance costs about USD 9 a month per product while it exists.
The executor prices every preview’s plan and shows the figure in its note. The cost check that keeps a merge closed above the product’s budget leaves the preview out, so a preview never blocks a change. And because only an approved change gets a preview, someone who can only open merge requests cannot run up the bill.
How a preview differs from the sandbox
Section titled “How a preview differs from the sandbox”A sandbox is a long-lived, optional copy of a product at the version on its main branch, with its own Cloud SQL instance and a service kept warm, at <product>-sandbox.<domain>.<journey>.projectairframe.dev. Other products test against it, and it costs money all month. A preview holds one merge request’s change, lives for hours or days, and is gone after the merge.
Seeing what a preview does
Section titled “Seeing what a preview does”A preview’s logs, metrics and traces carry the environment label eph-<iid>, so that the product’s dashboards show it by choosing that value. The golden path sets deployment.environment to that value on the preview’s service, and the OTLP gateway takes it as env, while the platform agent and the logs gateway derive it from the service’s name. The alert rules select production alone, so a preview never wakes anyone up. Observability explains the labels.
Designed, not built
Section titled “Designed, not built”- The smoke test (designed). The product’s
.airframe/preview.yamlnames a smoke test and commands to run after creation, and nothing runs them yet. Today the apply waits until the new revision is ready, and the application sets up its own database schema and seed data on start. - The dashboards on the merge request (designed). The note would link to the product’s dashboards filtered to the preview, and a preview’s errors would arrive as a comment on the merge request.
- The audit agent (designed). It would find a preview that has no record, or one whose destroy failed.
Previews ran in the platform’s rehearsals in September 2026. A preview needs the product’s projects to be billed, and no demo product is billed today, so none has a preview now.
This website runs on the platform too, but it has no previews: the maintainer decided that a merge request to the site gets none.
Where it lives
In the control-plane repository:
- executor/scripts/preview.sh: the apply, the destroy and the collector
- docs/adr/0016: the preview lifecycle through the executor
In the golden-path repository:
