Skip to main content

The Definition File

Everything Octo does starts from one file. It says which modules make up your infrastructure, what each one is given, where state lives, and which Terraform providers the generated folders declare. For most teams it is the only file anybody edits, which makes it worth knowing section by section.

This is the shop's, from the Shop Stack, with a checkout server added.

octo.yaml
version: 1

env:
- key: AWS_ACCOUNT_ID
kind: string
value: "123456789012"
- key: RECEIPTS_BUCKET_NAME
kind: string
value: shop-receipts

terraform:
requiredVersion: "1.6.0"
requiredProviders:
aws:
source: hashicorp/aws
providers:
- providerType: aws
accountId: "${env.AWS_ACCOUNT_ID}"
regionId: eu-west-1
spec:
profile: default

settings:
listeners:
- type: JournalEventListener
- type: LoggingEventListener
stateProvider:
type: LocalStateProvider
statePath: .octo

imports:
- className: SimpleAppModule
importPath: "@quadnix/octo-aws-cdk/modules/app/simple-app"
# ...

modules:
- moduleId: app-module
moduleClass: SimpleAppModule
moduleInputs:
name: shop
# ...

version, imports, modules, settings, and terraform are required. env, extends, hooks, stores, and tags are optional. The file is validated against a JSON schema before anything else happens, so a typo in a key name is caught with a message about that key.

Octo reads octo.yaml from the working directory, or the file OCTO_DEFINITION_FILE_PATH names. Everything the file names by relative path, imports and the state directory included, resolves against the file's own directory rather than your shell's working directory.

Editor autocompletion

Put this at the top and your editor will complete keys and flag mistakes as you type:

# yaml-language-server: $schema=https://raw.githubusercontent.com/quadnix/octo/refs/heads/main/packages/octo-build/src/resources/octo-v1.schema.json

modules​

The intent. Each entry loads one instance of one module class.

modules:
- moduleId: receipts-module
moduleClass: AwsS3StorageServiceModule
moduleInputs:
region: "${{region-module.model.region}}"
bucketName: "${env.RECEIPTS_BUCKET_NAME}"
accessPrefixes:
- uploads
moduleIdYours to choose. It scopes everything this instance produces, and names its Terragrunt folder.
moduleClassA class name that appears in imports.
moduleInputsWhatever that module's schema declares. Validated before the module runs.

Modules run top to bottom, and a module can only reference what an earlier one produced. Loading the same class twice under two IDs gives two independent instances.

Changing a moduleId renames the folder and, as far as Octo is concerned, removes one module and adds another. Pick them once.

imports​

Where each module class comes from.

imports:
- className: AwsS3StorageServiceModule
importPath: "@quadnix/octo-aws-cdk/modules/service/aws-s3-storage-service"
- className: ShopRegionModule
importPath: "./dist/index.js"

A bare specifier is resolved from your project's node_modules. A relative path is resolved against the definition file, which is why your own modules are imported from ./dist/index.js after npm run build rather than from ./src.

Importing a class you never load is harmless. Loading one you never imported fails at boot, with a message naming the class.

terraform​

The provider setup that would otherwise sit at the top of every .tf file. Octo renders it into every generated folder.

terraform:
requiredVersion: "1.6.0"
requiredProviders:
aws:
source: hashicorp/aws
minVersion: "5.0.0"
providers:
- providerType: aws
accountId: "${env.AWS_ACCOUNT_ID}"
regionId: eu-west-1
spec:
profile: default

requiredVersion and requiredProviders become the terraform block. providers is one entry per deployment target, and a target is an account and a region together. Resources bind to a provider by that pair alone, which is why a shop that spans two regions needs two entries.

Each entry becomes a named provider block, and the name is built from the account number: _123456789012-eu-west-1. That name reaches every generated folder, and generated Terraform is committed, so accountName lets you put something else there:

  providers:
- providerType: aws
accountId: "${env.AWS_ACCOUNT_ID}"
accountName: prod
regionId: eu-west-1

The alias then reads prod-eu-west-1. The region is still added, so one name covers every region it is registered in. Two accounts may not share a name within one region; the second one fails at generate rather than writing two provider blocks that answer to the same alias.

spec is passed through to the provider block as written, so anything the provider accepts goes there: profile, assume_role, endpoints. If your module produces resources in a region with no matching provider entry, generate fails rather than guessing.

Moving resources to another account or region is one edit: point the modules at the new pair and replace the entry. Each folder keeps the provider block it last committed for as long as its Terraform state holds anything the old provider created, so the apply can destroy it, even after a failed apply and a partial commit, and the old entry can leave providers in the same change.

Every provider block gets region = <regionId> written onto it, except for the HashiCorp utility providers such as null, external, random, and local, which take no arguments at all and fail the plan with An argument named "region" is not expected here. Octo knows those by name. For a provider it does not know that behaves the same way, setRegionAttribute: false on the entry turns the line off.

Because a resource picks its provider by account and region alone, no two entries may share that pair, whatever their providerType. A second entry on the same pair stops generate with Multiple providers registered for account ... Cannot derive a unique provider, so a utility provider alongside an AWS one in the same region needs an accountId of its own.

settings​

settings:
listeners:
- type: JournalEventListener
- type: LoggingEventListener
level: info
colorize: true
stateProvider:
type: LocalStateProvider
statePath: .octo

listeners names what watches each command: the journal, and the terminal log. Each is optional, and Events and Listeners covers their options.

stateProvider is required, and picks where state is kept: Octo's own, the Terraform state Octo checks in and out around every Terraform command, and the app lock that apply and commit both require.

LocalStateProvider writes plain JSON to statePath.

RemoteStateProvider keeps state in a backend every machine reaches, and takes the app it belongs to, the token to act as, and a directory to work in:

  stateProvider:
type: RemoteStateProvider
stateManagementServiceUrl: https://state.example.com/api/v1/state
organizationId: shop-organization
appId: shop
sessionId: "${env.STATE_SESSION_ID}"
statePath: .octo

lockContext and checkInIntervalInSeconds are optional. State Providers covers what each key means.

TestStateProvider keeps state in memory and takes no path, which makes it useful only in tests. See State.

listeners decides what a run prints. LoggingEventListener is the console output every command produces; its level is one of trace, debug, info, warn, error, fatal, silent, and colorize defaults to whether the output is a terminal. Without any listeners a run is silent. Events and Listeners covers writing your own.

env​

Values you want to declare in one place and override from the shell.

env:
- key: AWS_ACCOUNT_ID
kind: string
value: "123456789012"
- key: ENABLE_NAT_GATEWAY
kind: boolean
value: false

Reference one as ${env.KEY}, anywhere in moduleInputs, in a provider's accountId, regionId, or spec, or in terraform.requiredVersion. Inside moduleInputs that means at any depth: a reference in a list, or in a key of an object, resolves the same as one at the top level.

A reference that is the whole value takes the key's kind. One inside a longer string is written into it, so an ARN can be built from its parts:

resources: ["arn:aws:ses:us-east-1:${env.AWS_ACCOUNT_ID}:identity/example.com"]

An environment variable named OCTO_ENV_ and the key wins over the declared value, which is what makes the same file work in CI:

OCTO_ENV_AWS_ACCOUNT_ID=999999999999 npm run octo:generate

A variable named like the key itself is ignored, so an AWS_ACCOUNT_ID your shell exports for other work cannot move a deploy to another account.

kind is how the string a shell gives you becomes the right type. It matters most for boolean: a naive conversion makes "false" true, so Octo parses the string and rejects anything that is not plainly a boolean.

Referencing a key you did not declare is an error naming the key, so a typo does not become an empty string.

hooks​

Optional callbacks around lifecycle points, for an audit trail or an approval gate.

hooks:
- importPath: "./dist/index.js"
method: registerAuditHooks

The named export is called, and returns the hooks to register. Hooks covers the shapes available.

tags​

Tags applied to every resource a run produces, without any module being told about them.

tags:
- tags:
ManagedBy: octo
Owner: platform-team
- scope:
moduleId: region-module
tags:
Tier: network

An entry with no scope reaches every resource; one naming a moduleId reaches only what that module declared, and wins where both set the same key. Tagging Resources covers what to put there.

extends​

Another app's state this app builds on. Everything that app recorded is part of this app's graph, as if it were written in this file, so this file declares no app of its own and its modules reference the other app's models directly:

extends:
- type: LocalStateProvider
statePath: ../platform/.octo

modules:
- moduleId: checkout-account-module
moduleClass: AwsIniAccountModule
moduleInputs:
accountId: "123456789012"
app: "${{platform-app-module.model.app}}"

- moduleId: checkout-server-module
moduleClass: AwsServerModule
moduleInputs:
account: "${{checkout-account-module.model.account}}"
serverKey: checkout
lambda:
- execution: "${{platform-payments-execution-module.model.execution}}"

An entry takes the same LocalStateProvider or RemoteStateProvider fields as settings.stateProvider, and Octo only reads from it. It reads the other app's newest sealed state, the one its last released lock left: with local state, the newest folder in its .octo/archives/; with remote state, the newest record the backend sealed. A change the other app has under way is never seen, and an app that has sealed nothing yet cannot be extended. generate reads it, and the rest of that run reads what generate read, so a commit the other app makes meanwhile reaches this app at its next generate. With remote state, statePath is where Octo keeps its copy of that state, so give the entry a folder of its own. What the other app owns is never yours to change: none of its code runs here, Octo writes no Terraform for it, a reference to what it built reads what that app committed, and a module whose moduleId that app already uses is refused. A resource belongs to the app that made it: this app can add its own resources that point at one, and a command that changes one fails.

A module of this app can only take a node whose class this file's imports loads. Importing a module loads every class it defines, and an import does not have to appear under modules. A node of a class nothing loaded is still read, and a module input taking one fails, naming the class. The other app must be generated on the same version of every package both import, Octo's included; one generated on another version fails, naming the package and both versions.

Two entries may share what they build on, as a web app extending both a backend and the platform that backend extends does: what both hold is declared once. This app's state holds the other app's part too, so an app extending this one sees both.

When this app also reads, as a store, an app the extended one stands on, the extended app's resources stand on what the store holds now. If the store changed one of those since the extended app last committed, generate fails, naming both resources: run the extended app first, and this one after it.

stores​

Other apps' states this app reads, each under a name. A store stays a graph of its own: this app declares its own app and builds next to the store, never on it. A module input takes what the store built by its name, a colon and the reference the store's own file would write, and the module walks from it as from anything else:

stores:
- name: platform
stateProvider:
type: LocalStateProvider
statePath: ../platform/.octo

modules:
- moduleId: checkout-app-module
moduleClass: SimpleAppModule
moduleInputs:
name: checkout

- moduleId: checkout-account-module
moduleClass: AwsIniAccountModule
moduleInputs:
accountId: "123456789012"
app: "${{checkout-app-module.model.app}}"

- moduleId: checkout-server-module
moduleClass: AwsServerModule
moduleInputs:
account: "${{checkout-account-module.model.account}}"
serverKey: checkout
lambda:
- execution: "${{platform:platform-payments-execution-module.model.execution}}"

A name holds letters, digits, _ and -. The colon names a store, so no moduleId of this app may hold one. stateProvider takes the same fields as an extends entry, and the store is read the same way: its newest sealed state, never a change under way, and once per run. Classes and package versions work as for an extends entry.

A store is read-only. None of its code runs here, Octo writes no Terraform for it, and a reference to what it built reads what that app committed. Standing a model on one of the store's, such as an account on its app, fails with Cannot add to read-only node "app=platform"!; to build on another app's graph, use extends. Changing a store's resource fails too, and so does naming this app after a store's app. What two stores hold, or a store and an extends entry, is declared once, the first read winning, extends before stores; a store holding nothing that is not already read fails.

This app's state holds only this app's models and resources. Beside its resources it keeps a copy of each store resource they stand on, as this app last saw it, so its own resources read back without the store. The store is read again on every run for what this app builds now. An app reading this one as a store sees only this app, and this app's overlays keep their anchors on a store there only when that app reads the store too.

A store changing, renaming or deleting a resource this app stands on shows in this app's next plan as a change to the resource standing on it. To stop reading a store, remove the modules that use it and the store; generate plans the deletion of what those modules built, even when the store has already deleted what it stood on.

References between modules​

${{...}} is how one module receives what another produced, and it is what makes this a graph rather than a list.

FormResolves to
${{<moduleId>.model.<key>}}A model the module produced. ${{region-module.model.region}}.
${{<moduleId>.overlay.<overlayId>}}An overlay the module produced, by its overlay ID.
${{<moduleId>.resource.<resourceId>}}A resource the module produced, by its resource ID.
${{<moduleId>.input.<key>}}An input that module was given, so two modules can share a value.
${{<moduleId>.metadata.<key>}}Something the module's registerMetadata() worked out.

The <key> is what the module registered its output under, and it is normally the model's node name: model.app, model.account, model.region, model.subnet, model.service. A dotted path digs into the value, so ${{region-module.model.region.regionId}} is the string rather than the node.

Hand over the node wherever you can. ${{region-module.model.region}} gives the storage module a region it can query for anchors; ${{region-module.model.region.regionId}} gives it a string it can only trust.

References resolve when the module that needs them runs, so the module being referenced has to be listed earlier. A reference that cannot be resolved is an error naming the reference.

Functions from your own code​

A module input that is a function, such as a bucket's filter or transform, takes an export of a JavaScript file of yours:

octo/upload.js
export function skipSourceMaps(filePath) {
return !filePath.endsWith('.map');
}

export function underApp(filePath) {
return `app/${filePath}`;
}
    moduleInputs:
directoryPath: dist
filter: "${import ./octo/upload.js#skipSourceMaps}"
transform: "${import ./octo/upload.js#underApp}"

${import <path>#<export>} is replaced by that export before the module runs. A relative path is read from the definition file's directory, and a package name from your project's dependencies, the same as imports. The reference is the whole value; one inside a longer string is an error.

Octo loads the file with Node, so it is JavaScript, or TypeScript you compile first. The function runs on every command, and Octo compares what it returns between runs, so it reads nothing but its arguments: one that reads the clock or the network changes the plan every time.

Several definition files​

There is no environment flag. A deployment that must not share state with another is a separate file with a separate statePath:

OCTO_DEFINITION_FILE_PATH=octo.prod.yaml octo generate -o .octo-tf-prod

Keep the output directories separate too. Octo leaves folders it does not recognize alone, so nothing is overwritten, but terragrunt run --all does not know that: point two definitions at one directory and an apply of either one applies both.

Octo's environment model is a different idea, and the one to reach for first. staging and qa inside one region are environment models: they live in one graph, share one state file, and can refer to each other. A separate file is for deployments that must be isolated from each other, which for most teams means production and everything else.