- Rust 87.2%
- CSS 6.1%
- Nix 5.1%
- JavaScript 1.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .claude | ||
| .moira | ||
| docs | ||
| nixos | ||
| openspec | ||
| src | ||
| .containers-policy.json | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| cog.toml | ||
| docker-compose.yml | ||
| flake.lock | ||
| flake.nix | ||
| mirrors.json.example | ||
| README.md | ||
| renovate.json | ||
🌱 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 totofu - 🌱 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
applyand 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; {N}.meta.json beside each version records who pushed it, when, its serial and lineage |
locks/ |
Persisted lock files |
users/ |
User database |
webhooks.json |
Registered webhooks and their signing secrets (0600) |
Every push is kept forever by default. Set TERRARIUM_KEEP_VERSIONS=N to keep only the newest N versions per workspace; older ones are pruned on the next push.
The server listens on 0.0.0.0:8080; override with TERRARIUM_LISTEN (e.g. 127.0.0.1:9000).
Request bodies (state pushes, provider uploads) are capped at 256 MiB by default. Override with TERRARIUM_MAX_BODY_BYTES (in bytes).
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 web UI from the same server, at the root path (/). It's server-rendered (maud + a vendored htmx, every asset compiled into the binary — no CDN calls) and mostly a viewer: there are no plan/apply actions. Besides your own session and API tokens, it can do exactly two small things to a workspace — force-unlock a held lock and archive/unarchive — both behind a two-click confirm, and both going through the same handlers as the API, so webhooks and metrics fire as usual.
Open http://your-server:8080/ and sign in with any user from the user database.
What's there:
- Overview (
/) — fleet vitals, an activity timeline across all workspaces (who pushed what), live held locks, what needs attention (expired locks, policy violations), and workspaces that have gone quiet (no push in 90 days) - Workspace (
/w/{name}) — a lifeline of every version, the live lock (with force-unlock), policy findings, webhooks and activity - State (
/graph/{name}?version=N) — resources grouped by type with a filter, outputs, metadata, an explorable dependency graph (hover to trace, click to pin), and an attribute inspector for any resource. Values Terraform marks sensitive, and values under secret-looking keys (password,token,private_key, …), are masked server-side - History (
/diff/{name}?from=A&to=B) — pick two versions, get a colour-coded structural diff of resources and outputs in place (sensitive output values are never shown) - Policies, Registry with rendered provider docs, Tokens, Help
- Command palette — press
/orCtrl Kanywhere;infra/prod@3jumps to version 3
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.
Stale pushes
A push that would move a workspace backwards is refused with 409 Conflict: a state whose serial is lower than the current one (an old local copy), or whose lineage differs (a different state altogether). That's usually a stale terraform.tfstate or a state push -force aimed at the wrong workspace. To roll back on purpose, restore an older version instead; to overwrite anyway, push with ?force=true. Pushes that aren't Terraform state are not checked.
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
Backups
terra admin export writes the whole data directory to one zip; terra admin import unpacks it into a fresh one. Like terra user, both act on TERRARIUM_DATA directly, so run them where the server's data lives (docker compose exec terrarium /bin/terra admin export /app/backup.zip).
terra admin export backup.zip # state, history, locks, users, webhooks, policies
terra admin export backup.zip --registry # plus provider binaries and docs
terra admin import backup.zip # into an empty data directory, server stopped
Exporting while the server runs is safe: every file is written atomically, so each one in the archive is consistent. The archive holds password hashes and webhook signing secrets — it's created 0600; keep it that way.
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:
- Environment variables:
TERRARIUM_URL,TERRARIUM_USER,TERRARIUM_PASSWORD - Config file (first found wins):
--config <path>flag on anyremotecommand$TERRARIUM_CONFIG~/.config/terrarium/config.toml~/.terrarium.toml
- Interactive password prompt (if
TERRARIUM_PASSWORDis 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
# Roll back: make version 3 current again (pushed as a new version, so
# history is never rewritten). Works on deleted states too.
terra remote state restore infra/prod --version 3
Locks
# List all active locks
terra remote lock list
Locks expire so a crashed CI job can't wedge a workspace. A lock older than TERRARIUM_LOCK_TTL seconds (default 7200, 2 hours; 0 disables expiry) is taken over by the next lock request — nothing is released in the background, so a long apply nobody is waiting on keeps its lock. Once taken over, the old holder can no longer push or unlock with its lock ID, and a lock.expire webhook fires. Age is measured by the server's clock, not the client's.
When a lock is refused, the response body is the current holder's lock info, so tofu/terraform show who holds it.
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. Every delivery is HMAC-signed; the signing secret is printed once, when the hook is created.
# Register a webhook (all events) — prints the ID and signing secret
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.expire
# List webhooks for a workspace (secrets are never listed)
terra remote webhook list infra/prod
# Replace a webhook's signing secret
terra remote webhook rotate-secret <id>
# Remove a webhook by ID
terra remote webhook remove <id>
Supported events: state.push, state.delete, state.archive, state.unarchive, state.restore, lock.acquire, lock.release, lock.expire
Payload:
{
"event": "state.push",
"workspace": "infra/prod",
"version": 4,
"user": "alice",
"timestamp": "2026-03-29T16:00:00Z"
}
Each request carries X-Terrarium-Signature: sha256=<hex>, an HMAC-SHA256 of "{X-Terrarium-Timestamp}.{body}". Transient failures (network errors, 5xx, 408, 429) are retried up to 4 attempts with 1 s → 2 s → 4 s backoff. By default webhooks can't target loopback, link-local or cloud-metadata addresses (TERRARIUM_WEBHOOK_NETWORKS).
See docs/webhooks.md for signature verification and network restrictions.
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 |
POST |
/restore/{name} |
Make ?version=N current again, as a new version (&ID= if locked) |
GET |
/versions/{name} |
List version numbers (?detail=true: with who pushed each, when, serial) |
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 (body with ID: only that lock; empty: force) |
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 |
POST |
/webhooks/id/{id}/rotate-secret |
Replace a webhook's signing secret |
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) |