Infrena documentation
Everything needed to write configuration, run it, and operate it: the language, variables and environments, modules, templates, secrets, plugins, adopting infrastructure you already run, state backends and CI.
New to Infrena? Your first project runs the whole lifecycle against a local fake cloud, with no account and no credentials.
What Infrena is
Infrena reconciles infrastructure described in YAML with infrastructure that actually exists. You write what you want, it works out what has to change, it shows you, and then it does it.
If you have used Terraform or Pulumi, the shape will be familiar: declare resources, get a plan, apply the plan. What is different is everything around that loop.
- Environments are part of the project. You declare
dev,stagingandproductionin one file. You do not build a directory-copying scheme to get them. - Wiring mistakes fail before anything runs. A provider's schema says what every attribute means, so a typo or a field pointed at the wrong kind of resource is a compile error, not an API rejection halfway through an apply.
- Values remember where they came from. A plan can tell you a number came from a provider default rather than from something you wrote.
- Every cloud is a plugin. Providers and state backends run as separate processes, so the engine stays small. It has three third-party dependencies.
What you need
One binary. No runtime, no language toolchain, no daemon. You will also want at least one provider plugin, and those install with a single command once you have the engine.
Read Your first project next. It runs the complete lifecycle against a local fake cloud, so you can see a plan, an apply, drift detection and a destroy without an AWS account or a single credential.
Install
Download a binary, check it against the published checksums, put it on your PATH.
Releases ship for macOS, Linux and Windows, on Intel and ARM. Every release includes a SHA256SUMS file covering all of them.
The current release is 0.14.0. Every version shown anywhere on this site comes from one file, versions.json, last updated 2026-09-20.
VERSION=0.14.0
BASE=https://github.com/Infrena/infrena/releases/download/v$VERSION
curl -fsSLO $BASE/infrena_${VERSION}_linux_amd64.tar.gz
curl -fsSLO $BASE/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing # shasum -a 256 -c on macOS
tar -xzf infrena_${VERSION}_linux_amd64.tar.gz
sudo mv infrena_${VERSION}_linux_amd64/infrena /usr/local/bin/
infrena version
Infrena verifies every plugin it installs against plugins.lock. A tool that does that while shrugging at its own download would not be worth believing, so SHA256SUMS is published with every release for exactly this.
Supported platforms
| OS | Architectures |
|---|---|
| macOS | amd64, arm64 |
| Linux | 386, amd64, arm64, armv7 |
| Windows | amd64, arm64 |
Your first project
The whole lifecycle, start to finish, with no cloud account and no credentials. The fake provider keeps its cloud in a JSON file on disk, so everything below is real behaviour against a pretend world.
Create the project
mkdir hello-infrena && cd hello-infrena infrena init . # scaffold in place infrena plugins install fake # the local fake cloud
infrena init on its own creates the project in ./infrena/, so infrastructure can sit beside the application it belongs to. infrena init . puts it in the current directory instead.
Write some configuration
Replace infra.yml with this. It declares one environment, one variable, a network, and a database that sits in it.
project: hello
providers:
- plugin: fake
environments:
dev: {}
variables:
db_size:
type: integer
default: 10
resources:
net:
type: fake.network
cidr: 10.0.0.0/16
db:
type: fake.database
engine: postgres
network: ${net.id}
size: ${var.db_size}
Two things are already happening that are worth naming. network: ${net.id} is a dependency, not a string that mentions something: the network gets created first and the real id is substituted. And size is typed, so a value of the wrong shape is caught before anything runs.
Check it, then plan
infrena validate # config errors only, contacts nothing infrena plan dev
Plan for project "hello", environment "dev":
+ fake.database.db
endpoint: (known after apply)
engine: "postgres"
network: (known after apply)
size: 10 [variable, from base config]
+ fake.network.net
cidr: "10.0.0.0/16"
id: (known after apply)
Plan: 2 to create, 0 to update, 0 to replace, 0 to destroy, 0 to forget.
Note size: 10 [variable, from base config]. That bracket is the value's provenance, and it is the answer to “why is this 10 when I set it to something else somewhere”. It tells you which layer won.
Apply it
infrena apply dev infrena plan dev # nothing to do, the second time
Break it on purpose
This is the part worth doing. The fake cloud is a hand-editable JSON file, so you can change the world behind the tool's back and watch it notice.
# edit .infra/fake-cloud.json and change the database's size to 999 infrena refresh dev # read the real world into state infrena plan dev # size: 999 -> 10
That is drift detection. The configuration is the desired state, and anything that differs is scheduled to be put right.
Clean up
infrena destroy dev
Swap fake for aws and the same commands talk to a real account. Nothing about the workflow changes.
The workflow
Four commands do the day-to-day work, and they always run in the same order.
infrena validate # is the configuration correct? contacts nothing infrena plan dev # what would change? infrena apply dev # make it so infrena refresh dev # what changed without me?
validate
Parses and type-checks everything, resolves references, and confirms the plugins and state backend the project names are installed. It contacts no providers, so it is fast and free, and it is the right first gate in CI.
plan
Reads the real world, compares it to your configuration, and prints what it would do. Nothing is changed. A plan names an environment, always.
Save one with --output and apply exactly that plan later. A saved plan is applied without recompiling, and is refused outright if state moved since it was made, so what runs is what was reviewed.
apply
Executes a plan. With a saved plan it applies that one; without, it makes a fresh plan and asks you to confirm it. Creation order follows the dependencies your references already described, and teardown reverses it.
refresh
Reads the world and updates state to match, without changing any infrastructure. Run it when you suspect something was changed outside Infrena. The next plan then proposes putting it right.
destroy
Tears down everything in an environment. prevent_destroy on a resource, or on an environment, refuses it.
Locking is per environment, so two applies against different environments never contend. Two applies against the same environment do, and the second waits.
infra.yml
The project file. Ten top-level keys, and only project is required.
project: myapp # required, the project's name infrena: ">= 0.14" # the oldest engine that understands this project plugins: # version constraints, one per plugin aws: ">= 0.7.0, < 0.8.0" providers: # a LIST: each entry names a plugin, plus its config - plugin: aws region: eu-west-1 backend: # where state lives; omit for local state plugin: s3 bucket: myapp-state variables: # typed declarations instance_size: type: integer default: 2 environments: dev: {} production: {} modules: # a LIST of sources - ./modules/app-stack resources: net: type: aws.vpc cidr_block: 10.0.0.0/16
The ten keys
| Key | What it does |
|---|---|
project | The project's name. The only required key. Readable as ${var.project}. |
infrena | A version constraint on the engine. Optional, but worth keeping: without it an engine that is too old reports unknown keys one at a time instead of saying it is too old. |
plugins | Version constraints per plugin. |
providers | A list of provider instances. See below. |
backend | Where state lives. Omit it and state is local, under .infra/. |
migrate_from | Used once, when moving state between backends. |
variables | Typed variable declarations. |
environments | The environments this project has. |
modules | Module sources from outside modules/. |
resources | The infrastructure itself. |
An unrecognised top-level key is a warning that lists the ten, not an error. A newer project read by an older binary should still be usable.
Why providers is a list
A map cannot hold two instances of one plugin and would drop one silently. Each entry takes an optional name:, defaulting to the plugin's own name, which is how one project talks to two AWS regions at once.
providers:
- plugin: aws
region: eu-west-1
- plugin: aws
name: aws_us
region: us-east-1
A providers: block is not required. Which plugins to load is worked out from the resource types your configuration declares. You need the block only to say something about a provider: a region, an account, or two instances of one plugin.
Resources
A resource is a name you choose, a type the provider owns, and whatever attributes that type takes.
resources:
db:
type: aws.rds # required: .
engine: postgres # everything else is the provider's schema
size: ${var.db_size}
vpc_id: ${net.id}
The name (db) is yours and becomes the resource's address. The type prefix names the plugin that serves it, so aws.rds needs the aws plugin. That is how Infrena knows what to load.
Attribute names, types and requirements come from the provider's schema. A typo, a missing required attribute, or a value of the wrong type is an error before anything is contacted:
Error: fake.network has no attribute "nme"
at infra.yml:14:5
${net.nme} reads an attribute that does not exist.
Attributes of fake.network:
cidr
id
Suggested action:
Correct the attribute name.
Finding out what a type takes
infrena explain prints a type's schema, read out of the plugin itself, so you do not have to go looking for documentation that might be describing a different version.
infrena explain aws.rds infrena explain fake.network
Two reserved names
var and module cannot be used as resource names. var would make ${var.x} ambiguous; module is how an address names a module level. Both are refused at the declaration, where the fix is, rather than at the reference.
Keeping a resource out of an environment
bastion:
type: aws.instance
only: [dev, staging] # exists nowhere else
replica:
type: aws.rds
skip: [dev] # exists everywhere but dev
An excluded resource is not planned in that environment and not destroyed there. It is simply not part of it. Referring to one from a resource that is present is an error naming both, rather than a null quietly substituted at apply time.
depends_on
References create ordering on their own, so this is only for a dependency the configuration cannot see, such as an IAM policy that must exist before something using the role works, with no attribute connecting them.
app:
type: aws.instance
depends_on: [policy_attachment]
If you reach for it often, check whether a reference would say the same thing. A reference is checked; depends_on is taken on trust.
References and expressions
${ } marks an expression. Six forms, each with exactly one meaning.
${var.region} a variable
${var.tags.team} a path into a map variable
${var.azs[0]} an entry of a list variable
${vpc.id} an attribute of resource `vpc`
${vpc.tags.Name} a path into a resource attribute
${vpc} the resource `vpc` itself
The first segment is the resource, the second is the attribute, and anything after that is a path into it. No rule depends on counting segments, and a resource name can never contain a dot, so this stays unambiguous however deep the path goes.
Passing a whole resource
This is the one that saves the most time. ${vpc} with no attribute resolves to whichever attribute the consuming field declared it refers to.
subnet:
type: aws.subnet
vpc: ${vpc} # the provider says this field holds a VPC's id
cidr: 10.0.1.0/24
If a provider says vpc_id holds a VPC's id, then vpc_id: ${vpc} means ${vpc.id} and you did not have to look it up. You stop having to remember whether a given API wants an id, a name or an ARN. If the consuming attribute declares no such reference, the error says so rather than pretending the resource does not exist.
A reference is a dependency
vpc_id: ${net.id} means the network is created first and the real id is substituted. On teardown the order reverses. Nothing needs declaring for this to happen.
Pointing a field at the wrong kind of resource is a compile error too. If vpc_id holds a VPC's id, then vpc_id: ${database.id} fails at validate, not after four other resources already exist.
Composing
name: ${var.project}-${var.environment}-db
url: postgres://app:${secret.DB_PASSWORD}@${db.endpoint}/app
The other namespaces
| Namespace | Means |
|---|---|
${secret.NAME} | A credential, from the environment or a vault. See Secrets. |
${template.NAME} | A file, interpolated. See Templates. |
${file.NAME} | A file, verbatim. |
${each.key}, ${each.value} | The current for_each entry. |
Functions
Seven, and no more.
| Function | Does |
|---|---|
lower(s) / upper(s) | Case |
trim(s) | Strip surrounding whitespace |
replace(s, old, new) | Substitute |
join(sep, list) | Join with a separator |
default(value, fallback) | fallback when value is unset |
merge(a, b, …) | Combine maps, later keys winning |
Every one is pure and total. now(), uuid() and reading a file are the three most often asked for next, and each would break plan determinism: the same configuration and state would plan differently on a second run, which is the property the whole plan/apply split rests on.
Making many of something
for_each takes a list or a map and makes one resource per entry. There is no count, and that is deliberate.
resources:
subnet:
type: aws.subnet
for_each: ${var.availability_zones} # [eu-west-1a, eu-west-1b, eu-west-1c]
vpc_id: ${net.id}
availability_zone: ${each.value}
Each instance gets an address keyed by its entry:
subnet["eu-west-1a"] subnet["eu-west-1b"] subnet["eu-west-1c"]
With a list, each.key and each.value are both the entry. With a map they differ:
db:
type: aws.rds
for_each: {orders: postgres, billing: mysql}
engine: ${each.value}
tags:
service: ${each.key}
Why identity is the key, never the position
This is the reason for_each exists here and count does not. Remove eu-west-1b from the middle of that list and exactly one subnet is destroyed. Nothing else in the plan moves.
With ordinal addressing, removing the middle of three shifts every later one: the third becomes the second, and the plan proposes destroying and recreating resources that did not change. For anything holding data, that is not a renumbering, it is data loss. Keying by entry makes it structurally impossible rather than merely discouraged.
If you write count: and the provider has no such attribute, the error suggests for_each.
Referring to one instance
subnet_id: ${subnet["eu-west-1a"]}
Referring to the whole set is an error, because one resource cannot consume three ids. The message names the instances rather than calling the resource undeclared, which would send you hunting for a typo in a name that is right there in the file.
The rules
- Keys must be known at plan time, values need not be. A key that depends on something not yet created would mean not knowing how many resources a plan contains.
- A duplicate key is refused, not silently collapsed. Two declarations quietly becoming one instance is how a resource goes missing.
- An empty list or map makes nothing, with no error. This is the optional-resource case, and it is why there is no
count: enabled ? 1 : 0idiom to learn. - Map keys are ordered before expansion, so plans are deterministic.
Lifecycle rules
Five options, per resource, that constrain what a plan is allowed to propose. Refusals happen at plan time, so they arrive before any approval rather than part-way through an apply.
resources:
db:
type: aws.rds
lifecycle:
prevent_destroy: true
prevent_replace: true
create_before_destroy: true
retain: true
ignore_changes: [tags.LastModified]
prevent_destroy and prevent_replace
prevent_destroy refuses a plan that would destroy the resource. The case where somebody deleted the block, or pointed at the wrong environment.
prevent_replace refuses a plan that would replace it: destroy and recreate, because an attribute the provider marks as forcing a new resource changed underneath it.
prevent_replace guards the more dangerous case. The configuration still names the resource, the diff reads as an edit, and the data is gone all the same. Neither option implies the other, because they guard different mistakes. The diagnostic names the attributes that forced the replacement, since without them you are left working out why an ordinary-looking edit became a destroy.
retain and ignore_changes
retain removes the resource from state without deleting it, when the configuration stops managing it. For the thing you want to keep after you stop managing it.
ignore_changes lists attributes whose drift should not produce a diff: the tag another system writes, the field a console edit is allowed to own.
create_before_destroy
Reverses the two halves of a replacement. The new object is built, everything pointing at it is moved across, and only then is the old one destroyed. Use it for anything that must not be absent in between: a load balancer, an instance serving traffic.
A great many resources cannot exist twice: a unique name, a fixed port, a key that is the identity. For those, reversing the order turns a clean replacement into a create that collides. You are the one who knows which kind you have.
Two things worth knowing. Dependents follow automatically: a resource referring to the replaced one is updated in place to point at the new object, and that update finishes before the old one is destroyed. And if the old object cannot be deleted, the run reports it and state keeps a record, so every later plan proposes the cleanup until it succeeds:
Plan: 0 to create, 0 to update, 0 to replace, 0 to destroy, 0 to forget. 1 left over from an interrupted replacement to clean up.
That is deliberate. A real object nothing can name is a leak that bills monthly; one state still names is a line in the next plan.
One file, or many
Start with one infra.yml. Split it up when it earns it. Both forms produce byte-identical plans, and that is pinned by a test.
Nothing has to be listed. Conventional directory names are found on their own.
myapp/ ├── infra.yml ├── resources/ # your infrastructure, organised however suits you │ └── networking/ │ ├── vpc.yml │ ├── vars/ # variables scoped to this directory │ └── templates/ # templates scoped to this directory ├── vars/ # values, one file per environment ├── environments/ # one file per environment, its overrides ├── modules/ # reusable components ├── templates/ # project-wide templates ├── secrets/ # per-environment vaults └── discovered/ # written by import, reviewed by you
resources/ and vars/ are globbed recursively, so organise them however suits you. secrets/ is read at the project root only.
A directory scopes values, never names
This is the rule that makes reorganising safe. A directory scopes variables and templates. It never scopes names, so a resource's address is just its name, and moving a file between directories renames nothing and destroys nothing.
Declaring the same name twice is an error that points at both files, rather than one definition silently winning. Globbing makes accidental duplication easy in a way a single file does not, and one definition quietly winning is how you deploy something you did not write.
discovered/
Loaded like any other configuration rather than staged. A resource in state that no configuration declares is scheduled for destruction, so a staging area would mean import followed by apply destroys what was just adopted.
The single-file and directory forms produce byte-identical plans. Growing a project is a layout change and never a behaviour change.
Variables
Declared with types in infra.yml, given values in vars/, read as ${var.NAME}.
variables:
db_size:
type: integer
default: 10
min: 5
max: 100
db_password:
type: string
tags:
type: map
default: {}
Six types: string, integer, float, boolean, list, map. min and max apply to the numeric ones.
A variable with no default must be supplied somewhere. A run that does not supply it fails at plan time naming the variable, rather than passing an empty value to a provider.
The var. prefix is not optional
There is no bare form. That is what makes the rule total: no bare name anywhere resolves to a variable, so nothing depends on whether a resource happens to share a name.
Two you did not declare
${var.environment} and ${var.project} are always available: the environment this run names, and the project's own name. They carry the prefix like everything else, because a prefix that applies to some variables and not others is an exception nobody remembers.
name: ${var.project}-${var.environment}-db
Giving them values
vars/ ├── default.yml # applies everywhere ├── staging.yml # overrides default, value by value └── production.yml
db_size: 10 db_password: dev-secret
db_size: 100
An environment-named file overrides default.yml value by value, not file by file. Production above gets db_size: 100 and still gets db_password from default.yml. You never restate what has not changed, which is what keeps the files honest: everything written in production.yml is something that genuinely differs.
A variables file is a plain mapping of name to value. Typed declarations belong in infra.yml; the two are different documents and are decoded differently.
Directory-scoped variables
resources/
└── app/
├── app.yml
└── vars/
└── sizes.yml # `size` means something only inside resources/app/
Nothing outside that directory can see those names. This is how a large project keeps a generic name like size from becoming app_instance_size_for_the_web_tier.
Precedence
Lowest to highest. Explicit configuration always beats an implicit default, and the command line always wins.
| # | Source |
|---|---|
| 1 | Provider defaults |
| 2 | Base configuration |
| 3 | Module defaults |
| 4 | Environment inheritance |
| 5 | Environment variables (vars/<environment>.yml) |
| 6 | --var-file on the command line |
| 7 | --var on the command line |
infrena plan production --var db_size=200 infrena plan production --var-file /tmp/incident-overrides.yml
--var and --var-file are for an incident or a one-off test. Neither is a substitute for a committed file, because nothing records that you passed them.
Values carry their origin all the way through, so a plan can tell you where a value came from. That line is the answer to “why is this 10 when I set it to 50 somewhere”: it says which layer won.
size: 10 [default, from provider default]
Environments
Dev, staging and production hold the same infrastructure and differ in what their values say. Infrena makes that the structure rather than a convention.
environments:
dev: {}
staging: {}
production: {}
Every command that touches infrastructure names one. Each environment has its own state and its own lock, so two applies against different environments do not contend.
infrena plan production infrena apply production
Deleting an environment from infra.yml does not silently orphan what it held. Infrena proposes the teardown and shows exactly what it would destroy before you agree, so plan that environment before you delete the line.
Inheritance
environments:
production: {}
production_eu:
extends: production
The extending environment starts from the other's values and overrides what it names. It is for the case where two environments are genuinely the same thing in two places, not for saving a few lines.
Protected environments
environments:
production:
require_approval: true
prevent_destroy: true
With require_approval, --auto-approve is refused with exit code 77. A protection a flag can switch off is not one. Two approvals are accepted, and both involve a person: somebody confirming at a terminal, or a saved plan. See CI and automation for the two-stage shape that produces.
Referring across an exclusion
Error: ${net.id} reads "net", which is skipped in environment "dev"
at infra.yml:9:5
"net" is excluded from this environment by the `skip`/`only` at infra.yml:9:5,
so the value this needs will never exist here.
Suggested action:
Skip this resource in the same environments, or widen the filter on "net".
Secrets
A password has to reach the provider and must not reach the terminal, a report, or a pull request diff. One reference namespace, two places it can be answered from.
resources:
db:
type: aws.rds
engine: postgres
password: ${secret.DATABASE_PASSWORD}
database_url: postgres://app:${secret.DATABASE_PASSWORD}@db.internal/app
Where the value comes from
Highest first:
- The process environment, e.g.
DATABASE_PASSWORD. secrets/<environment>.yml, that environment's vault.secrets.yml, the vault every environment shares.
The environment wins, which is the same direction the rest of the ladder runs. It means CI can inject a rotated credential without anybody editing and re-encrypting a file, and a pipeline can run with no vault passphrase at all.
A leftover variable in somebody's shell silently shadows the committed one, and nothing warns you. It is the same trade --var already makes.
Unset is an error, and so is empty
The run stops at plan time rather than at the provider. Empty is refused alongside missing because empty is the shape a CI failure actually takes: a repository secret that was never created expands to an empty string rather than disappearing, and an empty password does not fail until after somebody has approved the run.
What redaction covers
A secret is sensitive because of where it came from, not because a provider marked the attribute. Put a credential somewhere nobody expected and it is still redacted. That holds for the plan on your terminal, for --output reports, and for anything derived from a secret: a connection string containing one is sensitive in full, and a template given one renders entirely as <sensitive>.
State. .infra/state/<environment>.json records what was applied, which includes the password that was set. Written mode 0600. If state lives in a bucket, that bucket holds credentials.
A saved plan. plan --output writes the values apply --plan needs. Also 0600. Treat it like a credential, not an artifact to attach to a ticket.
Neither is an oversight. An applier needs the real value; redaction is about what is displayed.
The vault
Reading secrets from the environment is the whole answer for CI. On a laptop it only moves the problem, because “where do I keep this file” usually resolves to a password in a repo in clear. infrena vault is an encrypted file you can commit beside the configuration that references it.
infrena vault create secrets.yml # new vault, opens in $EDITOR infrena vault edit secrets.yml # decrypt into $EDITOR, re-encrypt on save infrena vault view secrets.yml # print, write nothing infrena vault encrypt secrets.yml # seal a plain file in place infrena vault decrypt secrets.yml # unseal it in place infrena vault rekey secrets.yml # change the passphrase
The contents are plain YAML, one secret per key, and those names are the same names ${secret.NAME} asks for. A vault is not a separate namespace; it is a second place the same question gets answered.
The passphrase comes from --vault-password-file, then INFRENA_VAULT_PASSWORD, then a prompt with no echo. A project with no vault never asks for one, and a project with one opens it lazily, so a command that references no secret does not need it either.
Encryption is AES-256-GCM with the key derived by PBKDF2-SHA256 at 600,000 iterations. The header line is authenticated along with the contents, so editing the iteration count down in the file does not produce a file that opens faster, it produces one that does not open.
It is not a secret manager. It rotates nothing, controls nobody's access, and records no reads. It is what you use when you do not have such a store.
Ciphertext in git is permanent. If the repository is ever published, every historical version goes with it, and a passphrase compromised later opens all of them. If that happens, rotate the secrets themselves, not just the passphrase.
Modules
A group of resources that always go together, with a declared set of inputs and outputs. A database plus its subnet group plus its parameter group. You write it once and use it in three environments, or five times in one.
Writing one
A module is a directory containing module.yml.
inputs:
network:
type: string
size:
type: integer
default: 10
password:
type: string
resources:
db:
type: fake.database
engine: postgres
network: ${var.network}
size: ${var.size}
password: ${var.password}
outputs:
endpoint:
value: ${db.endpoint}
Inside the module, an input is read as ${var.NAME}, exactly like a variable. An input with no default is required, and omitting it is an error at the call site.
The two are different documents: a module has inputs and outputs and no project. Separate names make them distinguishable by construction rather than by four rejection rules. It also stops a module directory from looking like a project, where running infrena plan dev inside it would find a valid file and try.
Using one
Modules in modules/ are found automatically. One from elsewhere is listed under modules:. Then it is used like any other resource type, with module. as the prefix.
resources:
stack:
type: module.app_stack
network: ${network.id}
size: ${var.size}
password: ${var.db_password}
web:
type: fake.application
image: nginx:1.27
database_url: ${stack.endpoint}
A directory name becomes an identifier by turning - into _, so modules/app-stack/ is module.app_stack. Outputs are read off the call the way attributes are read off a resource, and the dependency that creates is real: web waits for the module's database.
Addresses
A resource inside a module is addressed through its call: module.stack.db. Output that lists resources prefixes the type, so plan and state list show fake.database.module.stack.db.
infrena state show dev module.stack.db
for_each on a module
for_each works on a module call, and on a resource inside a module. On a call, the whole module is instantiated once per entry and every resource inside lands under the keyed call.
module.store["orders"].db module.store["orders"].subnet_group module.store["billing"].db module.store["billing"].subnet_group
The call's inputs are evaluated once per entry, which is what makes ${each.key} useful here. The dependency lands on that instance, not on everything the call produced, so instances that have nothing to do with each other are not serialised behind one another.
Remote modules
modules: - https://github.com/acme/infra-app-stack:v1.2.0 # a tag - git@github.com:acme/infra-database:9f3c1ab # a commit - ssh://git@git.example.com:2222/acme/repo:v1.0.0 # ssh, with a port
A ref is required: an unpinned repository means the module you reviewed and the module you applied are not necessarily the same. Resolved sources are recorded in modules.lock, which belongs in version control.
It would be recorded in modules.lock and printed by every diagnostic that names the module. Use your ssh agent or a credential helper.
What a module is not
A module is a grouping, not an abstraction layer. It has no way to reach outside itself, no access to the caller's other resources, and no opinion about environments. Everything it knows, it was passed.
That is deliberate. The failure mode of module systems is a module that quietly depends on something at the call site, so it works in the project it was written in and nowhere else. Inputs and outputs being the entire interface is what makes a module movable.
Templates and files
Some values are documents rather than strings. An IAM policy, a user-data script, a container definition. Templates let those live in their own files, with syntax highlighting and a linter.
resources:
app:
type: aws.iam_role
assume_role_policy: ${template.trust.json}
user_data: ${file.bootstrap.sh}
Two namespaces, one difference. ${template.NAME} is interpolated: ${...} inside it is evaluated exactly as it would be in infra.yml. ${file.NAME} is read verbatim.
Shell scripts contain ${...} of their own. Interpolating a bootstrap script would silently eat ${HOME} and substitute nothing, producing a script that runs and does the wrong thing. If a file is not meant to be a template, say so by how you reference it.
Where they live
Nearest wins. A directory's own templates/ beats the project's for a name both define, the same direction every other scoped thing in the project runs. The name after the namespace is taken whole, because filenames contain dots: ${template.policy.v2.json} names one file called policy.v2.json.
References inside a template
A template is interpolated in the same grammar as configuration, so resource references work and create real dependency edges.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "${bucket.arn}/*"
}]
}
The role using this template now depends on the bucket. It is not a string that happens to mention one: the planner knows the ordering and waits for the real ARN.
Arguments, and the two passes
A template can take one argument, a map, whose contents are rendered by Go's text/template before the ${ } pass.
policy: ${template.access.json(var.policy_args)}
{{ }} sees what you passed it. ${ } sees the project.
A template engine handed the project's whole scope could write a secret into the rendered text as a literal, and the second pass would then see an ordinary string with no sensitivity, which reaches the plan, the state and the report in clear. Pass one can reach nothing it was not handed.
Three consequences. A sensitive argument taints the whole rendered document, because the template decides where the value lands. An unknown argument defers the whole render until apply, because a template has no way to represent “not yet”. And ${file.NAME} takes no argument, because it reads verbatim.
Template functions
| Function | Does |
|---|---|
until n | [0 1 … n-1], for range |
seq a b | [a … b] inclusive |
indent n s | Indent every line of s by n spaces |
quote s | Wrap in double quotes, escaping what needs it |
upper s / lower s | Case |
trim s | Strip surrounding whitespace |
join sep list | Join with a separator |
sortAlpha list | Sorted copy |
That is the entire set, pinned by a test so it cannot grow by accident. These are the {{ }} pass's functions and are not the same list as the seven built-ins the ${ } grammar has. Four names appear in both and mean the same thing; the two sets are separate because the two passes are.
When not to use one
Templates are for documents. They are deliberately not a way to generate configuration: there is no templating pass over infra.yml itself, and there is not going to be one. Generated configuration means every diagnostic points at a line nobody wrote.
For “I need N of these”, use for_each. For “these three resources always go together”, use a module.
How plugins work
Providers and state backends are separate binaries that Infrena starts and talks to over stdio. The engine ships with none of them, which is why it stays small.
Installing
infrena plugins install aws # one by name infrena plugins install # everything this project declares infrena plugins install aws --global infrena plugins list # what is installed, and where it came from infrena plugins search aws # find one across every source you trust infrena plugins verify # re-check installed binaries against the lock
With no name, install installs everything the project declares, the backend included, which is what a fresh clone wants. Install writes to <project>/.infra/plugins/, or to ~/.local/share/infrena/plugins/ with --global.
Where Infrena looks
In order:
--plugin-dirINFRENA_PLUGIN_PATH<project>/.infra/plugins~/.local/share/infrena/plugins$PATH
Placing a binary by hand still works, and still wins. Install populates the directories Infrena already searched rather than adding a second mechanism beside them, so a hand-placed plugin is found exactly as an installed one is, and one named by --plugin-dir is found first. That is how you run a build you made yourself, and it is not a lesser option.
plugins.lock
Records the resolved version, the source, and a SHA-256 per platform. Commit it. The host hashes a locked binary before launching it, because after the process has started is after its code has run, so a binary replaced on disk after installation is caught at the next command. A lock that cannot be read refuses every launch rather than being treated as absent.
A plugin the lock does not mention still loads, exactly as a hand-placed one does. The lock governs what install put there; it does not claim authority over everything.
A project may name where a plugin comes from. Only you may trust a source, and only from a terminal. Project configuration travels with a git clone, so it must not be able to introduce a place Infrena downloads executables from.
Two version numbers that are not the same question
| Question | Answered by |
|---|---|
| The oldest Infrena that can run this plugin | plugin.yaml's infrena: floor |
| The Infrena the plugin was built and tested against | its go.mod require |
The floor moves only when the wire protocol does. Raising it to match the require would refuse hosts the binary works perfectly well with.
Values reach provider plugins in cleartext. That is what dispatching a create to a plugin is. State reaches a backend in cleartext too, so a backend plugin can read every secret in your infrastructure. The mitigation is a choice rather than a mechanism: you decide which plugins to trust, and plugins.lock records that decision in a file that is committed and shows up in a diff.
aws
The AWS provider. One generic provider serving every resource type AWS Cloud Control API supports, 1,584 of them, generated from AWS's published CloudFormation schemas.
| Kind | Provider |
| Binary | infrena-plugin-aws |
| Install | infrena plugins install aws |
| Latest | 0.7.0, needs Infrena 0.13.0 or newer |
| Types | aws.* |
providers:
- plugin: aws
defaults:
region: ${var.aws_region}
resources:
vpc:
type: aws.vpc
cidr: 10.0.0.0/16
enable_dns_hostnames: true
tags:
Name: ec2-example-vpc
public_subnet:
type: aws.subnet
vpc: ${vpc}
cidr: 10.0.1.0/24
az: us-east-1a
map_public_ip_on_launch: true
Type names
aws.<resource> when the CloudFormation resource segment is unique across every supported type, so AWS::EC2::VPC is aws.vpc. Otherwise aws.<service>.<resource>, so AWS::EC2::Instance is aws.ec2.instance.
A name never changes once released. A later AWS type that would clash with an already-assigned short name gets the qualified form instead, and the short name keeps its original owner.
Attribute names
Every attribute accepts three spellings: AWS's own property name in any case (CidrBlock), its generated snake_case form (cidr_block), and a friendly alias where one is curated (cidr). Plans, infrena explain and import --generate show the friendly alias when one exists, otherwise the snake_case form.
With 1,584 types there is no page to read. infrena explain aws.ec2.instance prints every attribute, its spellings and the type's import ID shape, read out of the schema itself.
Credentials
Standard AWS resolution: environment variables, a named profile, an instance role. Credentials never go in infra.yml, which is committed.
fake
A provider whose “cloud” is a hand-editable JSON file on disk. The whole engine — planning, applying, drift detection, import and failure handling — can be exercised with no network and no credentials.
| Kind | Provider |
| Binary | infrena-plugin-fake |
| Install | infrena plugins install fake |
| Latest | 0.5.0, needs Infrena 0.13.0 or newer |
| Types | fake.network, fake.database, fake.application |
| Its cloud | .infra/fake-cloud.json |
providers:
- plugin: fake
resources:
net:
type: fake.network
cidr: 10.0.0.0/16
db:
type: fake.database
engine: postgres
network: ${net}
size: 10
Use it to learn the tool, to test a module, and in CI where a real account would be slow, expensive or dangerous. Because the cloud file is editable, it is also the easiest way to see drift detection work: change a value in it, run refresh, and the next plan proposes putting it right.
It is a real provider speaking the real plugin protocol. Everything the engine does with AWS, it does with this, which is why it can stand in for a cloud in the test suite.
s3
A state backend that keeps a project's state in an S3-compatible object store, and locks it with a conditional write. Infrena starts this binary; you do not run it yourself.
| Kind | State backend |
| Binary | infrena-backend-s3 |
| Install | infrena plugins install s3 |
| Latest | 0.2.0 |
| Needs | Infrena 0.13.0 or newer |
backend: plugin: s3 bucket: my-infrena-state
That is the whole minimum. Everything else has a default.
Which stores work
| Store | Status |
|---|---|
| AWS S3 | Passes the full live suite |
| MinIO | Passes the full live suite |
| Backblaze B2 | Fails the lock check, and is refused |
| Cloudflare R2 | Expected to work, not tested here |
| DigitalOcean Spaces | Expected to work, not tested here |
The backend proves your store honours conditional writes before it serves anything: it writes a throwaway object twice and requires the second write to be refused. A store that ignores the header reports success for both, which looks exactly like a lock that never locks. So the bucket is refused when the project loads, rather than during an apply already under way.
State reaches the backend in cleartext, so the bucket holds every secret in your infrastructure. Encryption at rest is the store's job and one setting. Use a bucket policy that refuses unencrypted writes, restrict who can read the bucket, and turn on object versioning — a store with versioning gives you state history for free.
Discovery and import
Most infrastructure exists before the tool that manages it does. Discovery reads what is already there and tells you what each resource would be called. Nothing is touched until you say so.
Looking
infrena discover infrena discover --tag Name=app1 --exclude-type aws.logs.loggroup infrena discover --all
TYPE ID NAME aws.subnet subnet-0a1b2c3d4e5f subnet-app1a aws.vpc vpc-1023902339 vpc-app1 2 resources found. Nothing has been imported.
Discovery is read-only. It never writes, never imports, and never changes state. Names come from a resource's Name tag where it has one, so you get vpc-app1 rather than vpc-1023902339, and a name collision is visible before it happens rather than after.
Resources this project already manages are excluded by default, as are the ones the provider says the cloud created for itself. An account's default VPC is not something to adopt by accident. --all shows everything.
Importing
infrena import dev --generate
Import adopts the resources into state, and --generate writes the configuration that declares them, under discovered/.
subnet-app1a:
type: aws.subnet
VpcId: ${vpc-app1} # not vpc-1023902339
CidrBlock: 10.0.1.0/24
What comes out is minimal: anything equal to a provider default is left out, so you get a file worth reviewing rather than a dump. Resources reference each other by name instead of pasting ids. A sensitive attribute is never written — it is omitted, with a comment on that line saying so.
Discover, import, generate, plan. That sequence producing no unexpected changes is pinned by a test, which is the difference between adoption you can trust and a first plan you have to argue with.
State and backends
State records what Infrena manages. By default it is a file per environment under .infra/, locked while a run holds it, and a project that never says otherwise never has to think about this.
Sharing it
backend: plugin: s3 # the only key infrena reads bucket: acme-state # everything below here is the backend's own region: eu-west-1 path: /infrena/
plugin: names the implementation. Every other key belongs to the backend and crosses to it untouched, which makes this the one block in the language where an unrecognised key is not an error: Infrena cannot know which keys an s3 backend accepts, and refusing what it does not recognise would make every backend option a change to the core. A missing plugin: is an error, because that is the one key Infrena owns.
Credentials do not go here
They are the plugin's business: environment variables, an instance profile, a credentials file, whatever it chooses. A bucket name is configuration; a secret key is not. infrena validate enforces that, which matters because infra.yml is committed and validate is the cheap gate CI runs.
Error: `backend` is not a block the s3 backend can read at infra.yml:2:1 backend s3: `backend.access_key_id` is a secret and this backend will not read one: infra.yml is committed to git. Set `profile:` to name a profile in your AWS credentials file instead, or leave credentials to the environment or an instance role
backend: may not use variables
A ${...} anywhere in the block is a diagnostic naming the key. The reason is an ordering cycle rather than a missing feature:
State is read before anything is compiled, and compiling is what resolves variables. destroy, refresh, discover and import never compile at all, and every other command has to open the backend to find out what already exists. Reading state would have to wait for a compile that is waiting for state.
providers: may interpolate because provider instances are constructed after variables resolve. backend: is read before all of it. Give the key a literal value, keep environments that need different state in separate projects, or let the backend read the value from its surroundings the way a provider reads credentials.
Moving between backends
backend: # where state should live plugin: s3 bucket: new-bucket migrate_from: # where it lives today plugin: local
infrena state migrate infrena state migrate --check # answers the same question for CI
state migrate copies, and never empties the source, so a migration that goes wrong leaves the old backend still holding the record. Until it runs, ordinary commands refuse rather than treating an empty destination as an empty world.
Working with state
infrena state list dev infrena state show dev module.stack.db infrena state rm dev old-bucket # stop managing, do not delete infrena state unlock dev # after an interrupted run
CI and automation
Everything here works in any CI system. Nothing is specific to GitHub, and Infrena integrates with no forge: it reads exit codes, environment variables and files, which is all any pipeline has.
infrena validate production # config errors, contacts nothing infrena plan production --output plan.json # exit 2 means there are changes infrena apply production --plan plan.json --auto-approve
The middle step is the one that matters. A saved plan is applied without recompiling and is refused outright if state moved since it was made, so what runs is what was reviewed.
Exit codes
| Code | Meaning |
|---|---|
0 | Success, nothing to do |
1 | Error — configuration, a provider, a backend |
2 | Success, and there are changes (plan) or changes were applied (apply) |
77 | Changes need an approval this run cannot obtain |
2 is not an error. A pipeline that treats any non-zero exit as failure will treat a plan with changes as a broken build.
set +e infrena plan production --output plan.json status=$? set -e case $status in 0) echo "nothing to do" ;; 2) echo "changes proposed" ;; *) exit "$status" ;; esac
The set +e matters under set -e, which most CI systems enable: without it the shell exits on the 2 before anything reads it.
The two-stage workflow
With require_approval, --auto-approve is refused with exit 77, which makes the pipeline a two-stage one. That is the point.
# On the pull request: produce the plan and publish it for review. infrena plan production --output plan.json # A human reads it. They need nothing installed: infrena plan --show plan.json # On merge: apply exactly what was reviewed. infrena apply production --plan plan.json --auto-approve \ --approved-by "$PR_URL"
--approved-by takes any text and records it in the run's report. It is a record, not a permission: Infrena cannot tell a real URL from an invented one, so nothing is allowed on the strength of it. It exists so a run can say under whose authority it happened.
Machine-readable output
--output FILE writes newline-delimited JSON and silences stdout completely, so a frontend tails one file rather than scraping a terminal. Every command writes the same shape: a meta line, then event, observation and diagnostic lines as work happens, then a final result line.
{"type":"meta","version":4,"infrena":"0.14.0","command":"apply","environment":"production"}
{"type":"event","event":"started","address":"db","op":"update"}
{"type":"result","approved_by":"https://github.com/acme/infra/pull/42","state_serial":18}
Sensitive values are redacted before they reach it. Diagnostics go to stderr regardless, so a failing run still says why on a channel somebody sees.
Things worth knowing
- Plans go stale on purpose.
apply --planreads state inside the lock and refuses the plan if that is not the state it was made against. If two pipelines can apply to one environment, the second is refused rather than applying against a world that moved. validatecontacts no providers, so it is the cheap gate to run first. It does check that the plugins and the state backend a project names are installed.- Locking is per environment, so pipelines for different environments do not block each other.
- Exit 141 cannot happen. Infrena ignores SIGPIPE, so
infrena plan | head -1still reports the exit code above.
Command reference
Every command. Those taking an environment require one, always.
The loop
| Command | Does |
|---|---|
infrena init [dir] | Scaffold a project. Defaults to ./infrena/; init . scaffolds in place. --provider aws includes a provider. |
infrena validate [env] | Parse and type-check. Contacts no providers. |
infrena plan <env> | Show what would change. --output saves it; --show prints a saved one. |
infrena apply <env> | Make it so. --plan applies a saved plan, --auto-approve skips the prompt. |
infrena destroy <env> | Tear down everything in the environment. |
infrena refresh <env> | Read the world into state, changing no infrastructure. |
Inspecting
| Command | Does |
|---|---|
infrena explain <type> | Print a resource type's schema, read from the plugin. |
infrena graph <env> | The dependency tree, module structure included. |
infrena export <env> | Export the environment. |
infrena version | Version of the engine. |
Adopting
| Command | Does |
|---|---|
infrena discover [type…] | Read-only listing of what exists. --tag, --exclude-type, --all. |
infrena import <env> [type…] | Adopt into state. --generate also writes configuration under discovered/. |
State
| Command | Does |
|---|---|
infrena state list <env> | Everything under management. |
infrena state show <env> <address> | One resource's recorded state. |
infrena state rm <env> <address> | Stop managing it, without deleting it. |
infrena state unlock <env> | Release a lock after an interrupted run. |
infrena state migrate | Copy state between backends. --check for CI. |
Plugins and vault
| Command | Does |
|---|---|
infrena plugins list | What is installed, and where it was loaded from. |
infrena plugins install [name] | Install one, or everything the project declares. --global installs to the user directory. |
infrena plugins search <name> | Find one across every source you trust. |
infrena plugins verify | Re-check installed binaries against plugins.lock. |
infrena vault create|edit|view | Create, edit or print a vault. |
infrena vault encrypt|decrypt | Seal or unseal a file in place. |
infrena vault rekey | Change the passphrase. |
Flags worth knowing
| Flag | Does |
|---|---|
--var name=value | Override one variable for this run. Beats everything. |
--var-file <path> | Override several. Repeatable; later files win. |
--output <file> | Write NDJSON and silence stdout. |
--auto-approve | Skip the confirmation. Refused with 77 on a protected environment. |
--approved-by <text> | Record who authorised a run. A record, not a permission. |
--plugin-dir <path> | Look here for plugins first. |
--vault-password-file <path> | Read the vault passphrase from a file. |
--chdir <path> | Run as though from another directory. |
The repository has the full detail, including provider-authoring hazards and the open-core policy: github.com/Infrena/infrena/docs. There is a worked project in examples/shop.