Skip to content

The in-house deploy platform

LogriteMasterControl/ops/ is a deploy platform that replaces Coolify for the services that have been moved onto it. One pipeline, tracked end to end:

fetch from GitHub → build on the target host → push to that host's registry
→ pin the tag → docker compose up -d → verify

Every run is a job with a queue position, a streamed log, a duration split into waiting and running, a Discord message and a rollback target.

The control surface is management.logrite.ai (ops/management/), behind Cloudflare Access. The service binds 127.0.0.1 only; there is no path to it that does not go through the tunnel.

All 20 dev services are compose-owned as of 2026-09-23. Coolify owns no Logrite service on dev — only its own coolify-sentinel and coolify-realtime, which belong to the Coolify installation itself.

ai-service · analyzer · backend · data-prepper · demo-seeder · devdocs · downtime-tracker · elasticsearch · frontend · ingest-shim · jobsearch · kibana · logrite-postgres · logrite-redis · mcp-server · opensearch · opensearch-dashboards · semantic-search · warden · webhook

Coolify is gone from dev entirely as of 2026-09-23 — docker ps -a | grep -i coolify returns nothing, and dev-cicd.logrite.ai has no DNS record. Nothing was deleted: /data/coolify/, the coolify-db / coolify-redis volumes and every coollabsio/* image are intact.

17 of prod’s 18 services are compose-owned as of 2026-09-23 (evening). Promoted one at a time, each verified before the next was started: devdocs, ai-service, warden, ingest-shim, analyzer, semantic-search, mcp-server, webhook, frontend, backend, data-prepper — joining downtime-tracker — then the five stateful ones: logrite-redis, opensearch-dashboards, logrite-mongo, logrite-postgres, opensearch.

jobsearch is the one service still on Coolify, deliberately. Its 689 MB is throwaway embedmongo-db-<uuid> and its database has never survived a restart — there is nothing to preserve and nothing to gain by moving it.

Every takeover left the original container behind as …--preadopt-<stamp>, so each service rolls back without a rebuild. That is also why prod’s Coolify still runs and still holds :8000.

The stateful five were verified on their data, not their exit codes

Section titled “The stateful five were verified on their data, not their exit codes”

An exit 0 is what a service that came up on an empty volume also reports.

serviceevidence either side of the cutover
logrite-redissame named volume re-attached (mounts compared against the --preadopt- container); PONG; genuinely empty — it is a cache, 89-byte dump.rdb
opensearch-dashboardsserving real traffic (app.logrite.ai → /api/alerting/alerts 200); local probe 401 = up + auth
logrite-mongoidentical volumes both sides, ping ok:1, 495 MB on disk, auth enforced — i.e. it read its own user store off that volume
logrite-postgresexact row counts identical: integrity_manifest 1218, analyzer_rules_config 203, user_sessions 159, 85 tables
opensearch877 indexes, 894 primaries identical; docs 19,572,272 → 19,572,923 (+651 = live ingest, not loss)

Prod still has no automatic deploy path, deliberately

Section titled “Prod still has no automatic deploy path, deliberately”

This is unchanged by the migration and is not an oversight:

  • Coolify’s per-app auto-deploy is false for every app it still holds.
  • All three docker-deploy GitHub Actions workflows are disabled_manually.
  • Prod’s watcher carries an empty auto_deploy list — it announces and never deploys.
  • Prod actions are refused to any caller without a proven identity, so there is no scripted path. A localhost API call is seen as local and rejected.

Prod moves when a human promotes it from the console. And promote is the path, not deploy — prod has no SSH key at all, so a branch deploy fails Permission denied (publickey). That is not a fault to fix: promotion copies the exact image tested on dev into prod’s registry, so prod runs the bits that were tested rather than a rebuild of the same commit. Giving prod a deploy key would silently turn promotion into a rebuild.

GET /api/services?host=<host> reports, per service, owner, container, the live state/status/image, and needs_takeover.

ownerwhat it meansa deploy will…
composethis platform created the containerreplace its image
coolifya live container under Coolify’s labeltake it over first
externala container at the stack’s declared name carrying neither labeltake it over first
nonenothing is runningsimply create it
unknownthe read failed, or two containers claim the servicenothing — fix this first

This comes from one labelled docker ps, not from docker compose ps. compose ps filters on the compose project label, so on a host where another control plane owns the containers it returns nothing — and every field came back empty, which renders as blank cells that read as “nothing is deployed here” about eighteen live production containers.

Ownership is not a matter of opinion — ask the platform:

Terminal window
curl -s "http://127.0.0.1:8090/api/safety?service=analyzer&host=dev" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["adoption"]["owner"])'
# compose

The three writable-layer services, resolved

Section titled “The three writable-layer services, resolved”

These were held back because the data guard refused them. Two now keep their data somewhere a redeploy cannot reach, which was never true under Coolify.

ServiceWasNow
data-prepper/usr/share/data-prepper/pipelines in the writable layerbind → /var/lib/logrite-data/data-prepper/pipelines
webhook/config in the writable layerbind → /var/lib/logrite-data/webhook/config
jobsearch702 MB, nothing declared it statefulinvestigated — not data

Both binds were verified byte-for-byte against the live container (md5 on every file) before the cutover and again inside the new container afterwards.

Not everything here is built from a repository. opensearch, its dashboards, elasticsearch and kibana are upstream images — Coolify calls that a dockerimage build pack — and jobsearch is a nixpacks build whose output is already a tag.

Deploying one of these does not build anything. The platform deploys the image its compose file already names:

[image] kibana has no source on dev; deploying the image its compose file
already names: docker.elastic.co/kibana/kibana:9.5.2

The Fetch source, Build image and Push to registry steps report skipped, not pending, so the log does not imply work that never applied.

Until 2026-09-22 this path did not exist: deploy called the builder unconditionally and answered “is a pre-built image, not something built from source”. Every word of that is true and the outcome was wrong — it reads like a misconfiguration to go and fix, and nothing is misconfigured. Four of dev’s services were unreachable through the platform for that reason alone.

opensearch, opensearch-dashboards and elasticsearch have now been moved too, and because they hold the estate’s log analytics data the move was verified on the data rather than on the deploy’s exit code:

beforeafter
OpenSearch indexes386386
OpenSearch documents48,741,62348,742,458 (ingest continued)
OpenSearch primary shards416 active, 42 unassigned, yellowidentical
Elasticsearch index dirs6666

Both keep their data on named volumes declared external: true in the generated stack, which is what makes this safe: compose attaches the existing volume rather than creating a project-prefixed empty one. Recovery took about 90 seconds for OpenSearch, during which the cluster reported red while it opened 416 shards — expected, and worth knowing before somebody reads it as a failed cutover.

Order matters. Each step exists because skipping it broke something.

1. Turn Coolify’s auto-deploy off for that app — FIRST

Section titled “1. Turn Coolify’s auto-deploy off for that app — FIRST”

While it is on, a push makes Coolify recreate the container under the name compose has just taken, and the two fight over it.

-- on coolify-db. NOTE the table: this flag is NOT on `applications`,
-- which is what the API returns.
update application_settings s set is_auto_deploy_enabled = false
from applications a where s.application_id = a.id and a.name = '<service>';

Do it for the WHOLE estate rather than per cutover. Seven apps were still true when the last eight services moved, including one that had been compose-owned for a day.

2. Deploy through the platform, allowing adoption

Section titled “2. Deploy through the platform, allowing adoption”
Terminal window
curl -sX POST http://127.0.0.1:8090/api/deploy \
-H 'content-type: application/json' \
-d '{"service":"<svc>","host":"dev","ref":"dev","allow_adopt":true}'

Adoption renames Coolify’s container to <name>--preadopt-<timestamp> and stops it. It is never removed: that container is the way back.

Omit ref for a service with no source — there is nothing to resolve a branch against, and asking is an error rather than a silent no-op.

GET /api/safety?service=…&host=dev must report adoption.owner == "compose".

4. Only once everything is across: route pushes to the console

Section titled “4. Only once everything is across: route pushes to the console”

deploy_backend now defaults to "console", so a host that has never been hand-edited behaves correctly. Override it in /etc/logrite-ops/config.json only to go back to Coolify, and restart logrite-deploy-watcher.

services.json records which build args each service needs. Values are never stored there — they are tokens and signing keys — and are resolved at build time from two places, in this order:

  1. /opt/logrite-stack/build-args.json — operator-declared, for build-only variables.
  2. The merged compose environment — the same environment the running container has.

Only args the Dockerfile DECLARES are required

Section titled “Only args the Dockerfile DECLARES are required”

build_arg_keys was harvested from Coolify’s env table, where a human ticks “build time”. Nothing checked that against the Dockerfile.

logrite-ai-service’s Dockerfile.ci declares no ARG at all, yet Coolify records OPENSEARCH_CA_CERT and OPENSEARCH_CA_PATH as build-time — so the guard refused every ai-service build over two variables that cannot reach the image. The builder now reads the Dockerfile’s ARG lines and requires only those. A declared arg with no value still fails the build, which is the case worth stopping.

Coolify flags is_buildtime and is_runtime independently. warden’s AI_SERVICE_IMAGE_TAG is build-only: it selects the base image in

ARG AI_SERVICE_IMAGE_BASE=ghcr.io/deployd-io/logrite-ai-service
ARG AI_SERVICE_IMAGE_TAG=latest
FROM ${AI_SERVICE_IMAGE_BASE}:${AI_SERVICE_IMAGE_TAG}

and is deliberately never injected into the container. Resolving it from the compose environment therefore looked in the one place it could not be.

The overlay can name the relationship instead of a literal tag:

{
"services": {
"warden": {
"AI_SERVICE_IMAGE_BASE": "@pin-repo:ai-service",
"AI_SERVICE_IMAGE_TAG": "@pin-tag:ai-service"
}
}
}

@pin-repo:<svc> and @pin-tag:<svc> resolve at build time to the image that service is currently pinned to on this host, so warden always builds on the ai-service image the platform actually deployed. A literal tag would go stale on the next ai-service deploy, silently — and if ai-service is not pinned at all, the build is refused rather than guessing a base image.

Promotion ships the exact image that was tested on dev, which is right for a service configured at runtime. For one configured at build time it inverts: build_args() resolves values out of the build host’s compose environment, so a dev-built frontend carries dev’s VITE_* values no matter where it is later deployed. That is the 2026-09-23 incident, and it left prod’s frontend unpromotable — the guard was correct, and there was nothing it could let through.

for_env is the way forward. It builds on one host for another: the source and the build machinery are the build host’s, the configuration is the target’s.

Terminal window
curl -X POST http://127.0.0.1:8090/api/build \
-H 'Content-Type: application/json' \
-d '{"service":"frontend","host":"dev","ref":"main","for_env":"prod"}'

The target’s values live in a per-estate block of the same overlay:

{
"environments": {
"prod": {
"frontend": {
"VITE_API_BASE_URL": "https://leapfrogapi.deployd.io",
"VITE_OPENSEARCH_API": "https://analyticsapi.logrite.ai"
}
}
}
}

The per-estate block wins over the plain services block, so a value that genuinely is the same everywhere is still written once.

Two further consequences, both deliberate:

  • The target estate reaches the image TAG, not just a label — main-<sha>-prod. Two builds of the same commit for different estates are different images whose tags would otherwise be identical, so the second push would silently replace the first in the registry.
  • Which args are required is the TARGET’s statement, so a for_env build reads build_arg_keys from the target’s catalogue entry. The two lists differ in practice — dev’s frontend carries one key prod’s does not.

Install a fragment onto a build host with the merge tool rather than by hand; it merges key by key, previews every change, and never removes anything:

Terminal window
python3 ops/deploy/merge_build_args.py build-args.prod-frontend.json # preview
python3 ops/deploy/merge_build_args.py build-args.prod-frontend.json --write # apply

Hand-editing /opt/logrite-stack/build-args.json risks dropping the @pin-repo: / @pin-tag: entries above, and that failure is not loud: the base image resolves from the compose environment instead, to a tag the previous control plane kept re-pushing and nothing has pushed since.

Every check around the incident asked about the container — healthy, 200, green. None could ask what was compiled into the JavaScript, because Vite inlines VITE_* at build time and the result is string literals in a minified file. A frontend pointed at the wrong estate is not a degraded service; it is a perfectly healthy service wired to someone else’s backend.

Terminal window
python3 ops/deploy/verify_bundle_estate.py --url https://app.logrite.ai --estate prod
python3 ops/deploy/verify_bundle_estate.py --image <ref> --host dev --estate prod

It reads the literals back out and classifies every hostname, with three distinct exit codes because the fixes differ: 2 a hostname belongs to another estate (wrong image), 1 a hostname does not resolve (right image, stale value), 0 clean.

The console shows a deploy as an ordered step list with a percentage and a remaining time.

StepEntered when the log emits
Queued[queued]
Resolve commit[started]
Fetch source[git]
Build imageBuildKit’s own #N … progress lines
Push to registry[push]
Take over the container[adopt] / [unadopt]
Data-safety check[safety]
Move data onto the host[stage]
Pull image[pull]
Pin the tag[pin]
Recreate container[up]
Done[succeeded] / [failed] / [cancelled]

The step labels are the console’s, not Coolify’s: it says “take over the container” rather than naming the product it replaced, and a test fails on any user-visible string that mentions it.

The steps are derived from the job’s own log, not reported separately by the handlers. A second state machine can disagree with the log, and when it does the operator has two stories and no way to choose which is true.

Progress is forward-only — the markers are not perfectly ordered (a late [state] line follows [up]) and a bar that goes backwards reads as a bug in the deploy rather than in the display.

The percentage names its own basis:

  • history — elapsed against the median of this service’s last five successful runs of this kind on this host. Median, not mean: one cold-cache build would poison a mean for days.
  • steps — how far down the list the job has reached. Drawn as a hatched bar and labelled as carrying no timing estimate, so it cannot pass for a measurement.

It never reaches 100% while a job is still running, and never states a remaining time it cannot know.

A deploy watches the container before it says “succeeded”

Section titled “A deploy watches the container before it says “succeeded””

docker compose up -d returns the moment the container starts, so a container that dies on its first line looked exactly like a healthy one. Since 2026-10-10 the driver watches the recreated container for 20 seconds ([settle] in the log; LOGRITE_DEPLOY_SETTLE_SECONDS overrides) and fails the deploy if it is restarting, exited or dead, if its restart count grows while watched, or if its healthcheck reports unhealthy. A state it cannot read is not a crash: the watch stops and says so.

When it fails, the new image is already running and pinned — the message says so. Old-tag pruning is skipped, so the previous image is still in the registry for a rollback. On its first day it caught a dev webhook image missing a module (MODULE_NOT_FOUND, previously a green deploy over a 35-minute crash loop) and a prod webhook that could not write its config into a root-owned bind (EACCES).

Prod’s healthchecks come from /opt/logrite-stack/docker-compose.healthchecks.yml (source ops/deploy/docker-compose.healthchecks.yml), loaded the same way as docker-compose.health.yml. The dev file must not be copied to prod: it names the dev-only Elasticsearch lab services, and compose then refuses every command.

container_name in the generated stack is Coolify’s timestamped name

Section titled “container_name in the generated stack is Coolify’s timestamped name”

<uuid>-<deploy-epoch> goes stale the moment Coolify redeploys that service. A stale one used to make adopt answer “nothing to do — a first deploy will simply create it”: a false all-clear while Coolify’s container kept serving. compose_driver.container_name now verifies the name exists, falls back to the Coolify label, then to the uuid half.

adopt preserves the container, not the data LOCATION

Section titled “adopt preserves the container, not the data LOCATION”

The platform binds /var/lib/logrite-data/<svc>/…; Coolify binds /data/coolify/applications/<uuid>/…. Merge the data across at cutover, or the service comes up on an older copy without saying so.

$ in a copied value is data, not a template

Section titled “$ in a copied value is data, not a template”

coolify_to_compose.py generates the stack by reading running containers, so every value in it is already final.

Compose interpolates ${VAR} in the YAML itself, before docker sees any of it, against the environment docker compose was run in — not the container’s.

The analyzer’s image declares:

HEALTHCHECK CMD curl -f "http://localhost:${PORT:-8080}/health" || exit 1

Docker keeps that verbatim and expands it inside the container, where PORT=9001. Copied into the stack unescaped, compose resolved it at config time — PORT is not set in the deploy user’s shell — and baked http://localhost:8080/health into the container. The analyzer listens on 9001 and answers healthy there, so the probe could never pass: 53 consecutive failures against a service that was serving fine, with nothing anywhere saying the port had been rewritten.

yaml_scalar() now escapes $ to $$. It is fixed in the one function every copied value passes through, because command and entrypoint carry exactly the same exposure.

The generator now also refuses to write a stack containing anything compose would still interpolate, naming every line it found:

REFUSING TO WRITE: 1 value(s) compose would still interpolate:
line 412: test: "curl -f http://localhost:${PORT:-8080}/health"
Every value here is already final, so each of these is a copied value about to
be rewritten.

--allow-interpolation is the escape hatch, named so that using it is a decision somebody made.

Both dev stack files scan clean. Prod’s stack has never been generated, so the check will run the first time it is — which is why it lives in the generator rather than in a checklist.

A deploy is given a ref (main) and, from the watcher, the commit that ref pointed at when the job was queued. Two things resolve between that and the image, and on 2026-09-23 both were wrong in the same direction: they shipped older code and reported success.

git ls-remote <repo> main is a PATTERN, not a name

Section titled “git ls-remote <repo> main is a PATTERN, not a name”

It matches every ref whose last slash-separated component is main. The pre-squash archive created that day gave eight of nine repos a second one, and it sorts first:

3313cc6b0d2f refs/heads/archive/pre-squash-20260923/main ← taken
43c579b203eb refs/heads/main ← wanted

resolve_commit() took the first parseable line, so every ref: "main" deploy resolved to the archive snapshot.

Nothing errors along the way, which is why it went unseen. The sha is real, the checkout is clean, the image is tagged main-<that sha>, compose recreates the container on the image it is already running, and the job records:

{ "ok": true, "requested": "…/ai-service:main-3313cc6b0d2f", "changed": false }

Discord says ✅ and the ledger says the commit shipped. Measured three times — ai-service twice, devdocs once. And it is permanent, not transient: archived branches are kept deliberately, so the ambiguity never ages out.

The exact ref now wins — refs/heads/<ref>, then a dereferenced tag, then the tag object, then the ref as spelled. A single match passes through. Two inexact matches refuse and name both: “which main did you mean” has no safe default, and guessing is what produced this.

A deploy that follows a prebuild is pinned to the prebuilt commit (F-31)

Section titled “A deploy that follows a prebuild is pinned to the prebuilt commit (F-31)”

The backend’s dev image is a retag of whatever the watcher’s prebuild last published, so an image can only truthfully carry the commit that prebuild compiled. With the branch as the ref, a push landing during the ~9-minute prebuild made the console follow the branch and stamp the newer sha on the older artifact. builder.verify_built_source() refuses that (StaleArtifactError) — correct, but it surfaced as a red job nobody could act on.

Since 2026-10-01 the watcher triggers a post-prebuild deploy with ref = the prebuilt sha (_reap_prebuilds → _trigger(…, pin_commit=True)). freshen_commit() leaves an exact sha alone, so the deploy ships exactly what was built, and the newer push is picked up on the next tick as its own prebuild. Deploys without a prebuild still send the branch. Pinned by ops/notify/test_trigger_target.py, which drives _reap_prebuilds itself rather than the trigger with the flag set by hand.

A tip that is BEHIND the queued commit is a stale read

Section titled “A tip that is BEHIND the queued commit is a stale read”

A queued deploy follows its branch rather than the tip it was queued with — one worker per host serialises the queue, and a job can wait minutes behind its own siblings. freshen_commit() implemented that by comparing the two shas for inequality, which holds because a branch only moves forward — until the read itself is stale. The same job’s log carried both halves:

[git] main has moved since this deploy was queued: 43c579b203eb -> 3313cc6b0d2f
...
3313cc6..43c579b main -> origin/main

That reads exactly like a push landing mid-job. It was not: re-running it returned the identical answer, and a race that reproduces exactly is not a race.

tip_is_behind() now asks the one question a squashed history cannot fake — is the sha I just read a parent of the sha I was asked to build. Three answers, not two: unknown (no clone, an object neither side has, a host that will not answer) keeps the queued commit as well, because none of those are evidence the tip is newer. A genuine move forward is still followed, so the base-image mismatch this guard exists for is not traded away for the new failure.

The section above is about what a deploy builds. This one is about what a container can tell you afterwards, which is a different question and had a different answer for two days.

Every image is labelled at build time:

Terminal window
docker inspect --format '{{index .Config.Labels "ai.logrite.commit"}}' <container>

Since 2026-09-25 the deploy also stamps the commit into the container’s environment, because that is where services already look:

# docker-compose.images.yml — generated, rewritten on every deploy
services:
analyzer:
image: "127.0.0.1:5000/logrite/analyzer:main-570856e0eb32"
environment:
SOURCE_COMMIT: "570856e0eb32bb8efb3ee1eebe1e3e2a0867c3a5"
SOURCE_BRANCH: "main"

Each service picks the stamp up on its next deploy. Containers running from before the fix keep the frozen value — nothing is silently rewritten underneath a running service. Until then the trustworthy source is the image tag:

Terminal window
curl -s http://127.0.0.1:8090/api/services | \
python3 -c 'import sys,json;[print(s["service"], s.get("image")) for s in json.load(sys.stdin)["services"]]'

What is prod behind on, and how does it move?

Section titled “What is prod behind on, and how does it move?”

/api/drift compares the commit each service runs on dev against the one it runs on prod, per service, and says whether the gap is promotable:

Terminal window
curl -s http://127.0.0.1:8090/api/drift | python3 -c '
import sys, json
for r in json.load(sys.stdin)["services"]:
if not r.get("in_pipeline"):
continue
dev = (r.get("dev") or {}).get("commit")
prod = (r.get("prod") or {}).get("commit")
print("%-20s %-14s %-14s %-10s %s" % (
r["service"], dev, prod, r["relation"], r["promotable"]))'

relation is one of same, behind (with ahead_by), diverged, or unknown. A row that is not promotable carries blocked saying why in words — prod’s stack does not declare the service, or dev is not running an image this pipeline built (the stateful five and the upstream images), or prod still runs it under the previous control plane (jobsearch).

Promoting is a button on that row in the console. It copies the exact image already tested on dev into prod’s registry over an ssh tunnel and deploys that — prod runs the bits that were tested, not a rebuild of the same commit.

Deployable only, and promoting several at once (since 2026-09-28). The Dev → prod page opens with Deployable only ticked. It hides every row production cannot be given an image for right now. A row is shown only if it is promotable and its dev container is running. That leaves out services prod does not declare (demo-seeder, the Elasticsearch lab), the stateful and upstream-image services, and jobsearch. The hidden rows are counted in a hint with a Show them button, so nothing disappears silently. Untick the filter to see every row.

Each row has a checkbox. It is armed exactly when that row’s own promote button would be: promotable, carrying a promote_image, and not already on the same commit. The header checkbox selects every armed row. Two buttons sit above the table:

  • Deploy selected (n) promotes the ticked rows.
  • Deploy all (n) promotes every armed row that is visible, except rows where production is ahead. Promoting one of those rolls production back, so tick it and use Deploy selected when that is what you mean.

Both buttons open one confirmation that lists each service and its drift. Each service is then queued as its own ordinary POST /api/promote job. Prod’s job worker runs one job at a time per host, so a bulk promote is N promotions shipped in order. There is no separate bulk code path on the server. A failed enqueue is reported per service, and the rest still go.

Why did a deploy fail, and how do I fix it?

Section titled “Why did a deploy fail, and how do I fix it?”

Open the failed job, from the Deployments ledger or from the toast. Since 2026-09-28 its dialog shows one panel for any failed deploy, build, promote or restart, on dev or prod:

  • Why it happened gives the cause in plain words, not the guard’s raw message.
  • How to fix it gives the steps in order, plus the platform’s own advice when the error contains some.
  • One-click actions start the fix as an ordinary job. Each one goes through the same confirmation as every other button, and a prod action sends DEPLOY-TO-PRODUCTION.
  • Evidence lists the error lines that prove the diagnosis. For a docker build these are the failing step’s own output, found inside BuildKit’s #N step. Chatter that merely contains the word “error” is left out.
  • Copy (panel header) copies the whole diagnosis as plain text: title, job, location, why, the fix steps, links and evidence. It is ready to paste into a ticket or a chat. Copy evidence copies just the evidence lines.

The panel comes from GET /api/deployment?id=<job>. For a failed job that response carries a diagnosis object, built by ops/management/diagnose.py. It is computed each time the job is opened, never stored, because the right fix depends on live state. When a frontend promote is refused because the image has dev’s VITE_* values baked in, the panel checks dev’s registry:

  • If a …-prod build of that commit exists, it offers Promote … to prod.
  • If not, it offers Build <sha> for prod. Run that, reopen the same failed job, and the panel now offers the promote.

No rule names a service. Each rule matches the shape of an error the platform emits, taken from the 49 failed jobs in the ledger on 2026-09-28. The service, host, commit, image and missing values all come from the failed job’s own text.

RuleRecognised byWhat it offers
foreign-buildpromote refused: image built for another environmentpromote the …-prod image, or build one
confirm-tokenproduction confirmation missingrun it again with the token
git-accessPermission denied (publickey) resolving a refbuild on dev and promote; or add a deploy key
base-pinned / base-guardthe base image (ai-service) is not this commit’s builddeploy the base at that commit, then retry
missing-build-argsbuild args with no value in the compose envwhere to add them, then retry
writable-layerthe data guard refused a recreateinspect with docker diff; retry with the guard overridden
registry-pushpushing to 127.0.0.1:5000 failedcheck the registry and disk, then retry
interruptedthe console restarted mid-jobcheck the running image, then run it again
no-new-privileges / read-only-fsthe console’s systemd sandbox blocked a stepthe unit setting to check, then retry
networkunreachable network, DNS failure or timeout during a pullretry
source-errora build step failed at a file and line in our code (mdx/vite/astro, tsc, javac/maven, python, webpack, go, …)Fix it in GitHub, which opens that file at that line in GitHub’s editor, plus View the line at the commit that failed. No retry
build-stepa Dockerfile RUN failed with no file and line to point ata gate (lint/test/tsc): fix the code, no retry offered; otherwise retry without cache
genericanything elsethe failed step, the log’s error lines, the error’s own advice, retry

Fix it in GitHub takes the repo and commit from the build log’s own [build] <svc> on <host>: <repo>@<sha> line. The file path the tool printed is a path inside the container (/app/src/…). The console checks every suffix of it with git cat-file -e <sha>:<path> in the builder’s checkout (/var/lib/logrite-deploy/src/<repo>), so the link points at a file that exists. Without a checkout it strips the usual WORKDIR (/app) and says the path was not confirmed. Committing in GitHub’s editor pushes to main, and that push deploys dev on its own, so fixing a broken build does not need a second trip to the console.

A retry re-sends the job’s own request pinned to the commit it recorded, not to its branch. A branch moves, and dev has been deleted, so “the same job” has to mean the same commit.

Every confirmation in the console — deploy, promote, take over, reclaim — is the console’s own themed dialog (since 2026-09-28), not the browser’s confirm() box. OK runs the action; Cancel, Esc or a click outside runs nothing. The separate typed DEPLOY-TO-PRODUCTION dialog in a service’s own Deploy tab is unchanged.

Terminal window
python3 /opt/logrite-management/ops/deploy/freshness.py # exit 1 if anything is behind

One row per service: the commit it is pinned at, the newest commit that can affect its image, what that tip was scoped by, and a verdict.

no pinned commit is honest and expected for jobsearch (nixpacks, no repo this platform builds from). A repo-wide BEHIND on one of the four services sharing logrite-ai-service.git names how many share it, because a repo tip cannot attribute the change to one of them.

main in every service repo carries exactly one commit per calendar day, back to 2026-07-01. ops/backup/daysquash.py is what maintains it:

Terminal window
python3 ops/backup/daysquash.py <repo> --from 2026-09-24 --to 2026-09-24 \
--emit-plan plan.json # what each day contains
python3 ops/backup/daysquash.py <repo> --from 2026-09-24 --to 2026-09-24 \
--messages msg.json # dry run — verifies, writes nothing
python3 ops/backup/daysquash.py <repo> … --messages msg.json --apply --push

It walks first-parent, not topological order. These repos carry real merges, and a topological linearisation interleaves days — a side branch started on the 3rd and merged on the 11th puts 3rd-of-the-month commits after 11th-of-the-month ones, so “the last commit of day D” stops meaning “what main looked like at the end of day D”. The first-parent walk is the mainline and is monotonic by construction.

Each new commit reuses that day’s final tree byte for byte. No merge is recomputed and no patch is re-applied, so nothing can conflict, and the tip tree is identical to the tip tree before. verify() asserts exactly that and refuses to move the ref otherwise — a build reads the tree, not the history.

Nothing is discarded: every original commit stays reachable from an attic tag pushed before the branch moves, so the rewrite is only ever reversible.

Because history is rewritten daily, git merge-base --is-ancestor reports drift that does not exist. Compare trees. That applies to MasterControl’s submodule gitlinks too: measured 2026-09-24, ten of eleven were behind, but five were behind only in the sense that they named a sha a rewrite had replaced — same tree, so bumping them changed nothing at all.

ops/deploy/safety.py refuses to recreate a container whose data exists only in its writable layer, and fails closed: an unresolvable path, an unmeasurable layer and an unknown container are all treated as unsafe.

It resolves a declared path through an in-container symlink before calling it uncovered, because a guard that cries wolf on a safe deploy is one an operator learns to override.

Data that exists only in the layer is moved BY the deploy

Section titled “Data that exists only in the layer is moved BY the deploy”

A guard that refuses is not a migration. Copying the data out by hand and then deploying has a race nobody can close from outside the deploy:

copy the data -> ...the service keeps writing... -> deploy

Everything written in that gap exists only in the old container’s layer, and the recreate destroys it. On prod’s OpenSearch that gap runs at about 4.6 documents a second.

deploy() contains exactly one instant with no writer:

adopt() the old container is renamed and STOPPED
>>> here <<< nothing is running; the layer is frozen and consistent
compose up the new container is created with the bind mounted

ops/deploy/staging.py copies there, and nowhere else. It appears as a [stage] step in the job’s own log, so it is something an operator watches rather than a procedure they follow.

  • It copies only into an empty destination. A destination that already holds data belongs to a service that has already migrated, and the source in that case is a frozen --preadopt- shadow — copying it over today’s data is a restore-from-old-backup wearing the clothes of a deploy.
  • A failure aborts the deploy. The alternative is a container that starts perfectly and is empty: a green deploy, a broken service, and a cause three layers from the symptom. An abort leaves the previous container stopped-but-intact, and unadopt() restores it.
  • The copy is verified by manifest — size and relative path for every regular file on both sides, compared as sets so a mismatch names the files.

Every command here is a pipeline out of docker cp, so they all run under set -o pipefail. A broken docker cp produces an empty stream, tar reports nothing, and the listing comes back empty — which reads identically to “this directory has no files” and leads straight to “nothing to copy”. That is a silent skip of the one step protecting the data, so an unreadable source raises instead.

“Nowhere to put it” and “not there yet” are different verdicts

Section titled ““Nowhere to put it” and “not there yet” are different verdicts”

assess() measures the container that EXISTS, which is right: recreating today’s container really would destroy what is in its writable layer. But when the stack already declares a bind for the uncovered path, the report says so, and says the deploy will copy the data there before the replacement starts.

The verdict stays safe: false — the bind is not what makes it safe, the deploy’s own adopt-and-stage sequence is. What changes is that the operator is no longer shown a flat refusal for a service the deploy is about to migrate, with the guard override as the only visible way forward. That is how an operator learns to override a guard that is telling the truth.

A guard that was never asked about two stateful services

Section titled “A guard that was never asked about two stateful services”

DATA_PATHS encodes what the platform KNOWS is stateful. elasticsearch and kibana were not in it. Both are stateful, so the only thing between them and a recreate was the writable-layer measurement — a real signal, but it says “nothing has been written to the layer yet”, not “this service is safe to recreate”. The two signals cover different blind spots and neither is sufficient alone.

elasticsearch is declared now (/usr/share/elasticsearch/data and its snapshots bind), and the guard confirms both paths are covered by a volume. When adding a service to this platform, check it against DATA_PATHS rather than assuming the measurement will catch it.

When the refusal is about bytes that are NOT data

Section titled “When the refusal is about bytes that are NOT data”

The other half of the guard is the writable-layer measurement, and it fires on bytes nothing declared. On backend those bytes are toolchain caches: it restores NuGet, npm and Maven packages while it instruments a customer repo, and they land in its layer.

That is a real refusal and the guard is right to make it — a recreate does destroy them — but the fix is not to override it and not to add them to DATA_PATHS. They are not data. The fix is to give them a bind, so the recreate destroys nothing and the measurement the guard takes is true rather than tolerated. They live in docker-compose.volumes.yml, apart from the declared data paths and labelled as caches.

Because it recurs, the refusal now names the bytes. It used to end “these are some other bytes — find out which before proceeding”, and finding out took a docker diff, a prefix-dedupe, a cross-check against the container’s mounts and a du, by hand, while the service sat undeployed:

275.0 MB sits in the writable layer and is destroyed by a recreate. Its
declared data paths ARE on volumes, so these are some other bytes — find out
which before proceeding. The largest unbound paths in that layer are
/root/.m2 (271.6 MB).

safety._layer_hotspots() produces that, and layer_hotspots carries the same list into the job record for the console. Mount-covered paths are excluded before measuring — their bytes are not in the writable layer at all, and du over backend’s 2.4 GB bound NuGet tree is slow enough to matter on a path somebody is waiting on. It runs only on the refusal path, and returns nothing at all on any failure: a diagnostic that could turn a refusal into an approval would be a guard that fails open.

The bind tells the next container where to write. It does nothing about the bytes already in the current one, and the guard measures the current one — so a deploy attempted after only the first half is refused with exactly the same message, which reads as though the edit did not take.

Both halves, in this order:

  1. Stage the bytes, then verify the count on both sides. A bind over a directory the image does not populate mounts empty, so an unstaged bind silently throws the cache away and the next scan re-downloads it.

    Terminal window
    docker cp <container>:/root/.m2/. /var/lib/logrite-data/backend/maven/
    docker exec <container> sh -c 'find /root/.m2 -type f | wc -l'
    find /var/lib/logrite-data/backend/maven -type f | wc -l
  2. Declare the bind in /opt/logrite-stack/docker-compose.volumes.yml (repo copy: ops/deploy/docker-compose.volumes.yml).

  3. Remove the in-container copy, which is what the guard is measuring:

    Terminal window
    docker exec <container> rm -rf /root/.m2

Only then does the guard fall below its 64 MB threshold and the deploy proceed.

Afterwards, verify on the file counts, not the exit code:

Terminal window
docker exec <container> sh -c 'find /root/.m2 -type f | wc -l'
find /var/lib/logrite-data/backend/maven -type f | wc -l
docker ps -s --filter "name=^<container>$" --format '{{.Size}}'

Measured on the 2026-09-25 cutover: 2296 files on both sides, and the writable layer 275 MB → 123 kB.

The guard said “safe” for a reason that was not why

Section titled “The guard said “safe” for a reason that was not why”

At first deploy the guard was answering the wrong question, and answering it confidently.

deploy() passed container_name(host, service) or service to the guard. container_name() only returns a name it has PROVEN is live, and straight after adopt() there is none — the Coolify container has been renamed to --preadopt-… and compose has not created its own yet. So the service name was substituted, docker found no container by that name, and assess() returned early:

{"container": "webhook", "declared_paths": [], "safe": true,
"reasons": ["no container named 'webhook' on dev — nothing to destroy"]}

declared_paths is empty for a service whose DATA_PATHS entry is ("/config",): the early return skipped the loop entirely.

Nothing was lost, because adoption renames rather than removes and the data had been staged onto host binds by hand first. Had it not been, the report would have been identical — webhook would have come up with an empty /config and said nothing.

At first deploy the question is not “what does a recreate destroy” (nothing — there is no container). It is “will the replacement find its data?” The guard now answers that one:

  • configured_name() — the name compose WOULD create, no existence check. A container that is really about to exist, unlike the service name.
  • configured_mounts() — what compose WOULD mount, in docker inspect shape so one coverage rule serves both cases instead of two that can drift.
  • assess(..., incoming=…) — on an absent container, DATA_PATHS is checked against those mounts. Uncovered means unsafe, and the reason says the new container starts EMPTY and will not mention it.

/api/safety in the console reads the same way, so the page and the deploy path answer from one guard. Two readers that disagree is two stories and no way to choose.

Known limit: a path reachable only through an in-container symlink cannot be resolved before the container exists, so it reads as uncovered. That is failing closed, and the reason text says so — an operator about to override deserves to know which of the two cases they are looking at.

BaseHTTPRequestHandler has no do_HEAD, so every HEAD reached the default 501 — including HEAD /, which is what a plain uptime check sends. A console that reports 501 to its own monitor looks down while serving perfectly.

do_HEAD sets a flag and routes through do_GET: one route table, so a monitor and a browser can never disagree about whether a page exists. Content-Length is still the length the GET would have had, per RFC 9110 — a monitor that read it as 0 would conclude the page is empty.

Assets are versioned, so half a deploy is impossible

Section titled “Assets are versioned, so half a deploy is impossible”

index.html and app.js must be the same vintage: the current app.js clears #svc-view-body, and one from before the services rewrite clears #services-rows, which the markup no longer has. A browser holding one from before a deploy and one from after gets clear(null) and a blank page — reported as a crash in code that is demonstrably correct on the server.

ETag + no-cache asks a browser to revalidate, and that is not enough: revalidation is a request, and a browser or an intermediary can decide not to make it. The console rewrites its own asset URLs to carry a hash of each file’s bytes:

<link rel="stylesheet" href="/style.css?v=0d0356603fa0">
<script src="/app.js?v=9ba6a157374a"></script>

A new app.js is a new URL, so no cache anywhere can answer it with the old bytes. A request naming the current version is served immutable; a version we do not recognise never is, or that browser could never be updated again.

Every job carries its own record, opened from any row in the Deployments table:

  • A timeline — Queued, Started, Finished, each with its wall-clock time, how long ago it was, and the gap that followed it (waited / ran / total).
  • What was asked for beside what happened: the branch or image requested, whether the build cache was bypassed, whether ownership was taken over as part of the deploy, and whether the data guard was overridden.
  • What resulted: commit, image deployed, image replaced, the container, and already on this image — nothing was recreated when a deploy was a no-op.
  • The data guard’s own verdict, in the record of the deploy it guarded. It is written to the log and then scrolled past; this is where somebody looks a week later when asking what protected a service.
  • The steps, derived from the job’s own log, and the full log itself.

The Deployments table is scoped by the host switcher

Section titled “The Deployments table is scoped by the host switcher”

The host switcher in the sidebar scopes this table like every other section, and the filtering happens on the server, not in the browser.

Three consequences worth knowing when changing this page:

  • The URL is a second door into the same state. applyRoute() writes state.hostFilter directly, so logic that lives only in the switcher’s handler leaves a bookmark, the back button and a shared #/deploys/prod link silently broken. Both doors call one hostFilterChanged().
  • A payload for the previous host is dropped, not rendered. It is not a slow answer to the new question, it is an answer to a different one — and the next poll quietly correcting it looks like a flicker rather than the bug it is.
  • A correct empty answer must not read as a broken filter. “Nothing has been deployed through this console yet” is true of the whole estate and false of a filtered view, so the empty state names the host it is about.

Three views over one measurement, so they cannot disagree:

WhereWhat it answers
Resources pageevery service on every host, sorted
A host card on Overview (click, or Enter)every service on that host, with the host’s own capacity
The Resources tab on a servicethat service alone, and what share of the host it is

The numbers come from one docker stats --no-stream sample joined to docker ps -s and /proc — the same joined rows for all three.

A percentage with no denominator is not information — 180% CPU is alarming until you know the box has 12 cores — so every share names what it is a share of: cores, the container’s own memory limit, and the host total.

It used to come from a hand-written tuple of five service names. It went stale the moment two of those services were given binds, and a stale warning is worse than none: it says a recreate destroys this about a service where it no longer does, and the next real warning is read the same way.

Each row now checks its own container’s mounts against the service’s declared data paths, parent-aware — a mount at /app/data covers /app/data/db. Three outcomes, three renderings:

  • no volume — a declared path with nothing mounted there, named in the tooltip;
  • mounts unknown — docker inspect did not answer, which is not a clean row;
  • no badge — measured, every declared path covered.

Search data: OpenSearch’s cost is heap, not disk (2026-10-19)

Section titled “Search data: OpenSearch’s cost is heap, not disk (2026-10-19)”

The console’s Search data section has one card per host for its OpenSearch cluster. Disk was the only view of OpenSearch the console had, and the least useful one: the cost of these clusters is cluster state held in heap, which grows per shard whatever the shard holds.

Each card shows shards per GB of heap against the usual guideline of 25 (with the heap that would support the current shard count), heap use against the 85% at which ml-commons refuses embeddings, shards / indexes / segments, data size, how many indexes are empty or under 1,000 documents and how many shards they hold, the share of wall time spent in GC, and the ML circuit breaker’s trips. OpenSearch’s own host-memory figure is shown with a warning, because it counts page cache as used. Inspect N indexes opens a paged index list (filter by name and by empty / tiny / real; sort by size, shards, documents or name).

RouteAnswers
GET /api/opensearch?host=dev|prodthe card’s figures; present: false when the host runs no OpenSearch
GET /api/opensearch/indices?host=&sort=&only=&contains=&limit=&offset=one page of indexes; total, store_bytes and shards describe the whole filter, not the page

Both are read-only and read the cluster inside its container on each host (prod over the same SSH hop as every other prod read), so there is no published port and nothing extra to configure. Entering the section and Refresh both re-read every visible host. Measured at install, 2026-10-19: dev 182.5 shards/GB (730 shards, 4 GB heap), prod 368.5 (737 shards, 2 GB heap) — both “critical”, 7x and 15x the guideline.

The Archive: every report, with when it was created and updated

Section titled “The Archive: every report, with when it was created and updated”

The console’s Archive section lists two shelves: Claude Artifacts (from ops/management/archive-artifacts.json) and the local HTML served off the dev VM (/opt/logrite-reports, /home/deployd/www).

Date filter. The same filter row as Deployments: a Range segment (Today, Yesterday, This week, This month, Custom, All), From / To date pickers, and Date (Updated or Created) to choose which time the window applies to. Picking a day in either box is the custom range. Periods are calendar periods in the viewer’s own zone; a week starts on Monday.

GET /api/archive?q=&field=updated|created&since=&until= — since and until are epoch seconds the browser computes from the viewer’s local midnight and 23:59:59, both inclusive. The server never resolves a bare date, because only the browser knows the viewer’s midnight. A bound that is not digits, a since after until, or an unknown field answers 400. A row with no value for the chosen field is left out of a window and counted in window.undated, and the page says how many.

Times on each row, as YYYY-MM-DD HH:mm:ss in the viewer’s zone:

CreatedUpdated
Local HTMLthe file’s birth time (statx btime on ext4)file mtime
Artifactnot available: claude.ai exposes no creation timepublish time of the version being served, to the second

The artifact time comes from the version id a scope: "files" listing prints (1789736809-3c3a leads with its epoch). A manifest entry without updated_ts shows the date alone, never an invented 00:00:00. Created is never filled from mtime: a “created” equal to “updated” on every row would look right and be wrong.

Local HTML is scanned live (cached 120 s), so a new file appears within two minutes.

Artifacts are kept current by a Claude Code hook, ops/management/archive_artifact_hook.py, registered as PostToolUse with matcher Artifact in the operator’s ~/.claude/settings.json (ops/management/install-archive-hook.sh adds or --removes it). After every Artifact call in any Claude Code session on the dev VM it updates the live manifest /var/lib/logrite-deploy/archive/archive-artifacts.json, which the console re-reads whenever it changes, with no restart or reinstall:

Call in a sessionWhat the shelf learns
publish a pagethat artifact, indexed from the very file published, timed by its version
list (complete)the whole shelf: new ones added, deleted ones removed
list (a page, “Showing 5 of 44”)upserts only; nothing is removed
list scope: "files" / readthe exact publish time of the version served
delete (no asset path)that artifact removed

The merge (refresh_archive_artifacts.py) matches a row by any id it goes by (short id, old UUID, URL) or its exact title, keeps indexed text for an unchanged version, writes atomically under a lock, and refuses an empty listing or one under half of a shelf of four or more: a failed fetch, not mass deletion. The hook always exits 0 and prints nothing. Log: /var/lib/logrite-deploy/archive/hook.log. The card on the page says when the shelf last changed.

Prod deploys are refused to any caller without a proven identity. A localhost API call is seen as local and rejected — including from the deploy watcher, deliberately.

Use the console’s Deploy button while signed in. Promotion ships the exact image built and tested on dev rather than rebuilding the same commit: base images move and package registries serve different bytes, and the first time that matters is the time it breaks in production.

A caller that is not a browser: operator tokens

Section titled “A caller that is not a browser: operator tokens”

A login-form session and a Cloudflare Access assertion are both browser things. Everything else fell through to local, so a deploy driven by a script, a runbook or an agent had no way to say who it was — and the only route to a prod action was a human with a mouse. That is a gap, not a policy.

Terminal window
python3 /opt/logrite-management/server.py --mint-token release-bot --token-ttl-days 30
python3 /opt/logrite-management/server.py --list-tokens # labels, never digests
python3 /opt/logrite-management/server.py --revoke-token release-bot
Terminal window
curl -X POST https://management.logrite.ai/api/promote \
-H "X-Logrite-Operator-Token: <token>" \
-d '{"service":"devdocs","image":"…","confirm":"DEPLOY-TO-PRODUCTION"}'

This does not widen the list of unproven identities; it adds a way to prove one. Two properties are what make it a credential rather than the same hole with ceremony:

  • Only sha256(token) is stored, exactly as sessions are. Someone who reads operators.json holds a verifier, not a key, and cannot mint a request with it — which is what stops “root on the jump host” collapsing back into “may ship prod”.
  • The identity is token:<label>, never an email. token:release-bot and victor@… are different actors and the audit log has to tell them apart afterwards. A token that logs as a person destroys the only record of who actually shipped. Labels are restricted to a-z 0-9 - _ so one can never be mistaken for an address.
Terminal window
# what is running, and who owns it
docker ps --filter label=com.docker.compose.project=logrite \
--format '{{.Label "com.docker.compose.service"}}\t{{.Status}}'
# the platform's own view, both hosts
curl -s http://127.0.0.1:8090/api/overview | python3 -m json.tool | head -40
# a job's live log
curl -s "http://127.0.0.1:8090/api/deployment/log?id=<job-id>"

Install or update the console (idempotent; restarts the unit):

Terminal window
rsync -az ops/ dev:/tmp/logrite-ops-src/
ssh dev 'cd /tmp/logrite-ops-src/management && sudo ALLOW_ACTIONS=1 ./install.sh'