Skip to content

Deployment

Rakkr ships a deployable controller stack:

  • Dockerfile.api — the Hono controller API (also runs Drizzle migrations).
  • Dockerfile.web — the React console served by nginx.
  • docker-compose.yml — a local controller stack with Postgres.
  • deploy/ansible — the optional recorder-node lifecycle runner.
  • deploy/helm/rakkr-controller — the Kubernetes chart.

All controller environment variables are documented in the configuration reference.

Docker Compose

Terminal window
docker compose up --build

The stack starts these services:

ServicePurposePort
postgresController database (postgres:17-alpine).5432
controller-migrateOne-shot Drizzle migration runner; runs before the API starts.
controller-apiController API + /metrics.8787
controller-webReact console served by nginx, proxying the API.5173
ansible-runnerOptional node-lifecycle runner.8790
recorder-test-rigDisposable Debian SSH target for lifecycle smokes.2222

The web container proxies /api, /healthz, and /metrics to the API, so browser traffic uses a single origin. Migrations run once in controller-migrate (the API waits for it to complete) — both use the API image, which carries the Drizzle tooling.

Default local sign-in is admin@rakkr.local / rakkr-local-dev-password. Override before starting:

Terminal window
$env:RAKKR_LOCAL_ADMIN_PASSWORD = "replace-me"
$env:RAKKR_WEB_ORIGIN = "http://localhost:5173"
docker compose up --build

The stack runs NODE_ENV=production, so the encryption crypto refuses the insecure dev fallback: RAKKR_SECRET_KEY (upload-destination secrets) and RAKKR_NODE_SSH_MASTER_KEY (node SSH private keys) must be set, or every secret write throws at runtime. Compose supplies length-valid local defaults so the stack works out of the box — set your own strong values via .env for any real deployment, since the defaults are published in this repo:

Terminal window
$env:RAKKR_SECRET_KEY = "replace-with-a-strong-32+-char-secret"
$env:RAKKR_NODE_SSH_MASTER_KEY = "replace-with-a-distinct-strong-secret"
docker compose up --build

Stop, or remove volumes:

Terminal window
docker compose down
docker compose down --volumes

The images

Dockerfile.api — multi-stage on node:26-alpine (pnpm 11.20.0): install deps, build @rakkr/shared + @rakkr/db + @rakkr/api, then a runtime stage that installs ffmpeg, copies the build plus the committed packages/db/drizzle migrations, runs as non-root, exposes 8787, and starts the API. Because it includes migration tooling, the same image runs migrations.

Dockerfile.web — multi-stage build of @rakkr/shared + @rakkr/web (Vite), then an nginx:1.31-alpine runtime that serves the SPA and env-substitutes deploy/nginx/default.conf.template. That template serves the SPA with try_files … /index.html and reverse-proxies /api/, /healthz, and /metrics to ${RAKKR_API_UPSTREAM} (default http://controller-api:8787).

Optional Ansible node lifecycle

The controller can request allowlisted node-lifecycle actions through the Dockerized Ansible runner. Compose wires controller-api to it with RAKKR_ANSIBLE_RUNNER_URL=http://ansible-runner:8790; the runner exposes POST /runs internally and a health endpoint at http://localhost:8790/healthz.

Supported actions: install_dependencies, update_binary, restart_service, rotate_trust, smoke_check. For local smoke validation, Compose also starts recorder-test-rig and maps the seeded node_x32_test record to it without touching real metadata.

For a physical rig, mount your SSH directory into the runner and provide per-node targets:

Terminal window
$env:RAKKR_ANSIBLE_SSH_DIR = "$env:USERPROFILE\.ssh"
$env:RAKKR_ANSIBLE_TARGETS = '{"node_x32_test":{"host":"172.22.145.152","sshUser":"root","sshKeyFile":"/run/rakkr-ssh/id_ed25519","smokeCommand":"/tmp/rakkr-recorder-agent --print-inventory"}}'
docker compose up -d --build ansible-runner
mise run ansible:x32-smoke

mise run ansible:x32-smoke runs a safe smoke_check (no binary deploy). Use update_binary only after RAKKR_ANSIBLE_BINARY_SRC points at a real Linux recorder-agent artifact. Full details: Node lifecycle and deploy/ansible/README.md.

Recorder-agent release binaries

The recorder-agent is versioned YYYY.MM.DD-N, stamped into the binary from the release tag. Run mise run release agent to push an agent-v<YYYY.MM.DD-N> tag; the tag triggers the Release recorder agent workflow (.github/workflows/release-agent.yml), which builds static musl binaries for x86_64-unknown-linux-musl and aarch64-unknown-linux-musl and publishes a GitHub release tagged with that tag (each artifact with a .sha256). The static musl build runs on Debian and RedHat nodes without a glibc version dependency.

The Ansible update_binary action pulls these releases automatically: the target node downloads the artifact for its architecture, verifies the checksum, and installs it. It defaults to the newest release; forward agentVersion to pin a specific tag (agent-v…). Set RAKKR_ANSIBLE_AGENT_SOURCE=local with RAKKR_ANSIBLE_BINARY_SRC only for air-gapped or offline staging. See Releases & versioning, Node lifecycle, and the recorder-agent README for the full release flow.

Helm (Kubernetes)

The chart is deploy/helm/rakkr-controller. The recommended way to publish the two images is the controller release workflow: run mise run release controller to push a controller-v<YYYY.MM.DD-N> tag, which builds and pushes versioned ghcr.io/<repo>-api and ghcr.io/<repo>-web images (see Releases & versioning). Rolling those images out with Helm stays a separate, deliberate step.

To build and publish to a different registry by hand:

Terminal window
docker build -f Dockerfile.api -t registry.example.com/rakkr/controller-api:0.1.0 .
docker build -f Dockerfile.web -t registry.example.com/rakkr/controller-web:0.1.0 .
docker push registry.example.com/rakkr/controller-api:0.1.0
docker push registry.example.com/rakkr/controller-web:0.1.0

Install with the bundled Postgres StatefulSet:

Terminal window
helm upgrade --install rakkr deploy/helm/rakkr-controller `
--set api.image.repository=registry.example.com/rakkr/controller-api `
--set api.image.tag=0.1.0 `
--set web.image.repository=registry.example.com/rakkr/controller-web `
--set web.image.tag=0.1.0

Enable ingress (it routes to the web service, which proxies the API):

Terminal window
helm upgrade --install rakkr deploy/helm/rakkr-controller `
--set ingress.enabled=true `
--set ingress.hosts[0].host=rakkr.example.com `
--set ingress.hosts[0].paths[0].path=/ `
--set ingress.hosts[0].paths[0].pathType=Prefix `
--set api.env.RAKKR_WEB_ORIGIN=https://rakkr.example.com `
--set api.env.RAKKR_OIDC_REDIRECT_URI=https://rakkr.example.com/api/v1/auth/oidc/callback

Recorder-agent cache-file uploads reach the API through this ingress -> web (nginx) path, so the chart ships four upload-related annotations — nginx.ingress.kubernetes.io/proxy-body-size: "0", nginx.ingress.kubernetes.io/proxy-request-buffering: "off", nginx.ingress.kubernetes.io/proxy-read-timeout: "3600", and nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" — (and the web image’s nginx sets client_max_body_size 0 on /api/) to lift the 1 MB default that would otherwise 413 every upload, stream rather than buffer the body, and give slow multi-GB uploads room not to time out. The controller enforces the authoritative cap via RAKKR_RECORDING_CACHE_MAX_BYTES (4 GiB default). Override ingress.annotations if you run a non-nginx ingress controller.

Use an external database instead of the bundled Postgres:

Terminal window
helm upgrade --install rakkr deploy/helm/rakkr-controller `
--set postgres.enabled=false `
--set database.externalUrl=postgres://user:password@postgres.example.com:5432/rakkr

Setting postgres.enabled=false is required here, not optional: the chart fails at render time if an external database is configured (database.externalUrl or database.existingSecret.name) while the bundled Postgres is still enabled (postgres.enabled=true, the default). See the warning below.

values.yaml ships no plaintext secret defaults (admin password, master keys, runner token, and postgres password are all empty) so a default install fails closed. For local clusters, apply the dev overlay:

Terminal window
helm upgrade --install rakkr deploy/helm/rakkr-controller `
-f deploy/helm/rakkr-controller/values-dev.yaml

Secrets backend

Every sensitive value is sourced from a Kubernetes Secret named after appSecret; secrets.backend selects how that Secret is populated. The app secret carries DATABASE_URL, RAKKR_LOCAL_ADMIN_PASSWORD, RAKKR_OIDC_CLIENT_SECRET, RAKKR_SECRET_KEY, RAKKR_NODE_SSH_MASTER_KEY (node SSH key encryption), and RAKKR_RUNNER_TOKEN (Ansible runner fetch auth).

  • native (default) — the chart renders an Opaque Secret from api.secretEnv. Provide real values via a values file or --set.
  • externalSecrets — an ExternalSecret (External Secrets Operator) materializes the Secret from a store (Vault / AWS SM / GCP SM / Azure KV). Set secrets.externalSecrets.secretStoreRef.name and either .data or .dataFrom (passed through to the ExternalSecret spec).
  • sealed — a committed SealedSecret (Sealed Secrets controller) from secrets.sealed.encryptedData (kubeseal ciphertext, key → value).

appSecret.existingSecret is always honored: set it to a pre-provisioned Secret’s name and the chart renders no Secret/ExternalSecret/SealedSecret. For a DB-only external secret, keep database.existingSecret:

# Bringing your own DATABASE_URL means bringing your own database — disable the
# bundled Postgres, or its pod boots with an empty password that will never match
# the DATABASE_URL your app secret carries, and the API fails to connect.
postgres:
enabled: false
database:
existingSecret:
name: rakkr-database
key: DATABASE_URL
# External Secrets Operator example
secrets:
backend: externalSecrets
externalSecrets:
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
dataFrom:
- extract:
key: secret/data/rakkr/controller

Bringing your own database means disabling the bundled Postgres. The chart fails at render time if you configure an external database (database.externalUrl or database.existingSecret.name) while leaving postgres.enabled=true (the default) — that combination would render a full Postgres StatefulSet + Service + Secret + PVC that runs UNUSED (an orphaned database: wasted storage, and a second, drifting source of truth for the DB password). The render error tells you to disable the bundled Postgres when bringing your own DATABASE_URL; set postgres.enabled=false (above).

If you deliberately keep the bundled Postgres as your database while your app secret is external/ESO/sealed, you must still coordinate the DB password. The chart’s Postgres Secret renders POSTGRES_PASSWORD from postgres.auth.password (empty by default), which is independent of the DATABASE_URL your app secret supplies — so point Postgres at the same secret via postgres.auth.existingSecret or set a matching postgres.auth.password. Otherwise Postgres trusts an empty password while the API presents a real one and every connection fails password authentication failed.

The Ansible runner’s SSH secrets no longer live in RAKKR_ANSIBLE_TARGETS (Phase 1): the runner fetches per-node keys from the controller using RAKKR_RUNNER_TOKEN, so TARGETS is a non-secret host map.

What the chart deploys

API and web Deployments + Services, an API PVC (/var/lib/rakkr), a ConfigMap (non-secret API env), the app secret per secrets.backend (Secret/ExternalSecret/SealedSecret, or none with appSecret.existingSecret), a ServiceAccount, an optional Ingress, an optional migration Job, and — when postgres.enabled=true — a Postgres StatefulSet/Service/Secret.

Migrations run in an API init container by default (api.migrateOnStartup=true, after a wait-for-database probe). For controlled release pipelines against an external / pre-existing database, enable the separate migration Job with migrations.job.enabled=true; this also disables the API init-container migrate (so migrations never run twice), runs its own wait-for-database init container, and sequences ahead of the API rollout as a pre-install,pre-upgrade Helm hook. Because that hook runs before the release’s normal resources, the bundled Postgres StatefulSet is not yet up when it fires — so migrations.job.enabled=true requires postgres.enabled=false and the chart fails render otherwise. For the bundled Postgres, keep the default init-container path (migrations.job.enabled=false). Key values include api.image/web.image, api.replicaCount, api.service.port, api.persistence, api.env/api.secretEnv, postgres.auth/postgres.enabled, database.externalUrl/existingSecret, and ingress.*.

Health probes

The API pod splits its probes by concern. Liveness rides /healthz, an unconditional 200 that only reflects that the process is up — a transient database blip must not restart an otherwise-healthy process. Readiness rides /readyz, which returns 503 while Postgres is unreachable and 200 once the controller can serve traffic, so Kubernetes keeps the pod out of the Service until the database is actually usable. (In memory-store mode — no DATABASE_URL/readyz is always ready.) The web (nginx) pod’s probes both hit an nginx-local health location and never proxy to the API.

Rendering the chart

mise run helm:check (part of mise run check) renders the chart across every secrets.backend (native/externalSecrets/sealed) and asserts its invariants: the native app Secret carries the full secret key set, the API consumes it via envFrom.secretRef (no plaintext key material in the pod spec), and both render-time fail guards (orphaned bundled Postgres; migration Job against bundled Postgres) fire for their bad combos. The gate self-skips when the helm binary is unavailable.