A lightweight, self-hostable implementation of the Terramate Cloud HTTP API, with a built-in web dashboard for querying infrastructure state.
Designed to be pointed at directly by the Terramate CLI — no network access to cloud.terramate.io needed. All state is persisted in PostgreSQL.
API server
- Full coverage of every endpoint consumed by the Terramate CLI
- Static API key authentication (SHA-256 hashed, stored in the database)
- HTTP Basic auth compatible — matches exactly what the CLI sends (
Authorization: Basic base64(token:)) - Single-organisation model — no multi-tenancy overhead
- Schema applied automatically on startup (
IF NOT EXISTS— safe to restart) - Native Postgres types via pgx v5 —
TEXT[]arrays,JSONB,TIMESTAMPTZ - Clean routing via Gin — no manual path-string parsing
- Correct state machine for composite
stack.status— derived from both drift and deployment outcomes per the real TMC transition table - Drift status propagation: every
PATCH /v2/drifts/:org/:uuidimmediately updates the parent stack'sdrift_statusand recomputesstatus - Resources materialised from
changeset_jsonat PATCH time — parsed from thetfjson.Planformat produced byterraform show -json <planfile>
Web dashboard (/ui)
- Stacks view with real-time filtering and column sorting
- Resources view with filtering, column sorting, and account-ID dropdown
- HTMX-powered — no JavaScript framework, no build step
- Server-side rendered via Go templates embedded in the binary
- Pagination on both views (50 rows per page)
| Tool | Version |
|---|---|
| Go | 1.22+ |
| PostgreSQL | 14+ |
createuser -P tmcapi # choose a password
createdb -O tmcapi tmcapiOr with psql as a superuser:
CREATE USER tmcapi WITH PASSWORD 'tmcapi';
CREATE DATABASE tmcapi OWNER tmcapi;git clone https://github.com/your-org/tmc-server
cd tmc-server
make build # produces ./bin/tmc-serverOr directly with go build:
go build -mod=vendor -o tmc-server ./cmd/serverTMC_API_KEYS="your-secret-key" \
TMC_ORG_NAME="acme" \
TMC_DATABASE_URL="postgres://tmcapi:tmcapi@localhost/tmcapi?sslmode=disable" \
./tmc-serverStartup output:
━━━ Terramate Cloud API ━━━
Listen : :8080
Database : postgres://tmcapi:***@localhost/tmcapi?sslmode=disable
Org : acme (6187958c-f4ef-47ac-b575-e7c26b8e86aa)
User : admin@example.com
API keys : 1 configured
Dashboard : http://localhost:8080/ui
To use with the Terramate CLI:
export TMC_API_URL=http://localhost:8080
export TMC_TOKEN=your-secret-key
export TM_CLOUD_ORGANIZATION=acme
No fork, no patch, no TLS tricks. The Terramate CLI has built-in env vars for this:
export TMC_API_URL=http://localhost:8080 # overrides cloud.terramate.io
export TMC_TOKEN=your-secret-key # must match TMC_API_KEYS
export TM_CLOUD_ORGANIZATION=acme # must match TMC_ORG_NAMEThen use the CLI normally:
terramate cloud info
terramate run --sync-drift-status -- terraform plan
terramate run --sync-drift-status --terraform-plan-file=tfplan -- terraform plan -out=tfplan
terramate run --sync-deployment -- terraform apply
TMC_API_URLis read inui/tui/cliauth/cli_cloud.goand wired tocloud.WithBaseURL(...), completely overriding the region-based URL.TMC_API_HOSTalso works if you prefer to supply just the hostname (the CLI prependshttps://automatically).The CLI sends credentials as HTTP Basic auth —
Authorization: Basic base64(token:). The server accepts both Basic and Bearer formats.
Open http://localhost:8080/ui in a browser after starting the server. No authentication is required for the dashboard — it is read-only.
Shows all synced stacks with their current health.
Summary bar: Total · OK · Drifted · Failed · Resources tracked
Columns:
| Column | Description |
|---|---|
| Status | Composite health: ok (green) · drifted (amber) · failed (red) |
| Title | Stack display name with path below in monospace |
| Resources | Count of tracked Terraform resources; links to the filtered Resources view |
| Repository | org/repo short form |
| Updated | Time since last sync |
Filters: free-text search (name, path, repository) · status select · repository dropdown
Sorting: click any column header to sort ascending; click again to reverse. Sort state is preserved through pagination.
The composite
statuscolumn is the worst of drift and deployment state — it matches the--statusflag interramate list --status=drifted.
Shows every Terraform resource extracted from plan files, across all stacks.
Summary bar: Total · OK (no-op) · Drifted (any planned change) · Stacks
Columns:
| Column | Description |
|---|---|
| Status | ✓ ok (green) for no-op; △ drifted (amber) with the specific action below for anything else |
| Resources | Resource type (e.g. aws_lb) on line one; the actual cloud identifier (ARN / self_link / resource ID) on line two, falling back to the Terraform address if no cloud ID was captured |
| Account | Cloud account ID (AWS account ID from ARN, GCP project, Azure subscription) |
| Name | Resource name segment |
| Stack | Stack display name; links to filtered Stacks view |
| Provider | Short provider name (aws, google, azurerm, …) |
| Repository | org/repo short form |
| Updated | Time since last plan sync |
Filters: free-text search · status select (OK / Drifted variants) · provider dropdown · account ID dropdown (values from DB) · stack name/path
Sorting: all columns except Repository are sortable.
Resources only appear when
--terraform-plan-fileis passed. Without it,changeset_jsonis empty and no resources are extracted.
# Generate and upload a plan file — resources appear in /ui/resources
terramate run \
--sync-drift-status \
--terraform-plan-file=tfplan \
-- terraform plan -out=tfplanThe CLI runs terraform show -json tfplan locally, sanitises the output, and sends it as changeset_json in the PATCH /v2/drifts/:org/:uuid call. The server parses resource_changes from the plan JSON and upserts rows into stack_resources.
Provider-specific account ID extraction:
- AWS — extracted from the ARN (
arn:aws:...:ACCOUNT_ID:...) orowner_idattribute - GCP — from the
projectattribute - Azure — from the resource ID (
/subscriptions/SUB_ID/...) orsubscription_id - Azure AD — from
tenant_id
Provider-specific cloud ID extraction (shown as line 2 in the Resources column):
- AWS — the full ARN (
arn, orrole_arn/policy_arn/topic_arn/instance_profile_arn), falling back toid - GCP —
self_link, falling back toid - Azure / Azure AD —
id(already the full ARM resource path)
If none of these attributes are present in the plan (e.g. the resource doesn't exist yet, or its type isn't covered above), the Terraform address is shown instead.
All configuration is via environment variables.
| Variable | Default | Description |
|---|---|---|
TMC_ADDR |
:8080 |
HTTP listen address |
TMC_DATABASE_URL |
postgres://tmcapi:tmcapi@localhost/tmcapi?sslmode=disable |
PostgreSQL DSN |
TMC_API_KEYS |
dev-api-key |
Comma-separated raw API keys |
TMC_ORG_NAME |
my-org |
Organisation slug — must match cloud.organization in terramate.tm.hcl |
TMC_ORG_DISPLAY_NAME |
My Org |
Human-readable org name |
TMC_ORG_DOMAIN |
`` | Optional org domain |
TMC_USER_EMAIL |
admin@example.com |
Seed user email (bound to the first API key) |
TMC_USER_NAME |
Admin |
Seed user display name |
TMC_API_KEYS is comma-separated. Each key gets its own row in api_keys. The first key is bound to the seed user.
TMC_API_KEYS="ci-key,developer-key,readonly-key"UPDATE api_keys SET revoked_at = NOW() WHERE description = 'ci-key';docker build -t tmc-server .
docker run -p 8080:8080 \
-e TMC_DATABASE_URL="postgres://tmcapi:tmcapi@host.docker.internal/tmcapi?sslmode=disable" \
-e TMC_API_KEYS="your-secret-key" \
-e TMC_ORG_NAME="acme" \
tmc-serverservices:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: tmcapi
POSTGRES_PASSWORD: tmcapi
POSTGRES_DB: tmcapi
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tmcapi"]
interval: 5s
retries: 5
tmc-server:
build: .
ports:
- "8080:8080"
environment:
TMC_DATABASE_URL: "postgres://tmcapi:tmcapi@postgres/tmcapi?sslmode=disable"
TMC_API_KEYS: "your-secret-key"
TMC_ORG_NAME: "acme"
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:All endpoints except /.well-known/cli.json, /health, and /ui/* require Authorization: Bearer <key> or Authorization: Basic base64(key:).
| Method | Path | Description |
|---|---|---|
GET |
/.well-known/cli.json |
CLI version compatibility check (public) |
GET |
/health |
Liveness probe — checks DB connection (public) |
GET |
/v1/users |
Authenticated user |
GET |
/v1/memberships |
Org memberships |
GET |
/v1/organizations/name/:name |
SSO org lookup by slug |
| Method | Path | Description |
|---|---|---|
PUT |
/v1/stacks/:org |
Upsert a stack (idempotent on meta_id + repository + target) |
GET |
/v1/stacks/:org |
List stacks — filterable by repository, target, meta_id, status, drift_status, deployment_status |
GET |
/v1/stacks/:org/:stackid |
Single stack by numeric ID |
GET |
/v1/stacks/:org/:stackid/drifts |
Drift history for a stack |
GET |
/v1/stacks/:org/:stackid/resources |
Terraform resources for a stack |
| Method | Path | Description |
|---|---|---|
POST |
/v1/deployments/:org/:uuid/stacks |
Create deployment + register stacks |
PATCH |
/v1/deployments/:org/:uuid/stacks |
Update deployment stack statuses + optional changeset |
POST |
/v1/stacks/:org/:stackid/deployments/:uuid/logs |
Append command logs |
GET |
/v1/stacks/:org/:stackid/deployments/:uuid/logs |
Read command logs |
| Method | Path | Description |
|---|---|---|
POST |
/v2/drifts/:org |
Start a drift check run → { uuid } |
PATCH |
/v2/drifts/:org/:uuid |
Update drift status, changeset, and materialise resources |
GET |
/v2/drifts/:org |
List drifts — filterable by status, repository, target |
GET |
/v2/drifts/:org/:uuid |
Single drift with joined stack fields |
POST |
/v2/drifts/:org/:uuid/logs |
Append CommandLog entries |
GET |
/v2/drifts/:org/:uuid/logs |
Read command logs |
GET |
/v1/drifts/:org/:stackid/:driftid |
Legacy v1 drift detail |
| Method | Path | Description |
|---|---|---|
POST |
/v1/previews/:org |
Create a preview run |
GET |
/v1/previews/:org/:uuid |
Get preview with stack previews |
PATCH |
/v1/stack_previews/:org/:id |
Update stack preview status + changeset |
POST |
/v1/stack_previews/:org/:id/logs |
Append stack preview logs |
| Method | Path | Description |
|---|---|---|
POST |
/v1/store/:org/outputs |
Create or update a named output |
GET |
/v1/store/:org/outputs |
Lookup by key (?name=, ?repository=, ?stack_meta_id=, ?target=) |
GET |
/v1/store/:org/outputs/:id |
Get by UUID |
PUT |
/v1/store/:org/outputs/:id/value |
Update value |
DELETE |
/v1/store/:org/outputs/:id |
Delete |
| Method | Path | Description |
|---|---|---|
GET |
/v1/review_requests/:org |
List review requests |
{
"stack": {
"repository": "github.com/acme/infra",
"path": "/stacks/vpc",
"meta_id": "vpc-stack",
"meta_name": "VPC",
"default_branch": "main",
"target": ""
},
"command": ["terraform", "plan", "-detailed-exitcode"],
"started_at": "2024-01-01T12:00:00Z"
}Response 201: { "uuid": "26832be6-..." }
{
"status": "drifted",
"changeset": {
"provisioner": "terraform",
"changeset_ascii": "~ aws_instance.web: ami changed",
"changeset_json": "{\"resource_changes\": [...]}"
},
"updated_at": "2024-01-01T12:05:00Z"
}Note: the request field is
changeset(notdrift_details) and the timestamp isupdated_at(notfinished_at). The response shape usesdrift_details— the request and response field names differ.
Valid status values: running · ok · drifted · failed · unknown
Response 204.
The CLI sends logs as a bare JSON array, not wrapped in an object:
[
{ "log_line": 1, "channel": "stdout", "message": "Refreshing state...", "timestamp": "2024-01-01T12:00:01Z" },
{ "log_line": 2, "channel": "stderr", "message": "Warning: deprecated" }
]The server also accepts the wrapped form { "logs": [...] } for direct API use.
The schema is embedded into the binary and applied automatically on startup. All DDL is idempotent.
api_keys – SHA-256 hashed API keys with optional revocation
org – single-row organisation record
users – users, each bound to an API key
stacks – Terramate stacks; composite status derived from drift + deployment
deployments – deployment runs identified by CLI-provided UUID
deployment_stacks – per-stack rows within a deployment
drifts – drift check runs with JSONB changeset details
logs – polymorphic log table (entity_kind + entity_id)
previews – preview runs
stack_previews – per-stack rows within a preview
stack_resources – Terraform resources parsed from changeset_json at PATCH time
store_outputs – key-value store for stack outputs
review_requests – GitHub/GitLab PR/MR associations (upserted on deployment/preview)
tmc-server/
├── cmd/server/
│ └── main.go – startup, seeding, graceful shutdown
├── internal/
│ ├── api/
│ │ ├── handlers.go – one Gin handler per endpoint
│ │ ├── router.go – route table, auth middleware (Basic + Bearer)
│ │ └── integration_test.go – httptest end-to-end tests (build tag: integration)
│ ├── config/
│ │ ├── config.go – env-var config with defaults
│ │ └── config_test.go
│ ├── db/
│ │ ├── db.go – pgxpool wrapper, all SQL queries
│ │ ├── schema.sql – embedded schema (go:embed)
│ │ ├── db_pure_test.go – unit tests for dependency-free helpers
│ │ └── integration_test.go – live-Postgres tests (build tag: integration)
│ ├── planparse/
│ │ ├── planparse.go – tfjson.Plan parser → stack_resources rows
│ │ └── planparse_test.go
│ └── ui/
│ ├── db.go – read-only queries for the dashboard
│ ├── handler.go – Gin handlers + template functions
│ ├── handler_test.go – pure function + funcMap unit tests
│ ├── templates_test.go – template rendering tests
│ └── templates/
│ ├── base.html – nav, CSS design tokens, HTMX/Tailwind CDN
│ ├── stacks.html – stacks full page
│ ├── stacks_rows.html – HTMX partial (tbody + OOB thead)
│ ├── resources.html – resources full page
│ └── resources_rows.html – HTMX partial (tbody + OOB thead)
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── go.mod
└── README.md
| Target | Description |
|---|---|
make build |
Compile the server binary into ./bin/tmc-server |
make run |
Build and run with dev defaults (key dev-api-key, org my-org) |
make dev-db |
Create the local tmcapi and tmcapi_test databases (idempotent) |
make dev-db-reset |
Drop and recreate both databases — destroys all data |
make test |
Run unit tests — fast, no database required |
make test-integration |
Run integration tests against tmcapi_test |
make test-all |
Unit tests followed by integration tests |
make coverage |
Generate an HTML coverage report at coverage.html |
make vet |
go vet ./... |
make lint |
go vet + gofmt check |
make fmt |
Format all Go source files in place |
make docker-build |
Build the production Docker image |
make docker-run |
Start Postgres + server via docker-compose |
make clean |
Remove build artifacts |
make help |
List all targets with descriptions |
make dev-db # one-time setup
make run
# → http://localhost:8080, key = "dev-api-key", org = "my-org"
# → http://localhost:8080/ui (dashboard)Tests are split into two tiers:
Unit tests (make test) run in milliseconds with no external dependencies. They cover:
internal/planparse— thetfjson.Planparser: action collapsing, AWS/GCP/Azure/Azure AD account ID extraction, check-status correlation, deduplication betweenresource_changesandresource_driftinternal/config— environment variable parsing and defaultsinternal/db— pure helpers:HashKey,buildWhere(SQL WHERE clause construction),RawJSONmarshalling,nullInt64internal/ui— template functions (statusClass,actionClass,repoShort,timeAgo, pagination math, sort-direction toggling) and full template rendering viahttptest-freehtml/templateexecution against the embedded templates
Integration tests (make test-integration, build tag integration) require a live Postgres database and are excluded from go test ./... by default. They cover:
internal/db— every exported method against a real database: upsert idempotency, the drift/deployment → stack composite status state machine, per-stack log isolation, resource upsert-in-place semanticsinternal/api— full HTTP round trips through the real Gin router viahttptest.NewServer: auth (Basic and Bearer), the complete drift lifecycle (create → patch → get), thetarget=""/target="default"normalisation that fixed thecloud drift showbug, bare-array log payloads, and review request materialisation
Run everything:
make dev-db # creates tmcapi_test if it doesn't exist
make test-allRun integration tests against a different database:
TEST_DATABASE_URL="postgres://user:pass@host/scratch_db?sslmode=disable" make test-integrationIntegration tests
TRUNCATEevery table before each test (RESTART IDENTITY CASCADE). Never pointTMC_TEST_DATABASE_URLat a database containing data you care about.
-- All API keys
SELECT id, description, created_at, revoked_at FROM api_keys;
-- Stack health overview
SELECT meta_id, path, status, drift_status, deployment_status, updated_at FROM stacks ORDER BY updated_at DESC;
-- Recent drifts with changeset
SELECT d.uuid, d.status, s.path, d.changeset_details->>'changeset_ascii' AS diff, d.updated_at
FROM drifts d JOIN stacks s ON s.id = d.stack_id
ORDER BY d.updated_at DESC LIMIT 20;
-- Resources by action (drifted = anything except no-op)
SELECT address, resource_type, action, account_id, s.path
FROM stack_resources r JOIN stacks s ON s.id = r.stack_id
WHERE action != 'no-op'
ORDER BY r.updated_at DESC;
-- Revoke a key
UPDATE api_keys SET revoked_at = NOW() WHERE description = 'ci-key';
-- Add a key at runtime (SHA-256 of "new-key")
INSERT INTO api_keys (key_hash, description)
VALUES (encode(sha256('new-key'::bytea), 'hex'), 'new-key');MIT — see LICENSE for the full text.