# Deployment topologies

Six ways to run the same thing. What each shape is for, what it hands you by default, and which one to pick.

Source: https://tokentriage.com/docs/operate/deployment

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

TokenTriage deploys as one static binary with one durable directory beside it. Everything below
is a different wrapper around that fact, and none of them changes the topology: one process, one
data volume, two listeners.

That is worth saying plainly because it sets expectations. There is no control plane to install,
no database to provision, no queue, no sidecar. Choosing a deployment shape is choosing how you
would rather manage a process, not choosing an architecture.

## Choose one

| Shape | Reach for it when | What you manage |
|---|---|---|
| the binary | a host you already configure, or a laptop | a service unit and a directory |
| a container | an existing container host or PaaS | one image, one volume, two ports |
| Docker Compose | a single host you want durable and locked down | a compose file and an env file |
| **Helm** | Kubernetes — **the canonical artifact** | a values file |
| Kustomize | Kubernetes under GitOps that cannot run Helm at apply | an overlay |
| Terraform | a single VM on AWS with the storage and load balancer alongside | a module invocation |

The Helm chart is canonical in a specific sense: the Kustomize base is rendered *from* it, with
a CI gate keeping the two identical, and the Compose overlays and the Terraform module express
the same single-node shape through their own primitives. Pick the one that fits how you already
run things.

## What every shape gives you

Two listeners, always, and they are not interchangeable.

The **data plane** takes proxy traffic on `8787`. It is what your applications point at, and it
is the one you may expose once TLS terminates in front of it.

The **admin plane** serves `9090`: the dashboard, the admin API, `/metrics` and `/health`. It
carries ledger metadata and the config surface, and it is not for the public internet. On
Kubernetes its Service type is a constant the chart will not let you override; on Compose it
binds loopback until you widen it yourself.

One **data directory** holds everything durable: the ledger, the governance store, the auth
stores and the derived index. `/data` in the images and on Kubernetes, `~/.tokentriage` for a
host binary.

<Callout type="warning">
That directory must live on ReadWriteOnce block storage. Rotation unlinks the active ledger
file, so on a shared or ReadWriteMany filesystem a second writer can keep appending to an inode
nobody will read, losing decisions with no error. [Back up and
restore](/docs/operate/backup-restore) covers the rule and why it is enforced rather than
advised.
</Callout>

## Kubernetes, with Helm

One StatefulSet at every topology, where a single node is `replicas: 1`, with one image, zero
chart dependencies, and one volume claim per pod.

```console
$ helm install tt deploy/helm/tokentriage \
    --namespace tokentriage --create-namespace \
    -f my-values.yaml
```

The post-install notes resolve, for your release, how to reach both planes, the credential
one-liner, and the storage disclosure. Reprint them any time with `helm get notes tt -n
tokentriage`.

You supply two things: the config body under `config:`, passed to the binary verbatim, and a
*reference* to a `Secret` holding your provider keys. The chart renders no `Secret` of its own.

```yaml
config:
  mode: ledger
  upstreams:
    anthropic:
      base_url: https://api.anthropic.com
      dialect: anthropic
      api_key_env: ANTHROPIC_API_KEY
  ledger:
    decision_log:
      path: /data/.tokentriage/decisions.jsonl

providerSecrets:
  existingSecret: tt-provider-keys
```

Confirm the install with the bundled test, which asserts both halves of the
secured-by-default state without needing a credential:

```console
$ helm test tt -n tokentriage
TEST SUITE:     tt-tokentriage-test-connection
Phase:          Succeeded
```

<Callout type="key">
"Ready" means the process is up. The probes target `/health`, which returns `200` and asserts
nothing about provider reachability, storage or the admin plane. That truth is reported
separately at `/tt/api/status`, and is not fabricated by a probe.
</Callout>

## Kubernetes, without Helm

`deploy/k8s/base` is a Kustomize base rendered from the chart, with `airgap-mirror` and
`prod-pinned` overlays, for shops that cannot run Helm at apply time:

```console
$ kubectl apply -k deploy/k8s/base
```

The overlay image digests are invalid all-zero placeholders. They fail to pull
until you replace them from the release's image list. That is a loud failure in preference to a
silently mutable tag.

## Docker Compose

Overlays layer onto the root compose file, and they compose with each other. Four are worth
knowing:

| Overlay | What it adds |
|---|---|
| `compose.prod.yaml` | a pinned image, a restart policy, resource limits, the `env_file` contract |
| `compose.hardened.yaml` | read-only rootfs, all capabilities dropped, no-new-privileges, a tmpfs `/tmp` |
| `compose.airgap.yaml` | a gateway-less internal network and an in-network mock upstream |
| `compose.backends.yaml` | development-grade Redis and Postgres, each behind its own profile |

Hardened production is the two stacked:

```console
$ docker compose -f docker-compose.yml -f deploy/compose/compose.prod.yaml \
    -f deploy/compose/compose.hardened.yaml up -d
```

Each of the first three carries a test profile that gates on a real exit code rather than on
`up` succeeding, because a proxy killed by a signal exits `0` and that must not read as a
pass. All three were run for this page; the air-gap gate additionally asserts a costed row comes
back with no route off the host.

<Callout type="warning">
`compose.backends.yaml` ships single, unreplicated, passwordless containers bound to loopback.
They exist so you can exercise the shared-state tier on a laptop. In production, point at a
managed Redis and Postgres. Note also that Compose provides the container and never wires
TokenTriage to it. Enabling external state is a consent string you type in your own config.
</Callout>

## Terraform on AWS

A single-VM example under `deploy/terraform/aws/examples/single-vm` builds on the `tokentriage`
module: an EC2 instance, a dedicated EBS volume at `/data`, and a TLS-terminated load balancer
in front of the data plane. The admin plane stays inside the security group and is reached
through Session Manager.

Credentials are ARNs resolved on the instance into the canonical environment variable names, so
no plaintext is passed to the module or written to Terraform state. A separate
external-state example expresses the shared-state tier on RDS and ElastiCache; it is a preview
root that CI validates and does not apply.

<Details summary="How the shapes differ on the things that bite">

| | binary | Compose | Kubernetes |
|---|---|---|---|
| config file | writable | writable if you mount it so | read-only projection |
| change config | edit, or the admin API | edit and restart | edit values, `helm upgrade` |
| dashboard default | off unless you enable it | off in the baked config | on, secured |
| admin bind | `127.0.0.1` | loopback publish | `0.0.0.0`, ClusterIP Service |
| credential bootstrap | run the CLI | `docker compose exec` | `kubectl exec` |
| upgrade | replace the binary | pull and recreate | `helm upgrade`, rolls the pod |
| roll back | keep the old binary | keep the old tag | `helm rollback` |

The read-only config on Kubernetes is the difference that surprises people most:
[configuration in production](/docs/operate/configuration) covers what still works there and
what the dashboard refuses.

</Details>

## What runs the same everywhere

Three behaviours are properties of the binary, not of the wrapper, so they hold in all six
shapes.

**Each instance keeps its own ledger.** There is no shared ledger file at any topology. A fleet
view is a read-time merge over per-instance ledgers, typed by how much of the fleet it covered.

**Nothing dials out unless you consent.** External state, an L3 cache, an external embeddings
API, OTLP export and the guardrail judge are each refused unless their config block carries
`external: "allow"`, a string no deployment layer types for you.

**Prices come from a compiled-in table.** There is no pricing service to reach, which is why an
air-gapped install still produces costed rows.

<NextSteps items={[
  {title: "Take it to production", href: "/docs/operate/production-checklist", description: "The decisions to settle once you have picked a shape, in the order they stop being reversible."},
  {title: "Configuration in production", href: "/docs/operate/configuration", description: "Validate the config a chart will actually mount, and read the banner that says what started."},
  {title: "Production hardening", href: "/docs/operate/hardening", description: "The rendered defaults, the exposure decisions, and what the presets do not deny."},
  {title: "Multi-replica & scale-out", href: "/docs/operate/scale-out", description: "What changes when one instance becomes several, and what stays per-instance."}
]} />
