Introduction

stratum is a tiny declarative IaC tool, written from scratch in Rust. It's scoped to system bootstrap: install packages, drop config files, manage systemd services, configure the firewall, and run system-tier containers (Traefik, monitoring agents, log shippers). Describe resources in a .strat file, run stratum plan to preview, and stratum apply -y to make it happen. State lives in a JSON file under .stratum/state.json next to your config.

The tool is intentionally small. No plugin system, no remote backend, no cloud SDK. Providers are first-class crates in the workspace; today there are four:

  • system — packages, services, files, secret files, directories, ufw rules.
  • ssh — shells out to system ssh to run commands and write files.
  • docker — drives the remote docker CLI over SSH (networks, containers, image builds).
  • git — clones and pins git working trees on a remote host.

What stratum is not

Stratum is not an app-deployment tool. It will not build your application image, manage per-app environments, or rotate deploys. That's the job of the sibling project deployd (different repo). Stratum's role stops at "the host has docker, traefik, a firewall, and the right config files." Deployd takes over from there.

What works today

  • .strat config: host, secret, provider, resource blocks; nested maps, lists, string/number/bool values; comments (# and //); refs of the form host.<name>.<field> and secret.<name>.value; ${...} string interpolation for embedding refs inside larger strings.
  • JSON state at .stratum/state.json, with create / update / delete actions and a recursive structural diff that ignores state-only provider-computed fields.
  • CLI: plan (with --refresh for live drift detection and --allow-unresolved-secrets for plan-only review), apply (with -y to execute and --allow-destroy to permit deletes), status (per-host resource snapshot), state list, state show, state merge. Global --env-file for loading env vars before resolving secret { from_env } refs, with auto-load of ./.env when no flag is passed.
  • Repeatable -c on plan/apply: multiple .strat files merge into one document and evaluate together. Hosts and secrets declared in one file are visible to refs in another. Duplicates across files are hard errors that name both paths. See Multi-file configs.
  • system_package, system_service, system_file, system_secret_file, system_dir, system_ufw_rule (apt + systemd + ufw + file + secret-file + directory-tree management). system_dir also has an empty-dir mode for pre-creating daemon directories without uploading anything. system_secret_file stores sha256 only — plaintext never persists in state.
  • ssh_exec (with optional env map for sensitive shell prefixes), ssh_file (run commands, write files).
  • docker_network, docker_container, docker_image. Containers support depends_on (planner topo-sorts), healthcheck (post-apply readiness wait), memory / memory_swap limits, list-form command for argv passthrough, and pull = false for locally-built images. docker_image builds images on the host with DOCKER_BUILDKIT=1 and tracks the resulting image_id.
  • git_repo (new git provider) clones a remote repo to a fixed path on a host and pins it to a branch, tag, or full SHA. State tracks commit_sha; drift triggers fetch + reset.
  • Secrets v0: secret { from_env = ... } or from_file = ..., referenced as secret.<name>.value or ${secret.<name>.value} inside strings. Plaintext flows to providers; state stores a {__secret, __secret_sha256} marker for whole-leaf matches and a <secret:NAME:sha256:HEX> inline marker for substring matches inside interpolated strings; diff/diff_observed are marker-aware so no perpetual drift; plan output renders object markers as <secret:name sha:abc123>. See Secrets.
  • Planner-side validators: docker_container.ports conflict check across the merged config (fails on (host, ip, host_port) collisions, with 0.0.0.0:N / 127.0.0.1:N symmetry); depends_on topo sort with cycle and unknown-ref detection.
  • Post-apply readiness wait: a docker_container with a healthcheck map blocks subsequent steps until docker inspect reports healthy (60s budget). Otherwise a 500ms cosmetic pause.
  • Provider::read on system_* (except system_ufw_rule), ssh_file, both docker_* kinds, and git_repo — surfaces drift between recorded state and live host reality.
  • Post-apply self-check: every successful stratum apply -y re-reads each resource and reports post-apply drift: clean (or counts of differ/missing/unreadable).
  • content_file = "<relative-path>" on system_file and source_dir = "<relative-path>" on system_dir, resolved against the .strat file's directory.
  • Destruction guard: stratum apply refuses to run a plan with Delete steps unless --allow-destroy is passed. Error names the resources, the loaded configs, and the state file path.

What does not work yet

  • No drift detection for system_ufw_rule or ssh_exec — both return Unknown from read, so they show up in unreadable counts (intentional).
  • No DNS provider.
  • No partial / targeted apply — every plan is whole-config.
  • No state locking or remote backend.
  • No cross-resource refs of the form <kind>.<name>.<attr> (e.g. docker_image.X.id) — producer-to-consumer wiring still uses the literal tag + a depends_on edge. Planned, not shipped.
  • The provider "<name>" { ... } block parses but no shipped provider reads one today.

Quick start

cargo build --release

# write a config
cat > stratum.strat <<'EOF'
host "prod" {
  addr = "root@1.2.3.4"
}

resource "system_package" "curl" {
  host = host.prod.addr
  name = "curl"
}
EOF

# see what would happen
./target/release/stratum plan

# preview again with live drift annotation
./target/release/stratum plan --refresh

# apply for real
./target/release/stratum apply -y

State is written to .stratum/state.json. Inspect it with stratum state list and stratum state show.

Where to go next