Skip to content

Topologies

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

Forge runs on one box by default, and that is the right answer for almost every shop. The deploy tooling also supports splitting the UI, the API and the database across separate machines without a Kubernetes commitment — but a split install has its own wiring, its own failure modes, and one upgrade step that nothing else will do for you. This page is the mechanics. The decision of whether to split at all, and the security posture that follows, belong to Hardening a Production Install.

Roles

forge-deploy --setup asks what this box runs, or takes it from --role for an unattended install. Each role is defined by the compose services it scopes out; everything else runs locally.

Role Runs locally Needs to reach
all Everything Nothing off-box
ui+api UI, API Postgres and object storage on the database box
api+db API, Postgres, object storage, backup sidecar Nothing; the UI box reaches it
ui UI only The API box's HTTP port
api API only Postgres and object storage on the database box
db Postgres, object storage, backup sidecar Nothing; the API box reaches it

Scoping out a service does three things at once: a generated docker-compose.scope.yml profiles it out so a bare compose cannot start it, FORGE_SCOPED_OUT records the roster for the shell scripts, and FORGE_DEPLOY_SERVICES is narrowed to the versioned components that remain. The scope file also resets any depends_on edge that would point at a scoped-out service — compose rejects the whole project over a dangling dependency, which would otherwise break every compose command on the box.

Check forge-deploy --components after the wizard. The role table does not scope out the demo and test SPAs, so they stay in FORGE_DEPLOY_SERVICES even on a database box, where the correct declaration is the literal none. They never start — they are profile-gated — but they do participate in the release intersection --update computes. See The CLI.

What --setup wires for each role

Beyond the scoping, the wizard does the role's specific plumbing:

  • A database box sets POSTGRES_BIND and MINIO_BIND to 0.0.0.0 so the API box can reach them. Firewall those ports to the API box; nothing else should be able to open them.
  • An API box without a local database is pointed at the remote one with --remote-db, including the object-storage endpoint.
  • A UI box without a local API gets one of two treatments depending on whether host nginx is present: with nginx, the box becomes the public edge and a vhost is generated; without it, the in-container forwarder is stood up instead.
  • Versioned components are pinned, and a component that already has a recorded deploy is not swapped in place — it is queued and converged afterwards through the ordinary gated deploy, so a version change during setup still gets its backup, reconcile, health gate and audit entry.
  • The chosen role is recorded in the deploy state file, which is also what lets the console tell "installed but never told what to run" apart from "configured".

Reaching a remote database

forge-deploy --remote-db <host>[:port] --minio <host>[:port]
forge-deploy --remote-db                  # show current
forge-deploy --remote-db off              # revert to the local database

This writes DB_HOST, DB_PORT and MINIO_ENDPOINT, which compose interpolates into the API's connection string and storage endpoint — both of which otherwise default to the local service names. It also mirrors the endpoint into MINIO_HOST and MINIO_PORT, because the backup sidecar reads the split pair rather than the endpoint and compose interpolation cannot split a host:port string.

Two things it will not do for you. The Postgres and object-storage credentials in this box's .env must match the database box's — they are not negotiated. And before any redeploy it probes both endpoints for TCP reachability and refuses if they do not answer; during --setup that is downgraded to a warning, because the database box may legitimately not be up yet, and the real gate is the pre-deploy check on every subsequent deploy.

Reaching a remote API

The forge-ui image bakes its nginx proxy target: /api and /hubs go to a fixed upstream service name. A UI-only box therefore cannot simply be told a different URL — so --remote-api stands up a tiny nginx container that claims that service name as a network alias and relays to the real API, with no image rebuild.

forge-deploy --remote-api http://<api-box>:5000
forge-deploy --remote-api https://api.example.com
forge-deploy --remote-api off

It writes the forwarder config and a generated compose overlay, scopes out the local API so it cannot crash-loop or fight for the alias, appends the overlay to COMPOSE_FILE so even a bare compose sees it, and brings up the forwarder and the UI. An https target additionally enables SNI and rewrites the Host header so a vhost-routed API resolves correctly.

The prerequisite it prints is the one people miss: the API box publishes on loopback by default. Set API_BIND to an interface the UI box can reach and redeploy, then verify from the UI box before assuming the wiring is wrong.

The edge vhost

forge-deploy --edge --host <fqdn> generates and installs a host-nginx vhost for a public hostname. It is topology-correct by construction: the SPA goes to the UI target, while /api, /hubs, the OIDC callbacks, /connect/ and /.well-known/ go to the API target — which may be another machine. WebSocket upgrade headers and long read timeouts are set on the hub location so SignalR survives.

The generator is careful with a config directory it does not own. It backs up and comments out conflicting server blocks for the same hostname in other active files, backs up any previous copy of its own file, validates with nginx -t, and restores everything it touched if validation fails. It can auto-detect a Let's Encrypt certificate covering the host or its parent domain; --cert and --key override, and --no-reload writes and validates without reloading.

One location is deliberately not proxied: the upgrade marker is served straight off the host filesystem, so a browser can distinguish "Forge is upgrading" from "Forge is down" while the UI is mid-cutover and the API is being replaced.

That same edge is what makes the blue/green UI cutover possible — without a vhost upstream to flip, the CLI falls back to an in-place swap and says so. See The CLI.

Standalone versus cohost

QBE_HOSTING_MODE records whether the stack owns the host or sits behind something that already does.

  • standalone — the stack terminates TLS itself. setup.sh --public generates a self-signed certificate, takes 80 and 443, offers to stop conflicting services and opens firewall rules.
  • cohost — an existing host-level reverse proxy, tunnel or web server fronts Forge. The UI stays on loopback and the cohost overlay is layered between the base and prod files.

The mode is not cosmetic: the CLI reads it to decide the overlay set, and forge-preflight compares the mode against the overlays the running project was actually brought up with, because a cohost box brought up without its overlay drifts on the next recreate.

Upgrading a split install

This is the part that catches people, and it catches them silently.

The API box's own schema reconcile skips when the database is elsewhere — and reports success. The Postgres container is not on the API box's compose network, so the reconcile warns and lets the deploy proceed. That is deliberate, and it means a split install will upgrade its API against an unmigrated database unless someone runs the reconcile on the database box:

forge-deploy --reconcile <tag>      # on the DB box

That subcommand exists for exactly this reason. It refuses to run on a box whose DB_HOST points somewhere else, sets SCHEMA_IMAGE_TAG and enables the reconcile flag for you, and then runs the same backup → plan → apply sequence the single-box path runs. The reconcile's own behaviour — the three phases, the destructive halt, the pre-migrate commit edge — is described on the hub at Upgrades and Rollback, and the schema project itself is forge-db.

If you want that ordering handled rather than remembered, the host agent can coordinate it. The box running the API is the coordinator, because it owns the reconcile and is the only agent forge-api can reach from inside a container. FORGE_SCHEMA_AGENT names the database box's agent and FORGE_PEER_AGENTS names the rest; the coordinator plans schema first, then itself, then each peer in the order .env lists them, so a UI box is never moved before the API it talks to. When a schema agent is configured and the target release cannot be determined, the coordinator refuses the job rather than skipping the schema step — an API running against an unmigrated database is worse than an upgrade that did not start. Peer agents are locked with an upgrade marker so every box shows the same state to a browser.

Two traps worth knowing before you split

Container names are global; the compose project name is not. The compose file pins container_name, but volumes are project-prefixed and the project name defaults to the install directory. Installing to a different path than the last install therefore produces a fresh set of volumes — a working Forge with none of your data in it. Fresh installs now pin the project name explicitly on .env creation only; adding it to an existing install would repoint every volume and read as total data loss. If you are moving a tree, move the .env with it.

Snapshots live on the box that runs the database. A split install's backups are not on the API box, and .env is on every box and in no snapshot. See Backup and Restore.