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
docker compose up --buildThe stack starts these services:
| Service | Purpose | Port |
|---|---|---|
postgres | Controller database (postgres:17-alpine). | 5432 |
controller-migrate | One-shot Drizzle migration runner; runs before the API starts. | — |
controller-api | Controller API + /metrics. | 8787 |
controller-web | React console served by nginx, proxying the API. | 5173 |
ansible-runner | Optional node-lifecycle runner. | 8790 |
recorder-test-rig | Disposable 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:
$env:RAKKR_LOCAL_ADMIN_PASSWORD = "replace-me"$env:RAKKR_WEB_ORIGIN = "http://localhost:5173"docker compose up --buildThe 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:
$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 --buildStop, or remove volumes:
docker compose downdocker compose down --volumesThe 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:
$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-runnermise run ansible:x32-smokemise 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:
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.0docker push registry.example.com/rakkr/controller-web:0.1.0Install with the bundled Postgres StatefulSet:
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.0Enable ingress (it routes to the web service, which proxies the API):
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/callbackRecorder-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:
helm upgrade --install rakkr deploy/helm/rakkr-controller ` --set postgres.enabled=false ` --set database.externalUrl=postgres://user:password@postgres.example.com:5432/rakkrSetting 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:
helm upgrade --install rakkr deploy/helm/rakkr-controller ` -f deploy/helm/rakkr-controller/values-dev.yamlSecrets 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 OpaqueSecretfromapi.secretEnv. Provide real values via a values file or--set.externalSecrets— anExternalSecret(External Secrets Operator) materializes the Secret from a store (Vault / AWS SM / GCP SM / Azure KV). Setsecrets.externalSecrets.secretStoreRef.nameand either.dataor.dataFrom(passed through to theExternalSecretspec).sealed— a committedSealedSecret(Sealed Secrets controller) fromsecrets.sealed.encryptedData(kubesealciphertext, 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 examplesecrets: backend: externalSecrets externalSecrets: secretStoreRef: name: vault-backend kind: ClusterSecretStore dataFrom: - extract: key: secret/data/rakkr/controllerBringing your own database means disabling the bundled Postgres. The chart
fails at render time if you configure an external database (database.externalUrlordatabase.existingSecret.name) while leavingpostgres.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 ownDATABASE_URL; setpostgres.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
SecretrendersPOSTGRES_PASSWORDfrompostgres.auth.password(empty by default), which is independent of theDATABASE_URLyour app secret supplies — so point Postgres at the same secret viapostgres.auth.existingSecretor set a matchingpostgres.auth.password. Otherwise Postgres trusts an empty password while the API presents a real one and every connection failspassword 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.