Deployment
Bloom runs as a Docker container on an exe.dev VM, deployed by GitHub Actions. exe.dev's
edge terminates TLS and gives the VM a stable hostname (https://<vmname>.exe.xyz), so there's
no reverse proxy, certificates, or DNS to manage. State lives in Supabase (see
persistence.md), so the container is stateless and safe to rebuild or restart.
Telegram / GitHub -> https://<vmname>.exe.xyz (exe.dev edge: TLS + stable hostname)
-> bloom container (uvicorn, host 8080 -> container 8000, restart: unless-stopped)
-> Supabase (durable state, already remote)
Continuous deployment (GitHub Actions)
Two deployment environments, each with its own workflow and GitHub Environment. The API and the web dashboard (Cloudflare Pages) are deployed separately but paired per environment:
| Env | API workflow | Web workflow | Trigger | Target |
|---|---|---|---|---|
| dev | deploy-dev.yml | deploy-web-dev.yml | auto on push to main (+ manual) | API on the exe.dev VM; web on the bloom-dashboard Pages project - the always-current, testable live deployment |
| production | deploy-production.yml | deploy-web-production.yml | manual only (workflow_dispatch) | API on separate infra (TBD); web on the bloom-dashboard-prod Pages project - both inert until a production API is stood up |
Web environments (Cloudflare Pages). Two Pages projects, one per environment: bloom-dashboard
(dev) and bloom-dashboard-prod (production). The SPA builds same-origin; the Pages Function
reverse-proxies /api to the API named by that project's API_ORIGIN env var (dev defaults to the
exe.dev API). Production web is manual-only and inert until a production API exists - then set
the prod project's API_ORIGIN, register its OAuth redirect URI, and point the prod API's
BLOOM_DASHBOARD_URL / BLOOM_OAUTH_REDIRECT_BASE_URL at it (see deploy-web-production.yml).
Docs site (Cloudflare Pages, M45-5). The documentation site (apps/docs, Docusaurus) is a
third Pages surface with the same two-project split:
deploy-docs-dev.yml auto-publishes the built site to
the bloom-docs project after CI succeeds on main - live at
bloom-docs.pages.dev - and
deploy-docs-production.yml is manual-only
(workflow_dispatch) to bloom-docs-prod. The site is static and API-independent, so there is no
API_ORIGIN/OAuth wiring. Unlike the other Pages deploys, the docs deploy fails loud when
Cloudflare is unconfigured (missing credentials fail a preflight step; a missing Pages project
fails the wrangler publish) - it never silently skips, so a green run always means the linked docs
URL actually updated. One-time operator step per environment: create the Pages project before the
first run (wrangler pages project create bloom-docs --production-branch=main, and
bloom-docs-prod for production). The project names can be overridden with the
CLOUDFLARE_DOCS_PAGES_PROJECT / CLOUDFLARE_DOCS_PAGES_PROJECT_PROD Actions variables.
Both workflows do the same two shared steps - render the .env from Actions secrets and
variables (scripts/deploy/render-env.sh), then run a
target script that knows how to reach that environment's infrastructure. The target script
is the only provider-specific piece:
- dev →
scripts/deploy/exedev.sh(SSH to the exe.dev VM; pulls the tagged image) - production →
scripts/deploy/production.sh- a VM over SSH today, so it delegates to the same tagged-image deploy/rollback path as dev once theproductionenvironment hasDEPLOY_HOST/DEPLOY_SSH_KEY; with none set it fails fast as "not configured". A different provider is a change to this one file, keeping the sameMODE+BLOOM_IMAGE_TAGcontract.
GitHub is the source of truth for configuration. The render merges all secrets and variables
into .env on each deploy - so you set config in Actions settings, never hand-edit .env on a
server. It uses jq's @json so every value (URLs, tokens, anything with $ # " \) is escaped
exactly as the app's parser expects. On the k3s dev cluster (M24) the same source of truth
holds, but the secrets portion is delivered as Kubernetes Secrets via sealed-secrets
instead of a .env - add/rotate runbook in
deploy/k8s/README.md#secrets.
Config layering with GitHub Environments: put shared app config at the repository level
(e.g. ANTHROPIC_API_KEY, most BLOOM_*); put per-environment overrides on the dev /
production environment (they win over repo-level), so production can use a distinct bot
token, database, and domain from dev without them colliding. Deploy-only infra uses the
DEPLOY_ prefix - it is scoped to its environment and excluded from the rendered .env (along
with the injected github_token).
The dev target (exedev.sh) follows exe.dev's SSH pattern but deploys by pulling a pre-built,
SHA-tagged image rather than rebuilding on the VM (see Immutable images, deploy-by-tag &
rollback below). It streams just the compose file
and the rendered .env over the ssh exec channel (exe.dev has no scp/sftp), preserving .env,
secrets/, and the rollback bookkeeping; then it logs into the registry, runs
docker compose pull + up -d --no-build, and polls http://localhost:8080/health. The VM never
builds and needs no source checkout.
The container publishes host port 8080 (it still serves 8000 internally) to avoid exe.dev's
default server placeholder on 8000. Point exe.dev's edge at it once:
ssh exe.dev share port <vmname> 8080 and ssh exe.dev share set-public <vmname> - then
https://<vmname>.exe.xyz forwards to Bloom.
Immutable images, deploy-by-tag & rollback (M15)
Deploys are build-once, deploy-many: CI builds the API image once and the deploy runs that exact artifact, so a deploy is immutable, reproducible, and reversible.
- Build & push (CI). The
buildstage (ci-build.yml) builds the API image and, on a push tomain, pushes it to GHCR taggedsha-<12>andlatest. PRs build the image to validate it without pushing. - Deploy by tag. The deploy checks out the exact commit CI built, computes the same
sha-<12>tag, and passes it to the target script, which pullsghcr.io/hidden-claw/bloom-ai:sha-<12>and runs it with--no-build.docker-compose.ymlreferencesimage: ${BLOOM_IMAGE:-ghcr.io/hidden-claw/bloom-ai}:${BLOOM_IMAGE_TAG:-latest};build:stays only as a local/offline fallback. - Registry auth. The VM pulls the private image with the workflow's
GITHUB_TOKEN(packages: read), passed in and used for adocker login ghcr.ioat deploy time - so there is no long-lived registry secret to manage on the VM. (A restart reuses the local image and needs no pull.) - Rollback. Each healthy deploy records the running tag in
.deployed_tagand the tag it replaced in.deployed_tag_prevon the VM. The Rollback (dev) workflow (deploy-rollback-dev.yml,workflow_dispatch) redeploys.deployed_tag_prev- one click restores the previous image; the pointers swap so the action is itself reversible. Production rolls back the same way viadeploy-production.yml'smode: rollbackinput. - Auto-rollback on e2e failure (E2E-7). The post-deploy e2e stage
(
e2e.yml) tracks a separate.last_green_tag- the last image that passed e2e. When the suite passes itpromotes the running image to that pointer; when it fails after an API deploy it runsrollback_green, redeploying.last_green_tagso the dev API is never left broken (the red run + a job-summary note are the notification). This is distinct from the manual rollback above:.last_green_tagonly ever advances on an e2e pass, so it stays a known-good target even across consecutive failures. It is idempotent and a no-op until a first green exists. (A web-deploy or manual-dispatch e2e failure is not fixed by an API rollback, so those are skipped.) - Retention. After a healthy deploy the VM keeps the current + previous + last-e2e-green image tags (so every rollback target stays local, independent of the registry) and prunes older ones, then reclaims dangling layers - the disk does not grow without bound.
App rollback is not data rollback. Rolling the image back does not revert Supabase schema/data. A deploy that ran a migration cannot be cleanly undone by image alone - keep schema changes backward-compatible (Bloom already creates tables idempotently). A real data-rollback story is out of scope.
One-time setup for CD
-
Prepare the VM (once): the deploy directory is created automatically, so this is minimal.
sudo usermod -aG docker exedev # so `docker compose` runs without sudo (re-login to apply) -
Create an SSH key for CI and authorize it on the VM:
ssh-keygen -t ed25519 -f bloom-deploy -C "github-actions-deploy" -N ""# register bloom-deploy.pub with exe.dev (ssh exe.dev -> ssh-key add) so it can reach the VM -
Add the shared app configuration as repository secrets (sensitive: API keys, tokens, DB URL) and variables (non-sensitive:
BLOOM_ENV,BLOOM_LLM_MODEL,BLOOM_PUBLIC_BASE_URL, ...) - the keys from.env.example. These are the defaults both environments render into.env. -
Set up the
devenvironment (Settings -> Environments ->dev) with the exe.dev deploy secrets - scoped to the environment and kept out of the rendered.env:devenv secretValue DEPLOY_HOSTthe VM's SSH host (e.g. <vmname>.exe.xyz)DEPLOY_SSH_KEYcontents of the private key registered with exe.dev deploy-dev.ymlconnects asexedev@$DEPLOY_HOST. A push tomainthen auto-deploys to dev once CI is green. -
productionenvironment: create it for later. Put production-specific overrides there (its own bot token, DB, domain). Production is a VM over SSH today, so addDEPLOY_HOST+DEPLOY_SSH_KEY(same shape asdev) and it deploys/rolls back by tag through the sharedexedev.shpath; with none set, a manual production run fails fast with a clear "not configured" message. On a paid plan you can also add Required reviewers to either environment for an approval gate; on the free plan the gate is that production is manual-only.
Enabling the production engineer engine (M6)
BLOOM_ENGINEER_ENGINE selects who implements delegated tickets and defaults to disabled -
delegate via labels and let an external swarm or humans do the work, exactly as before M6. Turning
on in-process execution is always an explicit opt-in:
simulator- a deterministic offline engine (no real branches/PRs). Safe for demos.claude_code- the real Claude Code engineer. Needs theclaudeCLI on PATH, a writableBLOOM_ENGINEER_WORKSPACE_ROOT, and a configured GitHub App (to push + open PRs).GET /healthreportsengine.modeand a per-checkengine.readysummary, so you can confirm the mode can actually run before and after a deploy. See engineering-engine.md.
Set the mode (and, for claude_code, the knobs above) as BLOOM_* values in the same shared app
configuration as any other setting (step 3). Never enable claude_code without first checking
engine.ready is true.
Switching the LLM provider to OpenClaw (M8)
BLOOM_LLM_PROVIDER is one config value among the shared app configuration in step 3 above; this
just spells out the OpenClaw-specific rollout. OpenClaw is a pluggable LLMProvider backend only -
switching to it does not change who owns SDLC orchestration (Bloom's own workflow engine still
does; see architecture.md).
Required repository/environment config (names match .env.example and
the settings surface in apps/server/src/settings.ts):
| Name | Kind | Required | Notes |
|---|---|---|---|
BLOOM_LLM_PROVIDER | variable | yes | Set to openclaw to select this backend. |
BLOOM_OPENCLAW_BASE_URL | variable | yes, when BLOOM_LLM_PROVIDER=openclaw | The gateway's URL. No safe default - startup fails fast if unset so production never silently falls back to a local gateway. |
BLOOM_OPENCLAW_API_KEY | secret | only if the gateway requires auth | Sent as Authorization: Bearer <key>. |
BLOOM_LLM_MODEL | variable | no | The model passed to the gateway (e.g. openclaw/default). |
BLOOM_LLM_FALLBACK_PROVIDER | variable | no | Build-time safety net: if openclaw fails to build (e.g. missing base URL), build this provider instead of crashing startup. |
Rollout steps:
-
Set
BLOOM_OPENCLAW_BASE_URL(andBLOOM_OPENCLAW_API_KEYif the gateway needs one) at the repository level (shared) or on the targetdev/productionenvironment (per-environment gateway) - same config layering as any otherBLOOM_*value (step 3 above). -
Optionally set
BLOOM_LLM_FALLBACK_PROVIDER(e.g.anthropic) so a build-time config error such as a missing base URL degrades to a working provider at startup instead of crash-looping. Runtime gateway request failures are still surfaced to callers; fallback is not a retry path. -
Set
BLOOM_LLM_PROVIDER=openclawand redeploy (push tomainfor dev, or run theproductionworkflow manually). -
Confirm the deploy:
docker compose logs bloomshould show noBLOOM_LLM_PROVIDER=openclaw requires BLOOM_OPENCLAW_BASE_URL to be seterror, and nollm_provider.fallback_engagedwarning (that would mean the primary config was rejected and the fallback engaged instead). -
Optionally run the local live harness before rollout. This harness was part of the Python suite and retired with
apps/api(#808) - check out gitd69f789to run it:cd apps/apiBLOOM_OPENCLAW_LIVE_TEST=1 \BLOOM_OPENCLAW_BASE_URL=http://127.0.0.1:18789 \BLOOM_LLM_MODEL=openclaw/default \uv run pytest tests/integration/test_openclaw_provider_harness.py
OpenClaw orchestration pilot and rollback (M9)
OpenClaw orchestration is separate from the OpenClaw LLM provider above. The default runtime is still Bloom's built-in runtime, and the M9 pilot is intentionally narrow:
BLOOM_ORCHESTRATION_RUNTIME=builtin
To enable the pilot for the read-only progress refresh path only:
BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilot
BLOOM_OPENCLAW_ORCHESTRATION_PILOT_NODES=track_progress
BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fallback
Required repository/environment config (names match .env.example and
the settings surface in apps/server/src/settings.ts):
| Name | Kind | Required | Notes |
|---|---|---|---|
BLOOM_ORCHESTRATION_RUNTIME | variable | yes | builtin by default. Set openclaw_pilot only for an approved pilot rollout. |
BLOOM_OPENCLAW_ORCHESTRATION_PILOT_NODES | variable | no | Comma-separated tool-node allowlist. Default is track_progress; keep it narrow. |
BLOOM_OPENCLAW_ORCHESTRATION_PILOT_AGENT_NODES | variable | no | Comma-separated state-only agent-node allowlist (M10-5). Empty by default - no agent node is piloted out of the box. Set prd_authoring to opt in; it must also be in OpenClawGatewayNodeExecutor's own DEFAULT_NODE_AGENT_MAP. |
BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE | variable | no | fallback by default. Use fail_closed only for deliberate drills or strict pilot validation. Applies to both allowlists above. |
BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URL | variable | yes, when BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilot and BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fail_closed | The concrete orchestration-runtime executor's Gateway URL (M10-1). Separate from BLOOM_OPENCLAW_BASE_URL below - see Concrete orchestration executor config. |
BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_API_KEY | secret | only if the gateway requires auth | Sent as Authorization: Bearer <key> by the executor. Never expose it to clients, logs, issue bodies, or PR comments. |
BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_TIMEOUT_SECONDS | variable | no | Per-request timeout for the executor client (default 30). |
BLOOM_OPENCLAW_BASE_URL | variable | no for the current runtime pilot | This is the OpenClaw LLM-provider base URL (required only when BLOOM_LLM_PROVIDER=openclaw). It does not configure the orchestration-runtime executor above, even when pointed at the same gateway. |
BLOOM_OPENCLAW_API_KEY | secret | no for the current runtime pilot | The OpenClaw LLM-provider API key. Likewise does not satisfy the orchestration-executor config above. Never expose it to clients, logs, issue bodies, or PR comments. |
Controls and limits:
BLOOM_ORCHESTRATION_RUNTIME=builtindisables the pilot and routes all workflow execution through Bloom's built-in runtime.BLOOM_OPENCLAW_ORCHESTRATION_PILOT_NODESis a narrowtool-node allowlist. Keep it attrack_progressuntil additional nodes are explicitly reviewed.BLOOM_OPENCLAW_ORCHESTRATION_PILOT_AGENT_NODESis a separate, narroweragent-node allowlist (M10-5), empty by default.prd_authoringis the only reviewed state-only agent node - see docs/design/openclaw-workflow-mapping.md for why it was selected.BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fallbackruns the built-in node handler if the pilot executor fails.fail_closedraises an operator-visible error instead.- The pilot does not move webhook handling, durable job ownership, state storage, reducers, GitHub mutations, Telegram sends, PR review publication, merge decisions, or milestone closure into OpenClaw.
- A centralized, code-owned capability policy (M10-6,
bloom/workflow/openclaw_policy.py) is a third, independent gate above the two allowlists: it rejects any node that is not atool/agentnode, is side-effecting, falls outside its own hard-coded node set, or declares writes outside what that node id is allowed to write. At the end of M10 exactly two node ids can ever route through OpenClaw, regardless of operator config:track_progress(tool) andprd_authoring(agent). Widening this set requires editingopenclaw_policy.pyand its tests, never a config change alone. See docs/design/openclaw-orchestration-runtime.md.
Monitor the active runtime via /health. The response reports the configured runtime, active
runtime, pilot node allowlists, failure mode, concrete-executor availability, the capability-policy
summary, and correlation-store health. It intentionally omits OpenClaw gateway URLs, API keys,
prompts, and model responses.
Example health check:
curl -fsS http://127.0.0.1:8000/health | jq '.orchestration'
Expected default shape:
{
"configured_runtime": "builtin",
"active_runtime": "builtin",
"openclaw_enabled": false,
"pilot_nodes": ["track_progress"],
"pilot_agent_nodes": [],
"failure_mode": "fallback",
"concrete_executor": {
"configured": false,
"available": false
},
"policy": null,
"correlation_store": {
"backend": "memory",
"healthy": true
}
}
policy is null for the built-in runtime (nothing to report) and, once the pilot is active, a
secret-safe summary shaped like:
{
"policy_allowed_tool_nodes": ["track_progress"],
"policy_allowed_agent_nodes": ["prd_authoring"],
"configured_tool_nodes": ["track_progress"],
"configured_agent_nodes": [],
"last_decision": null,
"decision_counts": {}
}
Local validation before rollout:
Historical (Python-era) command blocks. The
cd apps/api/uv runcommands in the rest of this section and in the flip-readiness section below retired with the Python backend (#808); run them at git commitd69f789. The TS server ports the deterministic decision core (apps/server/src/workflow/openclawReadiness.ts, exercised by the regularpnpm run test) but does not wire the live flip-readiness CLI or the orchestration/healthsurface.
-
Run the focused runtime and API checks:
cd apps/apiuv run pytest tests/unit/test_config.py tests/unit/test_runtime.py tests/unit/test_openclaw_policy.py tests/integration/test_api.py tests/integration/test_coordination_flow.py tests/integration/test_openclaw_readiness.py -q -
Run the flip-readiness command against the config you intend to deploy (see Flip-readiness check (M10-7) below) - this is the fast, in-process gate and should be green before you go any further:
cd apps/apiBLOOM_LLM_PROVIDER=fake \uv run python -m bloom.workflow.openclaw_readiness \--runtime openclaw_pilot \--pilot-nodes track_progress \--failure-mode fallback -
Start the API with the pilot flag and the deterministic fake LLM:
cd apps/apiBLOOM_LLM_PROVIDER=fake \BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilot \BLOOM_OPENCLAW_ORCHESTRATION_PILOT_NODES=track_progress \BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fallback \uv run uvicorn bloom.api.app:create_app --factory -
In another shell, verify the runtime surface:
curl -fsS http://127.0.0.1:8000/health | jq '.orchestration'Confirm
configured_runtime=openclaw_pilot,active_runtime=openclaw_pilot,openclaw_enabled=true,pilot_nodes=["track_progress"], andcorrelation_store.healthy=true.
Staged rollout:
-
Run the flip-readiness command (step 2 above) against the exact env vars you are about to set on the
devenvironment. Do not proceed if it reportsNOT READY. -
Enable the pilot in
devwith:BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilotBLOOM_OPENCLAW_ORCHESTRATION_PILOT_NODES=track_progressBLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fallback -
Redeploy dev by merging to
mainor runningdeploy-dev.yml. -
Check
/healthon the dev API and confirm the pilot values above. -
Watch API logs for secret-safe lifecycle events:
docker compose logs -f bloom | grep -E 'openclaw_runtime|openclaw_policy|openclaw_executor'Expected event names include
openclaw_runtime.node_started,openclaw_runtime.node_succeeded,openclaw_runtime.node_failed,openclaw_runtime.cached_result, andopenclaw_policy.rejected(a policy-rejected node that was still present in the operator allowlist - should not appear unless config drifted from the M10 node set above). -
Keep
fallbackduring the first dev soak so the built-in node handler remains the safety net if the pilot executor fails.
Production enablement:
- Confirm dev has passed local checks, CI,
/health, and log review. - Set the same runtime variables on the
productionenvironment only after approving the production rollout. - Run the flip-readiness command one more time with
--runtime openclaw_pilotand the exact variables about to be set onproduction(including--executor-base-urlif set) - this is the last automated check before a real environment's config changes. - Start production with
BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fallback. - Verify production
/healthreports the pilot values, capability-policy summary, and correlation-store health. - Before switching to
fail_closed, re-run the flip-readiness command with--failure-mode fail_closedand the production executor URL -Settingsitself will refuse to boot withfail_closedand no executor URL configured, but the readiness command catches this (and the executor-availability/harness checks) before you touch the real environment. Foropenclaw_pilot+fail_closedspecifically, the command now requires a real, reachable run of the live-gateway harness against that exact executor URL by default -/healthreportingconcrete_executor.available=Truefrom wiring/config alone is not sufficient, so a fake or unreachable executor URL correctly reportsNOT READYinstead of a falseREADY. Do not run this step with--skip-live-gateway/--offline-onlyagainst production - that flag is an explicit, visibly-marked non-production simulation and never approves a fail_closed production flip; see Flip-readiness check (M10-7) below. Only flip tofail_closedfor deliberate operator drills or once the pilot node's failure handling has been reviewed for the target environment.
Emergency rollback:
- Set
BLOOM_ORCHESTRATION_RUNTIME=builtin. - Redeploy or restart the API process.
- Confirm
/healthreportsconfigured_runtime=builtin,active_runtime=builtin, andopenclaw_enabled=false.
Rollback does not require a database migration. Existing runtime correlation rows can remain for
audit; Bloom continues from persisted RunState through the built-in runtime.
Milestone completion criteria:
- The built-in runtime remains implemented, tested, and documented as the default.
- OpenClaw orchestration remains a pilot behind explicit config.
- Removing the built-in runtime is not part of M9 and must be scheduled as a separate, explicitly-approved milestone if it ever becomes desirable.
M10 completion (concrete executor, agent-node pilot, capability policy, flip-readiness). At the end of M10:
- A real, gateway-backed
OpenClawGatewayNodeExecutor(not a fake) is wired intocreate_app. - The pilot covers exactly two nodes -
track_progress(tool) andprd_authoring(agent) - enforced in code by the capability policy, independent of operator config. - Unsupported and side-effecting workflow nodes (
human,event_wait,map,router, GitHub mutations, Telegram sends, deploy/CI triggers, PR review publication, merge decisions, milestone closure - see Exact M10 Routable Node Set) remain built-in-only; enabling any of them is out of scope for M10 and requires a deliberate, separately-reviewed change toopenclaw_policy.py. - A machine-checkable flip-readiness command exists and is part of the rollout procedure above.
- Next recommended expansion milestone (not started): pilot one additional read-only or advisory node - candidates noted in Pilot Path Recommendation include a status/monitoring answer path or a pre-publication specialist-review draft - through the same double-allowlist-plus-capability-policy pattern, only after this M10 pilot has run in production behind the flip-readiness check for a full soak period.
Flip-readiness check (M10-7)
bloom.workflow.openclaw_readiness is a machine-checkable readiness gate operators run before
switching an environment's BLOOM_ORCHESTRATION_RUNTIME to openclaw_pilot - and especially
before BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fail_closed, where a misconfiguration is no
longer silently absorbed by the built-in fallback. (Python-era CLI - retired with apps/api
(#808), run it at git d69f789; the deterministic decision core is ported in
apps/server/src/workflow/openclawReadiness.ts.)
cd apps/api
uv run python -m bloom.workflow.openclaw_readiness \
--runtime openclaw_pilot \
--failure-mode fail_closed \
--pilot-nodes track_progress \
--pilot-agent-nodes prd_authoring \
--executor-base-url http://127.0.0.1:18789
The --runtime/--failure-mode/--pilot-nodes/--pilot-agent-nodes/--executor-base-url flags
override the corresponding Settings field for this check only - they never write to .env
or the process environment, so this is safe to run against a target environment's real
configuration to preview a flip without changing it. Omit a flag to check whatever that field is
currently set to (from the process environment / .env) - useful for verifying an already-live
config post-deploy.
What it checks, all in-process (no separate server; a live OpenClaw gateway is only required for
openclaw_pilot + fail_closed - see the live-gateway bullet below):
- Config:
Settingsconstructs without error (this alone exercises the existing fail_closed-without-executor-url guard), the configured pilot allowlists are a subset of the M10 capability policy's hard-coded node set, and (forfail_closed) the executor base URL is configured. - Health:
GET /health(built via the realcreate_app, called in-process) reportsstatus: ok. - Active runtime:
orchestration.active_runtime == "openclaw_pilot"andopenclaw_enabled == true. - Executor availability:
orchestration.concrete_executorreports bothconfiguredandavailable. This reflects wiring/config only, not real reachability - it can betrueeven when the configured URL is fake or unreachable, which is exactly why the live-gateway check below exists and is required forfail_closed. - Correlation-store health:
orchestration.correlation_store.healthy. - Policy wiring: the live runtime's capability-policy summary matches the configured allowlists (catches stale wiring, not just stale config).
- The OpenClaw harness (offline): an offline (
httpx.MockTransport) run of thetrack_progressexecution harness (bloom.workflow.openclaw_track_progress_harness) through the realOpenClawPilotRuntime->OpenClawGatewayNodeExecutor->OpenClawExecutorGatewayClientcode path - proving request shape, idempotency reuse, and Bloom's own reducer application still work, with no live gateway or network access. This check always runs and is always required. - The OpenClaw harness (live gateway): the same harness against the actual configured
--executor-base-url(orBLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URL) - the only check that proves the configured executor URL is genuinely reachable and executable, not just syntactically configured.- Required by default for
openclaw_pilot+fail_closed. A fake or unreachable executor URL now correctly reportsNOT READY;/health'sconcrete_executor.availablealone is never sufficient for a fail_closed flip. - Optional everywhere else (
fallbackmode, orbuiltinruntime): opt in withBLOOM_OPENCLAW_EXECUTOR_LIVE_TEST=1plus an executor base URL (mirrorstests/integration/test_openclaw_track_progress_harness.py's live check); otherwise it is skipped and marked[WARN]/optional in the output. - Explicit offline-only escape hatch: pass
--skip-live-gateway(alias--offline-only) to skip the required live check foropenclaw_pilot+fail_closed- for CI/docs dry runs only. This is always visibly marked in the report (offline_only_simulation: truein--jsonoutput) and in the final banner (READY (OFFLINE-ONLY SIMULATION)instead of plainREADY). It does not approve a fail_closed production flip - never use it against the real production executor URL; re-run without it against the real gateway before flipping production.
- Required by default for
The command prints one [OK]/[FAIL]/[WARN] line per check (add --json for a machine-readable
report) and exits 0 only when every required check passes; exits 1 with a NOT READY message
on stderr otherwise. No check output ever includes an API key or a gateway URL. Unit/integration
coverage lives in tests/integration/test_openclaw_readiness.py, including dedicated tests
asserting: no configured secret ever appears in the rendered report; a fake/unreachable executor
URL is NOT READY by default under fail_closed; and --skip-live-gateway/--offline-only is
visibly marked as a non-production simulation.
Concrete orchestration executor config (M10-1)
The concrete gateway-backed executor has its own config surface and a defined contract (endpoint,
request/response shape, idempotency, timeout, auth posture, unsupported-node behavior) in
docs/design/openclaw-orchestration-runtime.md.
create_app wires it when BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URL is configured, and falls
back to UnavailableOpenClawNodeExecutor otherwise.
BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URL=
BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_API_KEY=
BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_TIMEOUT_SECONDS=30
This config is deliberately separate from BLOOM_OPENCLAW_BASE_URL/BLOOM_OPENCLAW_API_KEY (the
OpenClaw LLM-provider config, BLOOM_LLM_PROVIDER=openclaw) - the two are validated and consumed
independently, even when they happen to point at the same physical gateway.
Validation and health behavior:
-
Startup fails fast with an actionable error if
BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilotandBLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fail_closedare set withoutBLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URL:BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilot with BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fail_closed requires BLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URL to be set. ... -
BLOOM_OPENCLAW_ORCHESTRATION_FAILURE_MODE=fallbacknever requires this config - it stays deployable with no OpenClaw executor present at all. -
/health'sorchestration.concrete_executorreportsconfigured(whetherBLOOM_OPENCLAW_ORCHESTRATION_EXECUTOR_BASE_URLis set) andavailable(whether the runtime's wired executor is actually usable). With no executor configured, both arefalse:curl -fsS http://127.0.0.1:8000/health | jq '.orchestration.concrete_executor'{"configured": false,"available": false}
GitHub App private key (file form)
The App key is not part of .env; the bloom (and swarm) container mounts ./secrets
read-only at /app/secrets, and BLOOM_GITHUB_APP_PRIVATE_KEY_PATH points at the in-container
path. exe.dev's SSH server has no scp/sftp, so stream the .pem over an exec channel with
cat (from your local machine, once):
ssh exedev@<vmname>.exe.xyz \
'mkdir -p /home/exedev/bloom-ai/secrets \
&& cat > /home/exedev/bloom-ai/secrets/github-app.pem \
&& chmod 644 /home/exedev/bloom-ai/secrets/github-app.pem' \
< your-app.private-key.pem
BLOOM_GITHUB_APP_PRIVATE_KEY_PATH=/app/secrets/github-app.pem # in-container path
The deploy preserves secrets/ across syncs, so this is a one-time copy.
k3s dev runtime (M24 - parallel to compose until cutover)
M24 stands up a single-node k3s cluster on the same dev VM as the future
runtime for dev. It runs alongside the compose deploy above - compose stays the live path
(exe.dev edge -> 8080) until the M24-5 cutover; k3s owns 6443 (API), 10250 (kubelet), and 80/443
(the built-in Traefik ingress, kept enabled - decision + rationale in
deploy/k8s/README.md). No ports overlap.
-
Bootstrap / upgrade (idempotent; pin with
K3S_VERSION, orK3S_CHANNEL, defaultstable):ssh exedev@bloom-server.exe.xyz 'bash -s' < deploy/k8s/bootstrap-k3s.shInstalls/upgrades k3s, waits for the node
Ready+ core system deployments, writes~/.kube/configfor the login user, and asserts thebloomnamespace +ghcr-pullimagePullSecret.bootstrap-k3s.sh statusre-checks health;bootstrap-k3s.sh ghcr-secretrefreshes just the pull secret. -
GHCR pulls: the private API image pulls through the
bloom/ghcr-pullimagePullSecret. GHCR rejects GitHub App installation tokens, and the VM keeps no long-lived registry credential (same posture as the compose deploy) - so CI refreshes the secret with its ephemeral job token at deploy time (M24-5), and app pods useimagePullPolicy: IfNotPresent(M24-2) so restarts reuse the local image, exactly like compose restarts do today. -
Teardown (removes the cluster and all its state; compose unaffected):
ssh exedev@bloom-server.exe.xyz 'sudo /usr/local/bin/k3s-uninstall.sh' -
App manifests (M24-2): the
bloomHelm chart underdeploy/helm/bloomis the single source of truth for the API, web, and (guarded, off by default) swarm-simulator workloads - probes on the API's real/health, config via ConfigMap, secret refs left optional until M24-4, and the image tag pinned per deploy to the commit SHA (--set image.tag=<sha>), which is what keepshelm rollbackequivalent to the M15 image rollback. CI lints, renders, and unit-tests the chart on every PR. -
Env overlays + ingress/TLS (M24-3): one overlay per environment (
values-dev/-staging/-prod.yaml) so promotion is the samehelm upgradewith the next values file - never a manifest rewrite. Dev rides Traefik behind the exe.dev edge (which terminates TLS); staging/prod terminate TLS in-cluster via templated cert-manager Issuer/Certificates. What changes per env vs. what stays identical: the "Environment overlays & promotion" section of the chart README. -
Platform provisioning as code (M24-8): the OpenTofu module under
deploy/tofuprovisions the staging/prod substrate the overlays above assume - DOKS cluster + node pool, DNS, ingress-nginx + cert-manager bootstrap, and the Neon preview project - one module, per-env tfvars ("staging looks like prod"). Authored + offline-validated only ($0): CI runstofu fmt/init -backend=false/validateon every PR;plan/applyand the remote state store are M25-1 (#400).
CI deploy via Helm + rollback (M24-5)
deploy-k3s-dev.yml deploys main to the k3s
cluster on the same trigger as the compose deploy (CI success on main, plus manual dispatch)
and shares the deploy-dev concurrency lock, so the two deploys and the secret sync always
serialize. Until the cutover below, both deploys run on every merge - compose stays the
live path behind the exe.dev edge while the helm deploy proves parity. Each run:
- syncs the sealed secrets (
scripts/deploy/k8s-secrets-dev.sh, M24-4), so pods always roll out with the current Actions secrets; - runs the helm target
scripts/deploy/helm-dev.sh, which refreshes theghcr-pullimagePullSecret with the workflow's ephemeralGITHUB_TOKEN, ensures the pinned + checksum-verified helm CLI on the VM (deploy/k8s/helm-cli.sh), streams the chart over the SSH exec channel, and runshelm upgrade --install bloom -n bloom -f values-dev.yaml --set image.tag=sha-<12> --wait; - health-gates success twice:
--waitholds until the new pods pass their readiness probes (the API's realGET /health), then an endpoint check curls/healththrough Traefik with the dev ingress host - the exact request the exe.dev edge forwards after the cutover. Either failing turns the run red with pod/log diagnostics; nothing is recorded as deployed.
The SHA-pinned tag keeps the M15 immutable-deploy semantics: helm's in-cluster revision history
replaces the compose path's .deployed_tag/.deployed_tag_prev files. Rollback is the
Rollback (dev, k3s) workflow
(deploy-rollback-k3s-dev.yml): one click
runs helm rollback to the previous revision (or a chosen one via the revision input),
through the same probe + endpoint health gate. On the VM, helm history bloom -n bloom lists
revisions and helm get values bloom -n bloom --revision <n> shows the SHA a revision pinned.
The web workload stays --set web.enabled=false in this deploy: no
ghcr.io/hidden-claw/bloom-ai-web image is published yet, and dev web keeps shipping to
Cloudflare Pages (deploy-web-dev.yml) - unchanged by the cutover below.
Post-deploy e2e on dev/staging: dormant wiring + activation (M24-9)
After a dev deploy, E2E (dev) (e2e.yml) runs the full
Playwright suite (apps/web/e2e) against the deployed dashboard;
E2E (staging) (e2e-staging.yml) is its
manual-dispatch staging twin until M25 ships a staging deploy workflow to hook. Both are thin
callers of the reusable E2E (post-deploy) stage
(e2e-post-deploy.yml), which authenticates
through the password-gated test-login path and hands the verdict to
scripts/deploy/e2e-promote-rollback.sh (E2E-7):
- pass → promote the running dev image to "last e2e-green" (the rollback target);
- fail after
Deploy (dev)→ compose rollback to the last e2e-green image; - fail after
Deploy (dev, k3s)→helm rollbackto the previous revision; - anything else (web deploy, manual dispatch, unarmed env, staging until M25) → clean no-op.
The whole decision matrix is unit-tested offline (stubbed ssh, no live deploy):
scripts/deploy/tests/test_e2e_promote_rollback.sh, run by CI's deploy-scripts gate and
make e2e-verdict-test.
Everything ships dormant. With no E2E_BASE_URL / E2E_STAGING_BASE_URL repository
variable set, the workflows skip - and the committed chart overlays keep test-login off by
omission (values-dev.yaml), so merging code can
never silently expose the test-login form on a public site.
Activation runbook (operator, per environment) - dev shown; staging is identical with the
staging environment and E2E_STAGING_BASE_URL:
- Arm the API's test surface. Set two Actions secrets in the
devenvironment (Settings → Environments → dev):BLOOM_ENABLE_TEST_LOGIN=trueand a strong randomBLOOM_TEST_LOGIN_PASSWORD(e.g.openssl rand -base64 30). Run Sync secrets (dev) and restart the pods (kubectl -n bloom rollout restart deploy, or re-run the deploy workflows - pre-cutover, the live compose path needs a Deploy (dev) run to re-render its.env). Key-delivery details: deploy/k8s/README.md. - Verify the gate:
curl -s https://bloom-server.exe.xyz/api/auth/configreturns"testLogin": trueand"testLoginRequiresPassword": true. - Point the suite at the env. Set the repository variable
E2E_BASE_URLto the deployed origin (https://bloom-server.exe.xyzfor dev); staging usesE2E_STAGING_BASE_URL. Repo-level variables, deliberately: a job-levelifcannot read environment-scoped variables, so the dormant gate keys off repo variables. - Dispatch one run of E2E (dev) and expect green - including the "Promote image to last e2e-green" step recording the running tag. From then on every dev deploy triggers the suite automatically (staging stays dispatch-only until M25).
Security note - the visible-but-locked test-user form. Enabling test-login makes a "test
user" sign-in form visible on that environment's login page (login.tsx). It is locked behind
the password (X-Bloom-Test-Login-Password gates test-login and every /api/testing/*
route), and it is structurally impossible in production:
config.py::_forbid_test_login_in_production refuses to construct a production config with it
enabled (prod gets a separate unauthenticated smoke instead, #424). Showing the form on
dev/staging is the deliberate ops decision the activation makes. Disarm by unsetting the
repo variable (the suite goes dormant again) and deleting the two environment secrets, then
re-running the sync + a rollout (the form disappears).
Prod post-deploy smoke: unauthenticated checks + security guard (M24-10)
Production cannot run the authenticated E2E lane above - test-login is structurally forbidden
there - so Smoke (prod) (smoke-prod.yml) is prod's
post-deploy verdict. It fires after Deploy (production) / Deploy Web (production)
completes successfully (plus manual dispatch), with the same non-blocking posture as E2E (dev):
a failure is a red run + step summary, never a block on the deploy that already happened.
The checks (scripts/deploy/smoke-prod.sh) use only public,
unauthenticated surface - no credentials, no secrets, no environment: binding:
GET /health→ 200 with the version + engine readiness fields;GET /api/auth/config→ 200 withtestLogin:false- the security regression guard: a build that ever ships prod with the test-login bypass reachable fails loudly here (it fails closed - a missing key or unreadable body also reds the run);GET /→ 200 with the login page shell (the SPA mount<div id="root">+ Bloom title);- every
/assets/*.js|cssbundle the shell references → 200 (the render preconditions).
All checks run (no fail-fast) so one red run shows the whole picture. The matrix is
unit-tested offline against a stub curl:
scripts/deploy/tests/test_smoke_prod.sh, run
by CI's deploy-scripts gate and make smoke-prod-test.
Rollback on a red smoke is manual, by design - the deliberate opposite of dev's
auto-rollback-to-last-e2e-green: prod deploys are manual dispatches with a human already at
the wheel (dev auto-deploys on every merge, so nobody is watching); prod keeps no "last
e2e-green" bookkeeping (only the dev e2e lane advances it, and
scripts/deploy/e2e-promote-rollback.sh rejects prod outright); and an unauthenticated smoke
is too shallow a signal to auto-revert production on - a transient edge/CDN blip must not
flap the prod release. A red run's summary prints the manual path: Deploy (production)
with mode: rollback for the API, and the Cloudflare Pages deployment list (or a re-run of
Deploy Web (production) from the last good commit) for web.
Ships dormant until M25 stands prod up. Activation (operator):
- Set the repository variable
SMOKE_PROD_BASE_URLto the prod dashboard origin (repo-level deliberately - a job-levelifcannot read environment-scoped variables). - While prod web is Cloudflare Pages, also set
SMOKE_PROD_API_BASE_URLto the prod API origin: the Pages proxy (apps/web/functions) forwards/api/*only, and/healthis not under/api. A single-origin prod (an ingress routing/healthlike dev's k3s ingress) needs only the one variable. - Dispatch one Smoke (prod) run and expect green. From then on every prod deploy triggers it automatically.
An authenticated prod check (a synthetic real-OAuth account or signed monitoring token) is a deliberate follow-up, not part of this lane.
Cutover runbook: compose -> k3s (the documented switch)
The compose deploy is kept until k3s parity is verified, then retired:
-
Parity soak. Over a few merges to
main, both deploy workflows stay green. On the VM, the two runtimes answer identically:curl -fsS http://127.0.0.1:8080/health # composecurl -fsS -H 'Host: bloom-server.exe.xyz' http://127.0.0.1:80/health # k3s via Traefik -
Flip the exe.dev edge from compose (8080) to Traefik (80):
ssh exe.dev share port bloom-server 80 -
Verify
https://bloom-server.exe.xyz/healthis served by k3s (kubectl -n bloom logs deploy/bloom-apishows the requests; compose logs stay quiet). Telegram/GitHub webhooks and the Pages/apiproxy follow automatically - same hostname, and the dev ingress routes/api,/webhooks,/health,/metricsto the API. -
Retire the compose path (a follow-up PR + VM cleanup once the flip has soaked): remove the
workflow_runtrigger fromdeploy-dev.yml(keepingworkflow_dispatchas a temporary escape hatch, then delete the workflow together withdeploy-rollback-dev.yml), retire the compose branches of the post-deploy e2e verdict handler (scripts/deploy/e2e-promote-rollback.sh- theDeploy (dev, k3s)trigger already rolls back via helm (M24-9), but promote and theDeploy (dev)rollback still act on the compose stack's bookkeeping), and stop the stack on the VM:cd ~/bloom-ai && docker compose down. -
Undo (if needed). The flip is one command to reverse -
ssh exe.dev share port bloom-server 8080restores compose as the live path (do not stop the compose stack until the soak is over).
Until step 4 lands, the compose deploy is deprecated but live; after it, the helm deploy is the only dev path (production/staging follow in M25).
Admin dashboard: production posture (M27)
The admin console is one codebase with a deploy-time mode switch, not a second service (design
3.3). BLOOM_ADMIN_API_MODE decides what an API instance serves:
| Mode | Serves | Use |
|---|---|---|
disabled | User surface only; /api/admin/* is never mounted (404, indistinguishable from absent) | The user-facing production API |
only | Admin surface only (+ /health, /metrics); user/webhook routers absent; scheduler + job-runner never start | The admin production API instance |
mounted (default) | Both surfaces on one instance | Dev / combined deployments |
The two-origin model. The user dashboard and the admin console are different registrable
origins (bloom-web vs bloom-admin Pages projects; deploy-admin-dev.yml /
deploy-admin-staging.yml publish the latter). Each SPA reverse-proxies /api/* first-party
through its own Pages Function, so each origin holds exactly one host-only session cookie:
bloom_admin_session (Secure, HttpOnly, SameSite=Lax, 12h TTL, never a Domain attribute)
exists only on the admin origin, and nothing in the user origin's cookie jar or storage
authorizes anything on the admin surface - and vice versa. The admin origin is deliberately
absent from the API's CORS allowlist; it never needs an entry because the proxy keeps it
same-origin.
Production checklist (admin instance, BLOOM_ADMIN_API_MODE=only):
BLOOM_ADMIN_DASHBOARD_URL- the admin origin; the OAuth callback, the access-denied redirect, and the origin-check allowlist all derive from it. Register<origin>/api/admin/auth/google/callbackin the Google OAuth client alongside the user one.BLOOM_ADMIN_EMAILS- bootstrap superadmin seeding (idempotent; thereafter grants are managed in the UI and audited).- Hardening knobs (M27-14, on by default whenever the admin surface is mounted): the
origin-check middleware 403s any state-changing
/api/adminrequest whoseOrigin/Refereris not the admin origin (extras viaBLOOM_ADMIN_ALLOWED_ORIGINS, normally unset) and refuses non-JSON mutation bodies;/api/admin/auth/*is rate limited per client IP (BLOOM_ADMIN_AUTH_RATE_LIMIT_REQUESTS/..._WINDOW_SECONDS, default 20 per 60s). Run uvicorn with proxy headers enabled behind the edge so the limiter keys on real client IPs, not the proxy's. - Forbidden in production by boot-time validators (
Settingsrefuses to construct):BLOOM_ENABLE_TEST_LOGIN,BLOOM_ADMIN_TEST_GRANT,BLOOM_ADMIN_TEST_OIDC. These are the E2E-only surfaces; a misconfigured production deploy fails fast instead of running them. - The user-facing API instance sets
BLOOM_ADMIN_API_MODE=disabledand needs none of the admin variables.
CSP / response headers. Every response from the admin origin - static assets via
apps/admin/public/_headers, proxied /api responses via the Pages Function - carries the
strict header set defined once in apps/admin/security-headers.ts (each directive's
justification is documented there; apps/admin/src/test/csp-parity.test.ts fails CI if the
two surfaces drift).
Threat-model verification: every §4.4 STRIDE row's mitigation is pinned to a test or
documented check in docs/security/admin-threat-model.md.
Cloudflare Access in front of the admin origin (edge second factor)
Al's M27-14 decision: the admin origin gets Cloudflare Access as an edge-level second
factor - an attacker (or a stolen Google session) must also pass Access before a single byte
of the admin SPA or its /api proxy is served. The free Zero Trust tier covers this team
size.
Status: configured-as-documentation, not yet enforced. The admin origin (
bloom-admin.pages.dev) first deploys after them27 -> mainmerge, and Access rides the owner's Cloudflare Zero Trust account - so live enforcement is the operator's post-deploy step below. Nothing in CI asserts Access yet; do not treat this section as evidence of enforcement.
One-time prerequisite (free tier): Zero Trust setup
on the Cloudflare account that owns the Pages projects - pick a team domain
(<team>.cloudflareaccess.com). The default One-time PIN login method is sufficient (the
allow-list below pins emails; the PIN proves control of the inbox); adding Google as an
Access identity provider is optional polish.
Dev + staging (*.pages.dev origins) - dashboard procedure (the Pages-integrated path
Cloudflare supports for pages.dev hostnames):
- Cloudflare dashboard -> Workers & Pages ->
bloom-admin(dev; repeat for the staging project) -> Settings -> Access policy -> Enable. This creates a Zero Trust Access application protecting the project's preview deployments (*.bloom-admin.pages.dev). - In Zero Trust -> Access -> Applications, edit that application and add the production
hostname
bloom-admin.pages.devto its domains, so previews AND the live origin are both gated. - Edit the application's policy: action Allow, include Emails = the same list as
BLOOM_ADMIN_EMAILS(plus any granted operators/viewers). Session duration: 12h or less, never longer than the admin cookie TTL - Access must expire first or with it.
Production custom domain (admin.<domain>, once one exists in the account's zone) -
config-as-code via the API (reproducible; also works for staging on a custom domain):
# ACCOUNT_ID + a token with Access: Apps and Policies Edit. Re-runnable: POST once, then
# manage by id (GET /accounts/$ACCOUNT_ID/access/apps lists existing apps).
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/access/apps" \
--request POST \
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
--json '{
"type": "self_hosted",
"name": "Bloom Admin (production)",
"domain": "admin.example.com",
"session_duration": "12h",
"policies": [
{
"name": "Bloom admins",
"decision": "allow",
"include": [
{ "email": { "email": "al@example.com" } }
]
}
]
}'
Keep the include email list in lockstep with the grant table's superadmin/operator set;
Access is the outer gate, the in-app bloom_admin_grants check stays authoritative for
roles.
Post-deploy verification (the operator's step, after m27 -> main deploys the admin
origin):
curl -sI https://bloom-admin.pages.dev/- expect a302tohttps://<team>.cloudflareaccess.com/...(the Access login), not the SPA. An in-private browser must hit the Access gate before the Bloom admin login page.- Complete Access with an allow-listed email, then the normal admin Google sign-in; confirm
/api/admin/mesucceeds end-to-end through both gates. - Confirm a non-allow-listed email is refused by Access (never reaching the origin at all).
- Run the admin E2E suite against the deployed dev admin
(
E2E_BASE_URL=https://<dev-admin-origin> pnpm run test:e2efromapps/admin; seeapps/admin/e2e/README.mdfor the flags the target API needs, and note the deployed run requires an Access service token or a temporary bypass policy for the suite's traffic - CI's per-PR runs use the local stack precisely so they need neither).
Operations
- Deploy: push to
main(auto-deploys dev once CI is green), or run the workflow from the Actions tab. The deploy pulls the CI-built image by its commit-SHA tag. Until the M24-5 cutover this fires both dev deploys - compose (live) and k3s/Helm (parity) - see the cutover runbook above. Production is manual (workflow_dispatch). - Rollback: run Rollback (dev) from the Actions tab to redeploy the previous image on
the compose path (
.deployed_tag_prev), or Rollback (dev, k3s) forhelm rollbackon the k3s path; production usesDeploy (production)withmode: rollback. All are health-gated and reversible. (Rolling the app back does not roll back the database - see the data-rollback note above.) - Restart / logs (on the VM):
docker compose restart·docker compose logs -f bloom - Auto-recovery:
restart: unless-stoppedrestarts a crashed container; with the Docker service enabled, the stack also comes back after a reboot.
Local run
cd apps/server && pnpm run dev # the TS server with autoreload (or `make run` at the repo root)
To exercise the production image locally, build it from the repo root
(docker build -f apps/server/Dockerfile .); the dev/prod deploys never build - they pull the
CI-built image by tag. docker-compose.yml no longer defines workloads (the compose stack
retired with the k3s cutover, #807). See also coordination.md for the
retired engineer-swarm simulator.