Deployment & infrastructure
One CGO-free binary, a scratch container, the canonical Helm chart, Kustomize, Compose (incl. a self-contained air-gap profile), and Terraform — all rendering the same single-node topology, with secrets as references only, plus a local shared-state on-ramp and the config-as-contract HTTP flow.
Last updated
On this page
TokenTriage self-hosts as one static file. The docker run / docker compose up paths are
for evaluation; for a real self-host it ships a full deployment surface under deploy/ where
the Helm chart is canonical — the Kustomize base, the Compose overlays, and the
Terraform module all render the same single-node topology.
The artifact
A single CGO-free binary (≈10.7 MB, measured on linux/amd64) in a FROM scratch image
(≈11 MB per arch, estimated) with only the binary, vendored license files, CA certs, and a
non-root user (UID/GID 65532, HOME=/data). The pricing table and the dashboard SPA are
compiled in via go:embed, so there are no data files to ship.
Kubernetes (Helm — the canonical artifact)
One StatefulSet at every topology (single-node is replicas: 1), one image, zero chart
dependencies, and one per-pod PVC for the canonical ledger. You supply a config body and a
reference to a Secret holding your provider keys — the chart never holds a secret value:
helm install tt deploy/helm/tokentriage \
--namespace tokentriage --create-namespace \
-f my-values.yaml
helm install prints post-install NOTES that resolve, for your release, the port-forward
and the credential one-liner. The per-pod volumeClaimTemplates are ReadWriteOnce block
storage only — never ReadWriteMany (NFS/EFS are forbidden, because the ledger is a
SQLite-indexed append-only file per pod).
Kustomize (GitOps)
When you can’t run Helm at apply time, deploy/k8s/base is a Kustomize base rendered from
the chart (a CI drift gate keeps the two byte-identical), with airgap-mirror and
prod-pinned overlays:
kubectl apply -k deploy/k8s/base
Docker Compose (single host / edge)
Layer overlays onto the frozen root docker-compose.yml. The air-gap profile is fully
self-contained — an internal-only network with no gateway plus an in-network mock upstream,
so egress is impossible by construction:
docker compose -f docker-compose.yml -f deploy/compose/compose.airgap.yaml up
Production (compose.prod.yaml) and hardened (compose.hardened.yaml) overlays layer on the
same way.
Terraform (single VM on AWS)
A single-VM example (EC2 + a dedicated EBS /data + a TLS-terminated load balancer to the
data plane; the admin plane stays SG-internal, reached via SSM Session Manager) lives at
deploy/terraform/aws/examples/single-vm, built on the tokentriage module.
Trial multi-replica shared state locally
Every topology above is single-node by default: each pod or container keeps its own canonical ledger on its own volume, and the fleet view is a read-time merge. When you want to exercise the external-state seam — the Redis/Postgres path that a multi-replica fleet would coordinate through — there is a laptop-grade on-ramp and a cloud PREVIEW overlay.
Locally: compose.backends.yaml
deploy/compose/compose.backends.yaml ships dev-grade single-container backends —
redis:7-alpine and postgres:16-alpine, digest-pinned so they mirror into an air-gap. Each
backend is behind its own Compose profile (off by default), so you turn on exactly the one
you’re trialling:
docker compose -f docker-compose.yml \
-f deploy/compose/compose.prod.yaml \
-f deploy/compose/compose.backends.yaml \
--profile redis up -d
docker compose -f docker-compose.yml \
-f deploy/compose/compose.prod.yaml \
-f deploy/compose/compose.backends.yaml \
--profile postgres up -d
Compose provides the container; it never wires TokenTriage to it. Enabling external state
is your typed consent, in your own config — the same external: "allow" string the binary
demands. Compose never writes external: allow; you do. To opt in, mount a config that adds
the seam and supply the credential by name (via your env_file / host env — never a value
in the file):
shared_state:
redis:
external: "allow" # you type this; Compose never does
addr: "redis:6379"
password_env: TT_SHAREDSTATE_REDIS_PASSWORD
postgres:
external: "allow"
dsn_env: TT_SHAREDSTATE_POSTGRES_DSN
For a laptop trial the dev containers are passwordless, so TT_SHAREDSTATE_REDIS_PASSWORD may
be empty and the DSN needs no password
(postgres://tokentriage@postgres:5432/tokentriage?sslmode=disable). The proxy reaches each
backend in-network by service name (redis:6379 / postgres:5432) regardless of the
loopback host publish.
On AWS: the external-state overlay (PREVIEW)
deploy/terraform/aws/examples/external-state expresses the same seam on cloud primitives. It
composes two vendor modules — terraform-aws-modules/rds/aws (RDS Postgres 16) and
terraform-aws-modules/elasticache/aws (ElastiCache Redis 7.1, in-transit encryption on)
— provisions the endpoints, then feeds those endpoints and operator-created secret ARNs
back into the core tokentriage module under the canonical env names. The rendered config
carries external: "allow" on both backends:
shared_state:
redis:
external: "allow"
addr: "${module.redis.replication_group_primary_endpoint_address}:6379"
tls: true
password_env: TT_SHAREDSTATE_REDIS_PASSWORD
postgres:
external: "allow"
dsn_env: TT_SHAREDSTATE_POSTGRES_DSN
Dependency inversion holds end to end: the Redis endpoint is non-secret and wired into the
config; the Redis auth token and the whole Postgres DSN (host + password) are each an
ARN resolved on the instance to TT_SHAREDSTATE_REDIS_PASSWORD /
TT_SHAREDSTATE_POSTGRES_DSN. No plaintext secret is passed to the module or written to
Terraform state.
Config as a contract (over HTTP)
Config isn’t only a file you mount — the management API on the admin plane exposes the
same config as a contract you can validate, diff, and hot-apply without a restart, each step
its own endpoint under /tt/api/v1/. The pipeline is validate → diff → apply → reload →
audit, and a change that can’t be hot-applied is refused before anything is written. Point
TOKENTRIAGE_ADMIN_URL at the admin listener and drive it with an operator token
(config:apply scope; config:read for the read-only checks).
1. Validate a candidate — stateless, CI-callable. POST /config/validate runs the strict
loader over the bytes you send (an unknown key is an error, never a silent ignore) and returns
errors and warnings verbatim. It reads nothing and writes nothing, so it works on a server with
no config of its own:
curl -sS -X POST "$TOKENTRIAGE_ADMIN_URL/tt/api/v1/config/validate" \
-H "Authorization: Bearer $TOKENTRIAGE_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"yaml": "version: 1\nmode: ledger\n"}'
An invalid draft is a 200 carrying {"valid": false, "errors": [...]} — “is this valid?”
has a true answer in both directions. Validate is necessary but not sufficient: three check
classes (descriptor-aware provider checks, the routing-policy matrix, the governance/limit
registries) run only at apply, so a green result here means “won’t fail for a structural
reason”, not “will apply”.
2. Diff against what’s running. POST /config/diff returns every changed path with its
old/new rendering and whether the engine can hot-apply it. The restart_required list is the
subset that cannot be hot-applied:
curl -sS -X POST "$TOKENTRIAGE_ADMIN_URL/tt/api/v1/config/diff" \
-H "Authorization: Bearer $TOKENTRIAGE_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d @candidate.json
3. Read the running config + generation. GET /config/running returns the config file’s
bytes verbatim (comments included — not a re-serialization), its path, the loader’s live
warnings, and reload_generation, the compare-and-swap token:
curl -sS "$TOKENTRIAGE_ADMIN_URL/tt/api/v1/config/running" \
-H "Authorization: Bearer $TOKENTRIAGE_ADMIN_TOKEN"
No secret can appear in that body: credential fields are *_env references holding an
env-var name, and the loader rejects an inline value — so a config carrying a secret
never became the running config in the first place.
4. Apply. POST /config/apply takes a JSON envelope (the document as a string) and
requires expected_generation as a compare-and-swap read from GET /config/running; a lost
race writes nothing and answers 409 generation_conflict. Validation and the restart-required
refusal happen before any write, so a rejected apply leaves the file byte-unchanged and
traffic untouched. Past that point the file is written first (backup → temp-in-same-dir →
rename, crash-atomic) and only then is the engine reloaded. Use dry_run: true to preview the
exact changes, restart_required verdict, and badges without writing or auditing.
Declarative twin. If your config is under version control, PUT /config takes the file as
raw application/yaml with an If-Match: "<generation>" header, so an operator’s comments and
key order survive the round trip (a 412 precondition_failed signals the same lost race). Its
?dry_run=true spelling previews without writing.
Move the mode ladder. POST /control/mode compiles a mode change through the same atomic
pipeline, with the promote-to-route shadow-evidence gate asked (unsatisfied and un-acked →
403 promote_gate_refused with the gate’s verbatim reasons; demotion never checks).
What makes these honest
- Secrets are references. Config carries env-var names, never values, and the chart
renders zero
Secretobjects — there’s nothing to leak. See Security & secrets. - The air-gap can’t be un-gated by a deployment layer. External state (Redis / Postgres
/ an L3 cache) is refused unless the config carries the binary’s own consent string
external: "allow"— and no deployment layer types it for you. - Probes report process-up only.
GET /healthreturns200with a trivialOKbody and nothing more; provider reachability is reported separately at/tt/api/status, never fabricated. - Each pod keeps its own canonical ledger on its own PVC; the fleet view is a read-time merge (see Multi-replica & scale-out).
Operator docs live in deploy/docs/ (including INSTALL, AUTH, HARDENING, BACKUP_RESTORE,
AIRGAP, UPGRADES, FEDERATION, SUPPLY_CHAIN, CONTRACTS, TRUST_LADDER_K8S).