Multi-namespace deployments
You have a single host running several independent slices of infrastructure: a base layer (firewall, docker, traefik), a database tier, and a couple of apps. You want each slice to plan and apply on its own — without forgetting a -c flag and tripping the destruction guard, and without one slice's state file silently owning what another slice declared.
This is what namespaces are for. You write one manifest at ./stratum.strat that declares the shared host(s) and lists each slice by name; each slice gets its own state file; stratum -n <name> plan/apply operates inside one slice at a time. Stratum checks for docker_container port and name collisions across siblings at plan time, so two slices can't quietly fight over :80.
This tutorial walks through:
- Writing a manifest that splits one host into two namespaces.
- Applying each namespace independently.
- Observing the on-disk state split (
.stratum/<ns>.json+_shared.json). - Provoking a cross-namespace port collision and reading the error.
- Migrating from an existing bundle (
-c X -c Y -s state.json) to per-namespace state.
If you've never applied a stratum config before, start with Bootstrap a fresh droplet for the basics. The setup here assumes a host already exists.
What you'll end up with
./stratum.strat # manifest (host + namespace blocks)
./infra/edge.strat # namespace "infra" config (traefik, edge network)
./app/web.strat # namespace "app" config (nginx behind traefik)
./app/db.strat # namespace "app" config (postgres)
.stratum/
infra.json # state for namespace "infra"
app.json # state for namespace "app"
_shared.json # implicit _stratum_* tuning resources
Two independent state files for two namespaces, one shared file for the per-host tuning resources stratum injects automatically.
Step 1: write the manifest
The manifest is a plain .strat file. It contains:
hostblocks — visible to every namespace.secretblocks — visible to every namespace.namespaceblocks — one per slice.
It does not contain resource blocks. Those live in the per-namespace configs.
# stratum.strat
host "primary" {
addr = "root@192.0.2.10"
}
namespace "infra" {
configs = ["infra/edge.strat"]
}
namespace "app" {
configs = [
"app/web.strat",
"app/db.strat",
]
}
configs paths are resolved relative to the manifest's directory. The manifest itself is loaded first when a namespace is selected, so anything it declares (the host "primary" block, any secret blocks) is visible to every file under configs.
Step 2: per-namespace configs
# infra/edge.strat
resource "docker_network" "edge" {
host = host.primary.addr
name = "stratum-edge"
}
resource "docker_container" "traefik" {
host = host.primary.addr
name = "traefik"
image = "traefik:v2.11"
restart = "unless-stopped"
ports = ["80:80", "443:443"]
volumes = ["/var/run/docker.sock:/var/run/docker.sock:ro"]
networks = ["stratum-edge"]
}
# app/web.strat
resource "docker_container" "web" {
host = host.primary.addr
name = "web"
image = "nginx:alpine"
restart = "unless-stopped"
networks = ["stratum-edge"]
labels = {
"traefik.enable" = "true"
"traefik.http.routers.web.rule" = "Host(`web.example.com`)"
"traefik.http.routers.web.entrypoints" = "web"
}
}
# app/db.strat
secret "db_password" {
from_env = "DB_PASSWORD"
}
resource "docker_container" "db" {
host = host.primary.addr
name = "db"
image = "postgres:16-alpine"
restart = "unless-stopped"
networks = ["stratum-edge"]
env = {
POSTGRES_PASSWORD = secret.db_password.value
POSTGRES_DB = "app"
}
volumes = ["app-db-data:/var/lib/postgresql/data"]
}
Three details:
- Both
app/web.stratandapp/db.stratreferencehost.primary.addr. The host is declared in the manifest, not in either of these files — that's fine because the manifest is always loaded first in namespace mode. - The
secret "db_password"block is scoped to theappnamespace. Theinfranamespace's plan does not loadapp/db.strat, soDB_PASSWORDdoes not need to be set when planninginfra. - All three containers attach to
stratum-edge. The network is created by theinfranamespace; theappnamespace just attaches to it. Stratum does not validate that the network exists across namespaces — applyinfrafirst.
Step 3: plan and apply the infra namespace
stratum -n infra plan
stratum.strat is auto-discovered in the current directory. The -n infra flag resolves to:
- configs:
stratum.strat(the manifest) +infra/edge.strat. - state:
.stratum/infra.json(default for-n infrasince nostate =was set).
Expected plan: one create for docker_network.edge, one for docker_container.traefik, plus three implicit _stratum_* tuning resources stratum injects per host.
+ _stratum_sshd_oom_primary
+ _stratum_sshd_reload_primary
+ _stratum_swap_primary
+ docker_container.traefik
+ docker_network.edge
5 create, 0 update, 0 delete, 0 no-op
Apply it:
stratum -n infra apply -y
After the apply, inspect the state directory:
ls .stratum/
# _shared.json infra.json
infra.json holds the two user-declared resources (docker_container.traefik, docker_network.edge). _shared.json holds the three _stratum_* resources. The split is by addr name: anything starting with _stratum_ goes to _shared.json, everything else to the namespace's file. This is what lets a second namespace targeting the same host see the tuning resources as already-applied instead of trying to recreate them.
Step 4: plan and apply the app namespace
export DB_PASSWORD=$(openssl rand -hex 32)
stratum -n app plan
The app namespace's plan loads the manifest, then app/web.strat, then app/db.strat. Two creates for the new containers; the three _stratum_* resources show as no-op (they're already in _shared.json from the infra apply).
_stratum_sshd_oom_primary
_stratum_sshd_reload_primary
_stratum_swap_primary
+ docker_container.db
+ docker_container.web
2 create, 0 update, 0 delete, 3 no-op
The three no-ops are the heart of why _shared.json exists. Without it, the app namespace's first apply would try to recreate the swap file and the sshd drop-in, and the second apply of infra would do the same thing in reverse — every cross-namespace apply would churn the tuning resources.
Apply:
stratum -n app apply -y
State is now:
.stratum/
_shared.json # 3 _stratum_* resources
app.json # docker_container.{web,db}
infra.json # docker_container.traefik, docker_network.edge
Each namespace can now plan / apply / be torn down on its own without touching the other.
Step 5: collisions are caught at plan time
Now provoke a port conflict on purpose. Add a ports line to app/web.strat claiming :80:
# app/web.strat — buggy version
resource "docker_container" "web" {
# ...
ports = ["80:80"] # WRONG: traefik already binds :80 on this host
}
stratum -n app plan
The cross-namespace validator runs before any plan output prints:
Error: cross-namespace port conflict on host `root@192.0.2.10`:
- app::docker_container.web (current) wants 80
- infra::docker_container.traefik (sibling) already claims it
Three things to notice:
- The error names both namespaces (
app::...for the resource being planned,infra::...for the sibling that already claimed the port). - The host is named in the prefix. Two namespaces using
:80on different hosts is allowed. - The validator runs at plan time, before any side effects — apply doesn't get a chance to fight docker over the port.
Container name collisions are caught the same way:
Error: cross-namespace container name conflict on host `root@192.0.2.10`:
- app::docker_container.db (current) uses name `traefik`
- infra::docker_container.traefik (sibling) already uses it
Resolve the conflict by removing the ports line — the web container is fronted by traefik over the stratum-edge network, so it doesn't need a host port binding.
Step 6: cross-namespace depends_on doesn't work — duplicate instead
Suppose you want to add a build step: a ssh_exec runs docker build to produce an image, and the docker_container in the app namespace consumes it.
# app/web.strat
resource "docker_container" "web" {
# ...
depends_on = ["ssh_exec.build-web"] # WRONG: declared in another namespace
}
If ssh_exec.build-web lives in some other namespace, the planner can't see it — it only loads the current namespace's resources — and you'll get an undeclared-target error at plan time.
The workaround is duplicate the producer: move (or copy) the build step into the namespace that consumes it.
# app/web.strat
resource "ssh_exec" "build-web" {
host = host.primary.addr
command = "cd /srv/repos/web && docker build -t web:dev ."
}
resource "docker_container" "web" {
# ...
image = "web:dev"
pull = false
depends_on = ["ssh_exec.build-web"]
}
Now depends_on is local to the namespace, and the planner can topo-sort the build ahead of the container start. If two namespaces share the same git checkout and need to build it for different consumers, declare the ssh_exec once per namespace — the apt-package equivalent of "each apt cache update runs once per host, not once per consumer."
Migrating from bundle mode
If you've been running stratum with -c X -c Y -s droplet.json, the path to namespaces is mechanical:
- Move shared declarations into a new
stratum.strat. Pull everyhostandsecretblock out of the per-config files and into the manifest. Add onenamespace "<name>" { configs = [...] }per logical slice. - Leave the per-slice configs in place. They keep their
resourceblocks. Anyhost.<name>.<field>references in them now resolve against the manifest'shostdeclaration — no edits required, as long as the host name is unchanged. - Split the bundle state file. Run
stratum state show <addr> -p droplet.jsonfor each resource to identify which namespace it belongs to. Hand-write the per-namespace state files by copying entries out ofdroplet.json. Implicit_stratum_*resources go to_shared.json. - Verify with
plan. For each namespace, runstratum -n <name> plan. Every step should show no-op (0 create, 0 update, 0 delete, N no-op). Anything else means a resource ended up in the wrong file — fix the split. - Mind the
depends_onedges. If anydocker_container.depends_oncrosses what is now a namespace boundary (the producer is in namespace A, the consumer in namespace B), duplicate the producer into B and edge to the local copy. See Step 6 above.
The bundle workflow keeps working without migration. If you don't need per-slice state files, you can keep using -c X -c Y -s state.json indefinitely — nothing about namespaces is mandatory.
What's next
- Namespaces reference — full attribute table and error catalog.
-nand--manifestCLI flags — exact flag semantics, including the-soverride rule.- Architecture: split state — how
_shared.jsonis reconciled at load and save time.