You know the concepts. This page shows you how they look as YAML — the declarative catalog you actually write and apply.
The catalog is a set of documents that describe your fleet model: entity types, cells, relationship types, actions, and secrets. You apply it with one command, and Terrantula reconciles the model. The snippets below are drawn from the canonical demos under examples/ in the repository — they're real, runnable config, not invented for the docs.
The catalog is the whole declarative model. Each document in it has a kind — EntityType, Cell, RelationshipType, Action, Secret. If you've used Kubernetes manifests, the shape will feel familiar: apiVersion + kind + metadata + spec, one resource per document.
Every catalog document follows the same envelope:
You can put many documents in one file, separated by --- (standard multi-document YAML). A whole fleet blueprint is typically a few hundred lines in a single blueprint.yaml. You apply it like this:
Terrantula validates the entire document set, then reconciles it: new kinds are created, changed ones updated. References between documents (a cell pointing at an entity type, a relationship type pointing at two entity types) are checked at apply time, so a typo in a name fails loudly instead of silently.
We'll walk the kinds in the order you'd naturally define them.
Here's the TenantCluster entity type from the canonical demo. Read the comments — they explain each block.
A few things to notice:
states plus initialState is the entity's lifecycle. active and failed are always available implicitly; you list the rest.tenant-count metric is derived: Terrantula computes it by counting active runs_on relationships pointing at the cluster. You never set it by hand — it tracks reality as relationships are created and removed.constraint turns that metric into a rule. It's enforced when Terrantula tries to place a tenant, before anything provisions.The Tenant entity type is simpler — properties and a lifecycle, no derived metrics:
Properties can be written in the long form (one field per line) or the compact inline-map form shown here. Both are valid YAML; use whichever reads better.
A cell groups entities of one type, ranks them for placement, and caps the group:
Note the two layers of limit working together: the entity type capped each cluster at 50 tenants; this cell caps the fleet at 500 summed across all clusters. placementPolicy: least-loaded means when Terrantula needs to place a new tenant, it sorts the clusters by tenant-count ascending and picks the emptiest one that still has room.
cardinality is enforced, not advisory. With many-to-one, Terrantula will reject an attempt to give one tenant two active runs_on relationships. The relationship also has its own states and properties — here, the per-tenant Kubernetes namespace lives on the relationship, because it's a fact about the link between a specific tenant and a specific cluster.
Secrets declare a credential by name; you set the value separately so it never lands in your YAML or version control:
You set the value out-of-band, after applying:
In Actions you reference it as {{ secrets.tfc-api-token }}. The value is encrypted at rest and never printed.
The Action is the largest document because it ties everything together: inputs, placement, what changes in the graph, and how the change reaches your infrastructure. Here's OnboardTenant using the pull-request trigger (bare Terraform + GitHub Actions, one file per tenant):
Walking the structure:
associatedWith + scope — collection scope means this Action appears at the entity-type level ("onboard a new tenant"). instance scope would attach it to each existing entity ("suspend this tenant").recommendations — this is where cell placement is invoked. cell: prod-clusters, sortBy: tenant-count, order: asc resolves to "the least-loaded cluster." The chosen cluster is then available as {{ recommendations.target-cluster.* }} throughout the document.operation — declares the graph change and the entity's state at each phase. The onTrigger/onSuccess/onFailure states make the lifecycle explicit and recoverable.{{ ... }} interpolation — values flow from parameters, recommendations, and secrets into the operation and the trigger. {{ parameters.customer_id }} is operator input; {{ recommendations.target-cluster.properties.region }} is the placement result; {{ secrets.github-token }} is the credential.trigger — the pull-request trigger commits a tenants/<id>.tfvars.json file to a branch and opens a PR. When a reviewer merges it, the customer's existing GitHub Actions workflow runs terraform apply, and Terrantula transitions the tenant to active.
Notice there is no terraform apply anywhere in this Action. The pull-request trigger commits files and opens a PR. The customer's CI does the apply on merge. Swap the trigger block for type: terraform-cloudand the same Action fires a TFC run instead — the operation and placement logic are unchanged. The trigger is the only thing that knows about your runner.
A full blueprint is just these documents, separated by ---, in dependency order: entity types, then the cell, then the relationship type, then the secrets, then the Action that uses all of them. Apply the whole thing at once:
Want to see complete, runnable versions? The canonical demos in examples/ ship full blueprints for several substrates:
cattle-saas-tenants — Terraform Cloud (terraform-cloud trigger).bare-tf-gh-actions-onefile-per-tenant — bare Terraform + GitHub Actions (pull-request trigger, one file per tenant).cattle-saas-tenants-atmos — Atmos (atmos-workflow trigger).cattle-saas-tenants-atlantis — Atlantis (atlantis trigger).Pick the one closest to how you run Terraform and mirror its shape.
You've now seen the whole catalog. Time to write a piece of it yourself — starting with the smallest meaningful unit of fleet modeling: a cell.
Next: Define your first cell → — your first hands-on config.