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, staging and production in 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.

NEW HERE?

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.

Linux, amd64
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
CHECK THE CHECKSUM

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

OSArchitectures
macOSamd64, arm64
Linux386, amd64, arm64, armv7
Windowsamd64, 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

terminal
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.

infra.yml
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

terminal
infrena validate     # config errors only, contacts nothing
infrena plan dev
output
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

terminal
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.

terminal
# 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

terminal
infrena destroy dev
WHAT NEXT

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.

the loop
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

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.

infra.yml
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

KeyWhat it does
projectThe project's name. The only required key. Readable as ${var.project}.
infrenaA 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.
pluginsVersion constraints per plugin.
providersA list of provider instances. See below.
backendWhere state lives. Omit it and state is local, under .infra/.
migrate_fromUsed once, when moving state between backends.
variablesTyped variable declarations.
environmentsThe environments this project has.
modulesModule sources from outside modules/.
resourcesThe 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.

two regions
providers:
  - plugin: aws
    region: eu-west-1
  - plugin: aws
    name: aws_us
    region: us-east-1
GOOD TO KNOW

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.

infra.yml
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:

infrena validate
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.

terminal
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

infra.yml
  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.

infra.yml
  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.

the six forms
${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.

infra.yml
  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

infra.yml
    name: ${var.project}-${var.environment}-db
    url: postgres://app:${secret.DB_PASSWORD}@${db.endpoint}/app

The other namespaces

NamespaceMeans
${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.

FunctionDoes
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
WHY SO FEW

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.

infra.yml
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:

addresses
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:

infra.yml
  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.

HELPFUL

If you write count: and the provider has no such attribute, the error suggests for_each.

Referring to one instance

infra.yml
    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 : 0 idiom 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.

infra.yml
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.

THE INSIDIOUS ONE

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.

WHY IT IS NOT THE DEFAULT

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:

output
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.

project layout
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 GUARANTEE

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}.

infra.yml
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.

infra.yml
    name: ${var.project}-${var.environment}-db

Giving them values

vars/
vars/
├── default.yml       # applies everywhere
├── staging.yml       # overrides default, value by value
└── production.yml
vars/default.yml
db_size: 10
db_password: dev-secret
vars/production.yml
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

scoped
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
1Provider defaults
2Base configuration
3Module defaults
4Environment inheritance
5Environment variables (vars/<environment>.yml)
6--var-file on the command line
7--var on the command line
terminal
infrena plan production --var db_size=200
infrena plan production --var-file /tmp/incident-overrides.yml
FOR THE EXCEPTIONAL RUN ONLY

--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.

in a plan
      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.

infra.yml
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.

terminal
infrena plan production
infrena apply production
REMOVING ONE

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

infra.yml
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

infra.yml
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

infrena plan dev
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.

infra.yml
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:

  1. The process environment, e.g. DATABASE_PASSWORD.
  2. secrets/<environment>.yml, that environment's vault.
  3. 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.

THE COST OF THAT

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>.

TWO PLACES HOLD THE REAL VALUE

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.

terminal
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.

WHAT THE VAULT IS NOT

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.

modules/app-stack/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.

WHY module.yml AND NOT infra.yml

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.

infra.yml
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.

terminal
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.

addresses
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

infra.yml
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.

NEVER PUT A TOKEN IN A SOURCE

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.

infra.yml
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.

WHY BOTH EXIST

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.

templates/policy.json
{
  "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.

infra.yml
    policy: ${template.access.json(var.policy_args)}
THE ORDER IS THE WHOLE DESIGN

{{ }} 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

FunctionDoes
until n[0 1 … n-1], for range
seq a b[a … b] inclusive
indent n sIndent every line of s by n spaces
quote sWrap in double quotes, escaping what needs it
upper s / lower sCase
trim sStrip surrounding whitespace
join sep listJoin with a separator
sortAlpha listSorted 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

terminal
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:

  1. --plugin-dir
  2. INFRENA_PLUGIN_PATH
  3. <project>/.infra/plugins
  4. ~/.local/share/infrena/plugins
  5. $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.

TRUST IS YOURS, NOT THE PROJECT'S

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

QuestionAnswered by
The oldest Infrena that can run this pluginplugin.yaml's infrena: floor
The Infrena the plugin was built and tested againstits 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.

PLUGINS SEE EVERYTHING

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.

KindProvider
Binaryinfrena-plugin-aws
Installinfrena plugins install aws
Latest0.7.0, needs Infrena 0.13.0 or newer
Typesaws.*
infra.yml
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.

THE REFERENCE IS THE TOOL

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.

KindProvider
Binaryinfrena-plugin-fake
Installinfrena plugins install fake
Latest0.5.0, needs Infrena 0.13.0 or newer
Typesfake.network, fake.database, fake.application
Its cloud.infra/fake-cloud.json
infra.yml
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.

NOT A MOCK

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.

KindState backend
Binaryinfrena-backend-s3
Installinfrena plugins install s3
Latest0.2.0
NeedsInfrena 0.13.0 or newer
infra.yml
backend:
  plugin: s3
  bucket: my-infrena-state

That is the whole minimum. Everything else has a default.

Which stores work

StoreStatus
AWS S3Passes the full live suite
MinIOPasses the full live suite
Backblaze B2Fails the lock check, and is refused
Cloudflare R2Expected to work, not tested here
DigitalOcean SpacesExpected 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.

TURN ON SERVER-SIDE ENCRYPTION

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

terminal
infrena discover
infrena discover --tag Name=app1 --exclude-type aws.logs.loggroup
infrena discover --all
output
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

terminal
infrena import dev --generate

Import adopts the resources into state, and --generate writes the configuration that declares them, under discovered/.

discovered/subnet-app1a.yml
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.

THE ROUND TRIP IS A TESTED GUARANTEE

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

infra.yml
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.

infrena validate
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:

THE ORDERING

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

infra.yml
backend:                    # where state should live
  plugin: s3
  bucket: new-bucket
migrate_from:               # where it lives today
  plugin: local
terminal
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

terminal
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.

the short version
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

CodeMeaning
0Success, nothing to do
1Error — configuration, a provider, a backend
2Success, and there are changes (plan) or changes were applied (apply)
77Changes need an approval this run cannot obtain
THE MOST COMMON MISTAKE

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.

branching on it safely
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.

terminal
# 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.

run.ndjson
{"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 --plan reads 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.
  • validate contacts 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 -1 still reports the exit code above.

Command reference

Every command. Those taking an environment require one, always.

The loop

CommandDoes
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

CommandDoes
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 versionVersion of the engine.

Adopting

CommandDoes
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

CommandDoes
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 migrateCopy state between backends. --check for CI.

Plugins and vault

CommandDoes
infrena plugins listWhat 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 verifyRe-check installed binaries against plugins.lock.
infrena vault create|edit|viewCreate, edit or print a vault.
infrena vault encrypt|decryptSeal or unseal a file in place.
infrena vault rekeyChange the passphrase.

Flags worth knowing

FlagDoes
--var name=valueOverride one variable for this run. Beats everything.
--var-file <path>Override several. Repeatable; later files win.
--output <file>Write NDJSON and silence stdout.
--auto-approveSkip 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.
GOING FURTHER

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.