How Deployments Work

A plain-language walkthrough of the strata deployment model β€” from a single server to a multi-stage production pipeline.


The five things you need to know

Thing

What it is

Workspace

Declares what infrastructure tools exist and how to use them.

Provisioner

One entry in the workspace β€” a named IaC tool (Terraform, Ansible, Helm, …) with its source code location.

Topology

An optional named group in the workspace that says β€œthese servers were created by this provisioner.” When a stage uses topology:, strata derives the Ansible inventory from that provisioner’s stage_outputs at runtime β€” so no static inventory file is needed or consulted.

Deployment

Declares what to do β€” which environment to target, and a list of stages to run in order.

Stage

One step in the deployment. Points to a provisioner (or a topology) and runs a deployer (Terraform, Ansible, …).

Stages run in order. Each stage can read the outputs of every stage that ran before it.


Simple example β€” one server, one Ansible playbook

You want to:

  1. Create a server with Terraform.

  2. Configure it with Ansible.

Workspace

apiVersion: strata.huybrechts.xyz/v1
kind: workspace
meta:
  name: my_workspace
spec:
  provisioners:
    # Tool 1: Terraform β€” knows how to create the server
    - name: my_iac
      provisioner: terraform
      source:
        repository: my_infra
        source_path: terraform

    # Tool 2: Ansible β€” knows how to configure the server
    - name: my_ansible
      provisioner: ansible
      source:
        repository: my_infra
        source_path: ansible
      properties:
        playbook: site.yml
        ssh_private_key_secret: ssh_private_key

  topology:
    # "my_server" is a name for the concept "one server managed by my_iac".
    - name: my_server
      provisioner: my_iac           # which provisioner created it

Deployment

apiVersion: strata.huybrechts.xyz/v1
kind: deployment
meta:
  name: my_deployment
spec:
  workspace:
    name: my_workspace
    file: workspace.yaml

  environments:
    - environments/env-prd.yaml

  stages:
    # Stage 1 β€” create the server
    - name: infrastructure
      provisioner: my_iac        # direct: "use this provisioner by name"
      secrets:
        - hetzner_api_token      # only this secret is available

    # Stage 2 β€” configure the server
    - name: configure
      provisioner: my_ansible    # direct: "use this provisioner by name"
      secrets:
        - ssh_private_key        # only SSH key, not the API token
      # OR β€” using topology (see below):
      # topology: my_server

Terraform outputs.tf (simple)

This is the file inside your Terraform source that produces the output:

# terraform/outputs.tf
output "server_ip" {
  value = hcloud_server.main.ipv4_address
}

That single declaration is all that’s needed. After terraform apply strata runs terraform output -json, reads server_ip, and stores it for the next stage.

What happens at runtime

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage: infrastructure   (Terraform deployer)           β”‚
β”‚                                                         β”‚
β”‚  Reads:   environment variables (HETZNER_TOKEN, etc.)   β”‚
β”‚  Does:    terraform init β†’ plan β†’ apply                 β”‚
β”‚  Outputs: { "server_ip": "10.0.1.42" }   ← from outputs.tfβ”‚
β”‚           β†’ stored in stage_outputs["server_ip"]        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚  stage_outputs flows down
                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage: configure   (Ansible deployer)                  β”‚
β”‚                                                         β”‚
β”‚  Reads:   stage_outputs["server_ip"] = "10.0.1.42"      β”‚
β”‚           (when using topology:, this becomes the       β”‚
β”‚            inline Ansible inventory automatically)      β”‚
β”‚  Does:    ansible-galaxy install β†’ ansible-playbook     β”‚
β”‚  Outputs: (none β€” Ansible produces no structured output)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

provisioner: vs topology: on a stage

Both point the stage to the right deployer. The difference is what Ansible does with the server IP.

provisioner: my_ansible

topology: my_server

Direct link to the Ansible IaC entry.

Indirect: strata first looks up my_server, finds it uses my_iac, then finds the Ansible IaC entry.

Ansible uses a static inventory file from the playbook directory (e.g. inventory.yml).

Ansible uses a dynamic inventory: the IP is read from stage_outputs["server_ip"] at runtime and passed inline. No inventory file needed.

Use this when the host list is known ahead of time.

Use this when the servers were just created by a previous stage and their IPs are only known at runtime.

Why not combine them? A static inventory file in the playbook directory is never consulted when using topology:. This is intentional β€” if a stage is skipped in a partial re-run, a stale inventory file would silently point Ansible at servers from a previous run. The split makes the host source unambiguous: either you own a static file, or strata derives the hosts from fresh Terraform output.


Complex example β€” multi-stage production pipeline

Infrastructure, configuration, and application deployment across five stages.

What we’re building

[ Terraform: network ]  β†’  [ Terraform: servers ]  β†’  [ Ansible: configure ]
                                                              β”‚
                                                              β–Ό
                                                    [ Helm: deploy app ]
                                                              β”‚
                                                              β–Ό
                                                    [ Ansible: smoke test ]

Workspace

spec:
  provisioners:
    - name: network_iac
      provisioner: terraform
      source:
        repository: infra
        source_path: terraform/network

    - name: server_iac
      provisioner: terraform
      source:
        repository: infra
        source_path: terraform/servers

    - name: configure_ansible
      provisioner: ansible
      source:
        repository: infra
        source_path: ansible/configure
      properties:
        playbook: site.yml
        ssh_private_key_secret: ssh_private_key

    - name: app_helm
      provisioner: helm
      source:
        repository: infra
        source_path: helm

    - name: test_ansible
      provisioner: ansible
      source:
        repository: infra
        source_path: ansible/smoketest

  topology:
    # The "app_servers" topology: servers created by server_iac.
    - name: app_servers
      provisioner: server_iac

Deployment

spec:
  stages:
    - name: network
      provisioner: network_iac
      secrets:
        - cloud_api_key

    - name: servers
      provisioner: server_iac
      secrets:
        - cloud_api_key

    - name: configure
      topology: app_servers       # ← no static inventory; IPs come from stage 'servers'
      secrets:
        - ssh_private_key

    - name: deploy
      provisioner: app_helm
      # no secrets needed β€” Helm values come from STRATA_CONTEXT

    - name: smoketest
      topology: app_servers       # ← same topology, same dynamic inventory
      secrets:
        - ssh_private_key

Terraform outputs.tf (complex)

# terraform/network/outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}
output "subnet_id" {
  value = aws_subnet.app.id
}
# terraform/servers/outputs.tf
output "server_ips" {
  value = [for s in aws_instance.app : s.private_ip]  # list of IPs
}

The servers stage produces a list β€” strata joins it with commas into the Ansible inline inventory format: "10.0.1.10,10.0.1.11,10.0.1.12,".

Data flow through all five stages

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 1: network   (Terraform)                              β”‚
β”‚                                                              β”‚
β”‚  Reads:   env vars β€” cloud credentials, region              β”‚
β”‚  Does:    creates VPC, subnets, firewall rules               β”‚
β”‚  Outputs: { "vpc_id": "vpc-abc123",       ← from outputs.tf  β”‚
β”‚             "subnet_id": "sub-def456" }                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 2: servers   (Terraform)                              β”‚
β”‚                                                              β”‚
β”‚  Inputs:  env vars + stage_outputs from stage 1:            β”‚
β”‚           TF_VAR_vpc_id    = "vpc-abc123"                    β”‚
β”‚           TF_VAR_subnet_id = "sub-def456"                    β”‚
β”‚  Does:    creates 3 VMs inside the VPC                       β”‚
β”‚  Outputs: { "server_ips": ["10.0.1.10","10.0.1.11",         β”‚
β”‚                             "10.0.1.12"] }                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 3: configure   (Ansible, via topology: app_servers)   β”‚
β”‚                                                              β”‚
β”‚  Inputs:  stage_outputs["server_ips"]                        β”‚
β”‚           β†’ _dynamic_inventory = "10.0.1.10,10.0.1.11,      β”‚
β”‚                                   10.0.1.12,"                β”‚
β”‚  Does:    runs site.yml against all three servers            β”‚
β”‚  Outputs: (none)                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 4: deploy   (Helm)                                    β”‚
β”‚                                                              β”‚
β”‚  Inputs:  env vars + all previous stage_outputs             β”‚
β”‚           (Helm values can reference server IPs, VPC ID, …) β”‚
β”‚  Does:    helm upgrade --install my-app ./chart             β”‚
β”‚  Outputs: (none by default)                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 5: smoketest   (Ansible, via topology: app_servers)   β”‚
β”‚                                                              β”‚
β”‚  Inputs:  same dynamic inventory as stage 3                  β”‚
β”‚           (built again from stage_outputs["server_ips"])     β”‚
β”‚  Does:    runs smoketest.yml β€” hits each server's /health    β”‚
β”‚  Outputs: (none)                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

How outputs travel between stages

Terraform outputs are collected after every apply and split into two buckets:

Bucket

Key in code

Injected into subprocesses?

Example

Non-sensitive

stage_outputs (STRATA_CONTEXT)

Yes β€” as TF_VAR_<key> for Terraform stages, bare env var for Ansible/Compose

server_ip, vpc_id

Sensitive

stage_outputs_sensitive (STRATA_SENSITIVE)

Only if the stage declares the key in secrets:

passwords, tokens, private keys

Every stage that runs after a Terraform stage automatically has its non-sensitive outputs available (via STRATA_CONTEXT). Sensitive outputs require the downstream stage to declare the key in its secrets: allowlist.

STRATA_CONTEXT seed

Before the first stage runs, STRATA_CONTEXT is seeded with the resolved environment variables and feature flags. After each stage, its non-sensitive outputs are added. Every subsequent stage receives the full accumulated context.

STRATA_SENSITIVE seed and scoping

Before the first stage runs, STRATA_SENSITIVE is seeded with resolved secrets. Access is default-deny: a stage only receives secrets it declares in secrets:.

stages:
  - name: provision
    provisioner: my_tf
    secrets:
      - hetzner_api_token    # only this secret visible
  - name: configure
    provisioner: my_ansible
    secrets:
      - ssh_private_key      # only SSH key visible, not the API token
  - name: verify
    provisioner: script_check
    # no secrets β†’ no sensitive values at all

Topology β€” when to use it

Use topology: on a stage (instead of provisioner:) when both of these are true:

  1. The stage needs to talk to servers that were created by a previous stage.

  2. The IPs of those servers are only known at runtime (Terraform output).

The topology definition in the workspace is the bridge: it declares which provisioner created the servers and which Terraform output key holds their IPs. The Ansible deployer reads that key from stage_outputs and builds the inventory string automatically.

If the servers are static and known ahead of time (fixed IPs in a repo inventory file), use provisioner: directly and keep the inventory file in the playbook directory.