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:

  • host blocks — visible to every namespace.
  • secret blocks — visible to every namespace.
  • namespace blocks — 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.strat and app/db.strat reference host.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 the app namespace. The infra namespace's plan does not load app/db.strat, so DB_PASSWORD does not need to be set when planning infra.
  • All three containers attach to stratum-edge. The network is created by the infra namespace; the app namespace just attaches to it. Stratum does not validate that the network exists across namespaces — apply infra first.

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 infra since no state = 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 :80 on 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:

  1. Move shared declarations into a new stratum.strat. Pull every host and secret block out of the per-config files and into the manifest. Add one namespace "<name>" { configs = [...] } per logical slice.
  2. Leave the per-slice configs in place. They keep their resource blocks. Any host.<name>.<field> references in them now resolve against the manifest's host declaration — no edits required, as long as the host name is unchanged.
  3. Split the bundle state file. Run stratum state show <addr> -p droplet.json for each resource to identify which namespace it belongs to. Hand-write the per-namespace state files by copying entries out of droplet.json. Implicit _stratum_* resources go to _shared.json.
  4. Verify with plan. For each namespace, run stratum -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.
  5. Mind the depends_on edges. If any docker_container.depends_on crosses 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