Skip to main content
formae is built to run unattended. The same formae apply you run from a laptop works in a CI/CD job once you turn off the interactive prompts and read structured output instead of tables. This guide covers the non-interactive contract, the simulate-in-a-pull-request then apply-on-merge flow, passing values between stages, worked GitHub Actions and GitLab CI pipelines, reading discovered resources, and fanning the same pipeline out across many repositories. The Git as the source of truth operating model is the natural fit here: your forma files live in Git, the pipeline is the only thing that applies them, and every change lands through a reviewed merge.

Run formae non-interactively

A pipeline runner needs the formae CLI and a way to reach the agent. The agent is long-lived and shared: you do not start one per job. Many pipelines and humans connect to the same agent at once. Install the CLI in the runner the same way you would locally:
The runner has to reach the agent. The default profile points at http://localhost:49684, which is only right when the agent runs on the same host. For a remote agent, add a profile whose cli.api.url is the agent host and select it per command with --profile, or point the CLI at the agent through your CI secrets.
Three flags turn apply into something a pipeline can drive:

Gate the pipeline on the outcome

apply submits the command to the agent and the agent executes it asynchronously. In machine mode, apply prints the command ID as soon as the command is accepted:
To make the job pass or fail on the actual result, capture that ID and poll formae status command until the command reaches a terminal state:
A CI runner has no interactive terminal, so apply is fire-and-forget there: it submits the command and returns as soon as the agent accepts it. Use the formae status command poll above wherever the pipeline result has to gate a merge or a deploy - it streams status and fails the job when the apply fails.

Simulate in the pull request, apply on merge

The everyday pipeline has two triggers. On a pull request, validate the forma and preview the changes without touching anything. On merge to the main branch, apply them.
1

Validate in the pull request

formae eval catches syntax and type errors. Running apply with --simulate previews the plan without making any cloud API calls, so reviewers see exactly what the merge would do.
2

Apply on merge

Once the change lands on the main branch, apply it. Reconcile mode brings the stack into the exact shape the forma describes.
Here is the same flow as a complete pipeline for each system.
The deploy jobs above are fire-and-forget: apply returns as soon as the command is accepted, so the job can go green before the deploy finishes. For a hard pass/fail gate on the main branch, follow the apply with the formae status command check so the job waits for the terminal state and fails on Failed or Canceled.

Parameterize per environment

You rarely want a separate forma per environment. Declare properties for the parts that vary and formae turns each one into a CLI flag automatically. One forma then deploys to staging or production by passing different flags.
Here --env exists because the forma declares an env property. To see which flags a forma exposes, pass it to --help:
The output lists a Properties: section with each flag, its default, and whether it is required. For the full pattern, including how to constrain inputs so a bad value fails before any cloud call, see Build self-service infrastructure.

Pass values between stages

A common shape is provision, then deploy, then verify: one job creates infrastructure, the next needs a computed value from it (a hostname, an ARN, a generated name), and the last smoke-tests the result. The machine output of formae inventory is the bridge between stages. Computed values live under ReadOnlyProperties; values you declared in the forma live under Properties. Query the resource you just created and pull the field you need:
The two systems forward the value differently. GitHub Actions writes it to $GITHUB_OUTPUT and reads it back through needs.provision.outputs. GitLab CI writes it to a dotenv artifact and later jobs that needs this one get the variable injected automatically. The formae side of the bridge is identical: one formae inventory query, one jq extraction.

Read a discovered resource in a pipeline

Sometimes a stage needs a value from a resource that lives in your cloud account but is not managed by formae: a legacy database owned by another team, shared infrastructure, anything that predates formae. Hard-coding the value in a CI variable works once and then rots. Reading it through discovery keeps the pipeline correct for as long as the resource exists. First make the resource discoverable by applying a target that points at the account or region where it lives. No stack, no resources, just a target:
Discovery scans the target on an interval and surfaces the resources it finds with managed: false: visible to inventory and extract, untouched by apply. Once the resource has been picked up, query it exactly as you would a managed one, with a managed:false filter:
If you later decide to bring the resource under management, slice it out by query and give it a stack:
Edit orders-db.pkl to add a stack, then formae apply --mode reconcile orders-db.pkl. For a larger estate, slice by team, type, or environment, one stack per slice. See Bring resources under management for the full adoption path.

Provision the pipeline itself as a forma

The pipelines above run formae. You can also let formae manage the pipeline. When you have a plugin for your CI system, the workflow file, the variables and secrets it depends on, and the environments it deploys into are all resources. One formae apply lays them all down.
Applying this forma writes .github/workflows/deploy.yml into the repository as a managed resource. When the workflow definition is code, editing it is an ordinary forma change that flows through the same review-and-apply pipeline as everything else.

Fan out across many repositories

To run the same pipeline shape across many repositories with per-repository overrides, wrap the pieces in a Pkl class and instantiate it once per service. Each instance carries its own target, so a single forma provisions the whole fleet.
The fleet manifest lists one entry per service. Class defaults carry the common case, and you override only the fields that differ:
Adding a repository means appending one listing entry and re-applying. Changing a step in the class updates every repository at once. Because this is reconcile mode, anything in the service-fleet stack that is no longer declared gets removed, so the forma stays the single source of truth for the whole fleet. The class pattern is covered in depth in Reuse with modules.

Tips and gotchas

One agent serves many clients. Multiple pipelines and humans can connect to the same agent at the same time. Do not start an agent per job.
Concurrent applies are safe when each pipeline works on its own stack and target. Conflicts arise when two applies touch the same stack and target at once. For ephemeral environments, put a unique ID in the stack label so each pipeline gets its own stack. For shared stacks, serialize the deploys.

See also