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 |
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:
Create a server with Terraform.
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.
|
|
|---|---|
Direct link to the Ansible IaC entry. |
Indirect: strata first looks up |
Ansible uses a static inventory file from the playbook directory (e.g. |
Ansible uses a dynamic inventory: the IP is read from |
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 |
|
Yes β as |
|
Sensitive |
|
Only if the stage declares the key in |
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:
The stage needs to talk to servers that were created by a previous stage.
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.