🌱 A safe enclosure for your Terraform state 🦎
Find a file
JMARyA b9d3d93103
Some checks are pending
ci/woodpecker/push/container-manifest Pipeline is pending
ci/woodpecker/push/container/1 Pipeline is pending
ci/woodpecker/push/container/2 Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
Merge pull request 'chore(deps): update rust crate argh to v0.1.19' (#11) from renovate/argh-0.x-lockfile into main
Reviewed-on: #11
2026-04-04 05:43:32 +00:00
.woodpecker ci: disable binary cache (attic HTTP/2 errors) 2026-03-30 19:30:41 +02:00
src feat: color CLI output + group resources by type in get 2026-04-01 16:29:18 +02:00
.gitignore docs: full README rewrite + deployment fixes 2026-03-29 23:31:55 +02:00
Cargo.lock Merge pull request 'chore(deps): update rust crate argh to v0.1.19' (#11) from renovate/argh-0.x-lockfile into main 2026-04-04 05:43:32 +00:00
Cargo.toml chore(deps): update rust crate toml to 0.9 2026-04-04 03:02:34 +00:00
cog.toml chore: add cog 2025-12-29 11:53:40 +01:00
docker-compose.yml feat: configurable data directory via TERRARIUM_DATA env var 2026-03-30 18:58:30 +02:00
flake.lock fix: rust nightly 2025-12-29 12:34:34 +01:00
flake.nix docs: full README rewrite + deployment fixes 2026-03-29 23:31:55 +02:00
README.md feat: configurable data directory via TERRARIUM_DATA env var 2026-03-30 18:58:30 +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.

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

  • 🌱 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
  • 🗂 Path-prefix namespacing — infra/prod, apps/backend/staging
  • 🦎 Single static binary
  • 🧱 Cloud-agnostic
  • 🔐 HTTP Basic Auth

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/terrarium 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

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 tofu init to migrate.

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/terrarium user add alice
# Add a user (prompts for password if omitted)
terrarium user add <username> [password]

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

# Delete a user
terrarium user delete <username>

# List all users
terrarium user list

Client — Remote Operations

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

Configuration

Credentials and server URL are loaded in priority order:

  1. Environment variables: TERRARIUM_URL, TERRARIUM_USER, TERRARIUM_PASSWORD
  2. Config file (first found wins):
    • --config <path> flag
    • $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

Pass --config <path> to any remote command to use a specific config file.

State

# List all states
terrarium remote state list

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

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

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

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

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

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

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

# Archive a state (permanently read-only — cannot be undone)
terrarium remote state archive infra/prod

Locks

# List all active locks
terrarium remote lock list

Self-Service User

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

Webhooks

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

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

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

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

# Remove a webhook by ID
terrarium 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 HTTP Basic Auth.

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)
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