Skip to content

The CLI

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

scripts/forge-deploy is the only thing that is allowed to change what runs on a box. Everything else — the npm bootstrapper, the upgrade wrapper, the in-app Updates screen, the LAN panel — is a front door that eventually calls it with a fixed argument list, so none of them can skip a gate the script enforces. This page is the reference for the script itself: how it decides what it is talking to, what each subcommand does, and the rules it will not let you break.

The operator narrative — when to upgrade, what to check first, what rollback cannot undo — is on the hub: Operations and Troubleshooting and Upgrades and Rollback.

Front doors

There is one mechanism and four ways in.

Entry What it is
npx @armoryworks/forge-deploy The npm bootstrapper. On a machine with no Forge it fetches a pinned deploy tree and runs first-time setup; on a machine that already runs Forge it finds the existing tree and opens the console in place, downloading nothing
forge-deploy The CLI itself, once scripts/install-forge-deploy.sh has put it on the path. No arguments opens the guided console
forge-upgrade.sh A wrapper: refresh the deploy tree from npm, then run the gated deploy. Installs jq if missing and takes care of a /opt that needs one sudo
Admin → Updates in the app The API decides who may upgrade and what is audited; the host agent in agent/ executes it as a fixed argv against this CLI. The command line stays the recovery path when the app will not start

The bootstrapper only searches when you give it nothing. A bare npx @armoryworks/forge-deploy looks at the deploy state file's recorded tree root first, then $FORGE_DEPLOY_DIR, then the current directory, ./forge-deploy, /opt/forge-deploy, /opt/forge and ~/forge-deploy. A directory argument, a subcommand, or any setup.sh flag means you have already said what you want, and the search is skipped. If the box has deploy state but the tree is nowhere on that list, it refuses rather than starting a second install beside the first — point it at the tree instead.

The tree ref the bootstrapper fetches is a release tag, never main. A 404 on that tag is reported as a 404; it will not silently fall back.

The guided console versus the operator flags

forge-deploy with no arguments is the console, and it is deliberately the whole customer-facing surface. It reads the machine, reports in plain language, and offers a short numbered menu whose entries depend on what it found: update the tool first, look at what is unhealthy, upgrade to a named release, check the machine and fix what can be fixed, find out why nobody can reach the box, show deploy history, quit. Exactly one entry is marked (recommended), chosen by urgency — stale tooling, then unhealthy containers, then being behind.

Two things about the console are worth knowing before you rely on it:

  • It runs softer checks than every other subcommand. The flag paths call an internal preflight that hard-fails on a missing compose file, a missing .env, an unwritable state file or log. The console does not, because a half-built box is exactly the state it exists to describe. What it does insist on is jq and curl: every state read goes through jq, and without it the console would read a working install as one that was never set up.
  • It is honest about not knowing. If the registry cannot be reached it says it cannot tell whether you are up to date, rather than reporting "current".

Everything below the console is operator tooling. On a non-interactive stdin the console prints what it would have offered and exits without asking.

Subcommands

Command What it does
forge-deploy The guided console
forge-deploy <tag> Deploy an immutable tag to this box's components, through the full gated path
forge-deploy --update Deploy the newest release published for every component this box runs
forge-deploy --update --check Report only. Exit 0 current, 10 behind — the form for cron and monitoring
forge-deploy --pick Per-component release picker: Enter keeps the current tag, r re-runs setup, q quits
forge-deploy --service <api|ui|test|demo|all> Narrow to one service, constrained to this box's scope
forge-deploy --list / --releases / --builds Recent versions. The default pairs each X.Y.Z with the main-<sha> build sharing its manifest digest
forge-deploy --status Deployed tag and container health for this box, and it flags drift between the recorded deploy and the running tag
forge-deploy --rollback Re-pin the previously deployed application tag. Read the hub's caveat first
forge-deploy --reconcile [tag] DB box only. Apply the declarative schema to the local database
forge-deploy --logs Tail the deploy history log
forge-deploy --setup [--role …] Topology wizard, or unattended role setup — see Topologies
forge-deploy --components [list] Show or set which versioned components this box deploys
forge-deploy --wizard Interactively remove installed components from this box
forge-deploy --up Bring up only this box's components
forge-deploy compose <args> Scope-aware docker compose passthrough
forge-deploy --remote-api, --remote-db, --edge Split-topology wiring — see Topologies
forge-deploy --recover The fix-and-resume doctor — see Recovery
forge-deploy --fresh-start Wipe everything on this box and set up from scratch — see Recovery
forge-deploy --self-update git pull the tree and reinstall the CLI. Refuses on modified tracked files

The --up rule

Bring the box up with forge-deploy --up, not docker compose up. This is not style. Three things go wrong with bare compose:

  1. Scope. A box that was narrowed with --wizard or --setup --role has its unwanted services profiled out in a generated overlay. Bare compose without that overlay starts them, and keeps starting them on every restart.
  2. Overlays. setup.sh writes public and TLS port bindings into docker-compose.override.yml. The CLI passes an explicit -f list, which disables compose's automatic loading of that file — so the CLI lists it deliberately. Hand-build a compose invocation that omits it and the next recreate quietly puts an exposed site back on loopback.
  3. Floating tags. --up refuses to start when SERVER_IMAGE_TAG or UI_IMAGE_TAG is latest.

--up starts the core services in dependency order — database, object storage, backup sidecar, API, UI — and skips whatever is scoped out. Profile-gated extras (AI, TTS, logging, signing, the demo and test SPAs) are never started by it; they are opt-in through COMPOSE_PROFILES. When the UI is kept but the API is scoped out, --up adds --no-deps so compose cannot drag the removed API back in.

forge-deploy compose <args> is the escape hatch and takes any compose verb. It assembles the same -f set the deploy path uses: base, then the cohost overlay when QBE_HOSTING_MODE=cohost, then prod, then the blue/green file, then docker-compose.override.yml, then the remote-API forwarder, then the per-box scope overlay last so its profiles win. Use it for compose ps, compose logs -f forge-api, compose stop, and for anything a runbook tells you to do with compose.

Component scope

A box declares which versioned components it deploys in FORGE_DEPLOY_SERVICES — a space or comma list drawn from api ui test demo. Unset means all of them, which is the legacy all-in-one behaviour. The literal none declares a database box that runs Postgres, object storage and the backup sidecar and deploys no versioned component at all.

Scope is enforced, not advisory: --service ui on a box whose scope is api is refused with the scope named, so an API box cannot accidentally deploy the UI. --list and --status are scoped the same way, which is why an API and database on another machine are invisible to a UI box's tooling.

Two rough edges here:

  • --components will not accept none. It validates tokens against the service list plus all, so the database-box declaration has to be written into .env by hand or left by --setup --role db.
  • --setup --role leaves demo and test in the scope. The role table only scopes out the containers a role does not run, and the demo and test SPAs are not in any role's exclusion list — so a db role ends up declaring demo test rather than none, and other roles carry them too. They are profiled off and never start, but --update still intersects release tags across every declared component, so a component with no matching release turns into "no single release covers every part of this system yet." Check forge-deploy --components after running the topology wizard and narrow it to what the box really deploys.

Which tags deploy

Only immutable tags: a semver release (X.Y.Z, optionally with a pre-release suffix) or a build tag of the form main- plus seven hex characters. latest is refused at deploy time and again at start-up, because a floating tag turns a restart into an unplanned upgrade. Anything else is rejected as a malformed tag before anything is touched.

Deploying a semver tag also pins SCHEMA_IMAGE_TAG to the same release and turns on ENABLE_SCHEMA_RECONCILE, so schema and application move in lockstep. Deploying a build tag does neither — the hub explains why that matters in Upgrades and Rollback.

The registry host, owner and scheme are all overridable by environment variable so the whole deploy path can be rehearsed against a throwaway registry without publishing to the real release line. Every registry call is bounded by a timeout, so a registry that accepts a connection and then goes quiet cannot wedge a deploy.

What a deploy actually does

Per component, in api → ui order: verify the tag exists in the registry → rewrite the pin in .env → pull → (API only) pre-reconcile backup and schema reconcile → recreate → wait for healthy → on failure re-pin the previous tag and recreate. A failed tier stops the tiers behind it, and that ordering is a safety property, not a convenience.

The health gate is what makes a failed upgrade self-correcting. For the API it polls the composite health endpoint through the bound port; for other services it reads the container's own health status, falling back to "running" where no healthcheck is defined. The timeout defaults to five minutes and is tunable with HEALTHCHECK_TIMEOUT_SECS, because first boot on a populated database legitimately takes a while and a premature rollback is worse than a slow gate.

The UI is the one tier that can move without downtime. Where UI_BLUE_GREEN is on and there is a generated edge vhost to flip, the new version comes up on UI_PORT_ALT, is health-checked, and the edge upstream is switched before the old container is retired; a standby that never answers is thrown away while the live one keeps serving. Without an edge vhost the CLI falls back to the in-place swap and says so.

State lives in three places and they are not interchangeable — the pins in .env, the per-component current/prior record in the host state file, and the deploy event log. --status reads the state file in preference to .env precisely so it can tell you when the two disagree.

Two flags called --doctor

This one has caught people:

  • forge-deploy --doctor is an alias for --recover — the fix-and-resume doctor. It changes things.
  • setup.sh --doctor, ./doctor.sh and npx @armoryworks/forge-deploy --doctor run the network-exposure doctor. It changes nothing.

If you want the read-only diagnosis, use ./doctor.sh or the console's "Forge runs here but people cannot reach it" entry. Recovery covers both.

Testing the CLI

CI lints every compose file (standalone and layered), shellchecks the tree, and runs three behaviour suites in tools/: the console scenarios, install discovery, and a containerised first-install end-to-end with docker stubbed per scenario. The release workflow re-runs the same verification against the exact tagged tree rather than trusting a branch CI run — a tag once produced a public release from a tree whose suite was red, and the gate exists because of it.