🌱 A safe enclosure for your Terraform state 🦎
  • Rust 93.4%
  • Nix 6.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JMARyA fe5ecd49d5
Some checks reported errors
moira/test moira/test — succeeded
moira/flake/checks.nixos-integration@x86_64-linux checks.nixos-integration — queued
moira/flake/checks.terrarium@x86_64-linux checks.terrarium — a dependency failed
moira/flake/packages.containerImage@x86_64-linux packages.containerImage — a dependency failed
moira/flake/packages.default@x86_64-linux packages.default — a dependency failed
moira/container moira/container — succeeded
ci: declare moiraFlake so the flake is evaluated, not built on demand
.moira/container.yaml and .moira/container-release.yaml both declare
needs_flake: packages.containerImage.

checks.nixos-integration is a NixOS VM test requiring KVM. moira maps
requiredSystemFeatures onto the agent label vocabulary, so if no agent
advertises it the derivation is unclaimable and sits in the queue rather
than failing. Nothing depends on it, so it blocks nothing; exclude it if
it shows as stranded.

Without a moiraFlake spec no evaluation is queued, so the run falls back
to OnDemandBuilder: one opaque `nix build` inside the step that needs the
output. That gets no derivation graph, no substitutable-frontier pruning,
no per-derivation cache push, no fleet-wide dedupe, and no per-output
commit status. Declaring the spec is what turns it into scheduled work.

moira reads this as `nix eval .#moiraFlake`, so it has to be a top-level
output — inside eachDefaultSystem it would become moiraFlake.<system> and
silently parse as NeedsFlake::None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:51:35 +02:00
.moira ci: replace manual rustup test with nix flake check 2026-07-04 23:00:54 +02:00
docs docs(policy): document the policy engine 2026-07-29 13:39:35 +02:00
nixos fix: encode network mirror zh: hashes as hex, not base64 2026-07-22 09:52:46 +02:00
src feat(cli): render detailed plans from JSON 2026-08-02 18:11:16 +02:00
.containers-policy.json fix(ci): pass explicit skopeo trust policy in container pipelines 2026-05-30 02:44:06 +02:00
.gitignore feat: read-only web dashboard served at / 2026-05-29 23:17:32 +02:00
Cargo.lock feat(policy): embed Rego engine with bounded evaluation 2026-07-29 13:02:02 +02:00
Cargo.toml feat(policy): embed Rego engine with bounded evaluation 2026-07-29 13:02:02 +02:00
cog.toml chore: add cog 2025-12-29 11:53:40 +01:00
docker-compose.yml Add Prometheus observability 2026-07-19 20:04:47 +02:00
flake.lock chore: upgrade 2026-07-09 23:28:29 +02:00
flake.nix ci: declare moiraFlake so the flake is evaluated, not built on demand 2026-08-07 21:51:35 +02:00
mirrors.json.example feat: declarative auto-mirror with interval refresh 2026-07-04 22:56:16 +02:00
README.md docs(policy): document the policy engine 2026-07-29 13:39:35 +02:00
renovate.json chore: add renovate.json 2025-12-29 11:48:47 +01:00

🌱 Terrarium

A safe enclosure for your Terraform state. 🦎🪴

Terrarium is a small, boring, correct Terraform HTTP state backend — shipped as terra, a unified CLI that also wraps OpenTofu.

It stores Terraform state as an opaque blob, provides strict locking, tracks full version history, and stays completely out of your way.

No S3. No Terraform Cloud. No vendor assumptions.

Why?

Terraform state is critical, shared, and easy to corrupt. Terrarium exists because storing state in Git is unsafe, S3 should not be mandatory, and the Terraform HTTP backend deserves a first-class server.

Features

  • 🔀 Full OpenTofu CLI pass-through — terra plan, terra apply, etc. delegate straight to tofu
  • 🌱 Terraform-compatible HTTP backend (lock/unlock/push/pull)
  • 🔒 Strict, explicit state locking
  • 📜 Full version history — every push is versioned, diffs included
  • 📦 Workspace archival — mark a workspace read-only permanently
  • 🪝 Webhooks — event-driven integration per workspace
  • 📜 Policy engine — Rego (OPA) rules checked before apply and against every pushed state
  • 🗂 Path-prefix namespacing — infra/prod, apps/backend/staging
  • 🖥 Built-in read-only web UI — workspaces, version history, state diffs, dependency graph, locks, activity
  • 🦎 Single static binary (terra)
  • 🧱 Cloud-agnostic
  • 🔐 HTTP Basic Auth, browser sessions, and self-service Bearer API tokens

OpenTofu CLI

terra wraps tofu transparently — all OpenTofu subcommands are available as-is, with the same flags:

terra init
terra plan --var-file=prod.tfvars
terra apply --auto-approve
terra destroy
terra workspace list
terra state list
# ... and all other tofu subcommands

OpenTofu must be installed and available in $PATH.


Deployment

Docker Compose

services:
  terrarium:
    image: git.hydrar.de/jmarya/terrarium:latest
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app # all state, locks, users, versions and webhooks live here
    environment:
      - "RUST_LOG=info"
      - "TERRARIUM_DATA=/app"
    command: "/bin/terra serve"

All data is stored under TERRARIUM_DATA (/app in the container, ./data on the host):

Path Contents
state/ Current state blobs
versions/ Full version history
locks/ Persisted lock files
users/ User database
webhooks.json Registered webhooks

Observability

Terrarium can expose a Prometheus endpoint for safe aggregate platform metrics:

TERRARIUM_METRICS=1
TERRARIUM_METRICS_TOKEN_FILE=/run/secrets/terrarium_metrics_token

Scrape:

GET /metrics
Authorization: Bearer <metrics-token>

Metrics focus on core Terrarium functions: state inventory and change velocity, Terraform resource/output aggregates, storage growth, locks, webhooks, provider registry/mirror jobs, and Terrarium API latency/errors. Metrics intentionally avoid workspace names, usernames, resource addresses, output names/values, token IDs, and webhook URLs.

See docs/observability.md and docs/grafana/terrarium-dashboard.json.


Policies

Terrarium checks Rego (OPA) policies before an apply and against every pushed state. The engine is built into terra — no OPA binary, no sidecar.

package terrarium.plan

deny contains msg if {
    some rc in input.plan.resource_changes
    "delete" in rc.change.actions
    startswith(rc.address, "aws_db_instance.")
    msg := sprintf("refusing to destroy database %s", [rc.address])
}

Author policies in your repository's .terrarium/policies/, check them with terra policy test against a real plan, then terra policy push to share them with the team and the server-side linter.

Note that the apply-time check is a guardrail, not access control — anyone who can push state can bypass it. What does not depend on client cooperation is the server-side lint, which records every violating state that lands.

See docs/policies.md.


Web UI

Terrarium serves a small, read-only web dashboard from the same server, at the root path (/). It's purely informational — a viewer, not a control plane: there are no plan/apply/force-unlock actions. The only things it mutates are your own login session and your own API tokens.

Open http://your-server:8080/ and sign in with any user from the user database.

What's there:

  • Workspaces (/) — namespace tree, active locks, archived states
  • Per workspace (/w/{name}) — lock status, version history with a size trend, registered webhooks, and an activity log derived from lock history (who locked the state, for which operation, when)
  • State view (/w/{name} → a version, or /graph/{name}?version=N) — state metadata, resources grouped by type, outputs, and a dependency graph rendered as inline SVG (no external assets)
  • Diff (/diff/{name}?from=A&to=B) — structural diff between any two versions
  • Tokens (/tokens) — create, list, and revoke your own API tokens

Authentication

Audience Credential
Browser session cookie, set on login at /login
Terraform HTTP backend / terra remote HTTP Basic Auth
Scripts / CI Authorization: Bearer <token> (tokens minted in the UI)

API endpoints accept either Basic Auth or a Bearer token, so a token minted in the UI works directly against the HTTP API.

The session cookie is marked Secure only when TERRARIUM_TLS is set (1/true) — set it when terra runs behind TLS. Leave it unset for plain-HTTP local testing.


Terraform Configuration

terraform {
  backend "http" {
    address        = "https://terrarium.example/state/infra/prod"
    lock_address   = "https://terrarium.example/lock/infra/prod"
    unlock_address = "https://terrarium.example/lock/infra/prod"

    lock_method   = "POST"
    unlock_method = "DELETE"
  }
}

Provide credentials via $TF_HTTP_USERNAME and $TF_HTTP_PASSWORD, then run terra init.

Workspace names support path prefixes: infra/prod, apps/backend/staging, etc.


User Management

User management commands act directly on the local user database. When running in Docker, exec into the container or they'll write to a different path than the server uses:

docker compose exec terrarium /bin/terra user add alice
# Add a user (prompts for password if omitted)
terra user add <username> [password]

# Change a user's password
terra user passwd <username>

# Delete a user
terra user delete <username>

# List all users
terra user list

Client — Remote Operations

The remote subcommand talks to a running terrarium server over HTTP.

Configuration

Save credentials interactively:

terra terra-login
# prompts for server URL, username, and password
# saves to ~/.config/terrarium/config.toml (chmod 600)

Or set environment variables:

export TERRARIUM_URL=https://terrarium.example
export TERRARIUM_USER=alice
export TERRARIUM_PASSWORD=secret

Full priority order:

  1. Environment variables: TERRARIUM_URL, TERRARIUM_USER, TERRARIUM_PASSWORD
  2. Config file (first found wins):
    • --config <path> flag on any remote command
    • $TERRARIUM_CONFIG
    • ~/.config/terrarium/config.toml
    • ~/.terrarium.toml
  3. Interactive password prompt (if TERRARIUM_PASSWORD is unset)

Config file format (~/.config/terrarium/config.toml):

url = "https://terrarium.example"
username = "alice"
# password: use TERRARIUM_PASSWORD env var, not the config file

State

# List all states
terra remote state list

# List states under a path prefix
terra remote state list infra/

# List archived states
terra remote state list --archived

# Get current state (pretty-printed by default)
terra remote state get infra/prod

# Get raw JSON
terra remote state get infra/prod --raw

# Get a specific historical version
terra remote state get infra/prod --version 3

# List all available versions
terra remote state versions infra/prod

# Diff two versions (structural JSON diff)
terra remote state diff infra/prod 2 5

# Force-unlock a state
terra remote state unlock infra/prod

# Archive a state (marks it read-only, rejects future pushes)
terra remote state archive infra/prod

# Unarchive a state (re-enables writes)
terra remote state unarchive infra/prod

Locks

# List all active locks
terra remote lock list

Self-Service User

# Change your own password
terra remote user passwd [new-password]

Webhooks

Webhooks are scoped per workspace and fire on state and lock events.

# Register a webhook (all events)
terra remote webhook add infra/prod https://hooks.example.com/tf

# Register for specific events only
terra remote webhook add infra/prod https://hooks.example.com/tf \
  --events state.push,lock.acquire

# List webhooks for a workspace
terra remote webhook list infra/prod

# Remove a webhook by ID
terra remote webhook remove <id>

Supported events: state.push, state.delete, state.archive, lock.acquire, lock.release

Payload:

{
  "event": "state.push",
  "workspace": "infra/prod",
  "version": 4,
  "user": "alice",
  "timestamp": "2026-03-29T16:00:00Z"
}

Delivery is retried up to 4 times with exponential backoff (1 s → 2 s → 4 s) before giving up.


HTTP API

All endpoints require authentication — either HTTP Basic Auth or an Authorization: Bearer <token> API token (created via the web UI).

Method Path Description
GET /state List all state names (?prefix=infra/ to scope)
GET /state/{name} Get current state (?version=N for history)
POST /state/{name} Push state (?ID=<lock-id> if locked)
DELETE /state/{name} Delete state
GET /versions/{name} List version numbers for a state
POST /archive/{name} Archive a state (mark read-only)
DELETE /archive/{name} Unarchive a state (re-enable writes)
GET /lock List all active locks
POST /lock/{name} Acquire lock
DELETE /lock/{name} Release lock
PUT /user/password Change own password ({ current_password, new_password })
GET /webhooks/{workspace} List webhooks for workspace
POST /webhooks/{workspace} Register webhook ({ url, events[] })
DELETE /webhooks/id/{id} Remove webhook by ID
GET /policy List policies
GET /policy/bundle Policies with source + effective config (?workspace=)
PUT /policy/{name} Create/replace a policy ({ source, workspace, enabled })
DELETE /policy/{name} Remove a policy
GET /policy/config Scoped enforcement configuration
PUT /policy/config Replace enforcement configuration
GET /violations Current violation reports
GET /violations/{name} Violation report for one workspace (404 when clean)