The whole platform, assembled. This repository holds no product code: it clones the nine product repositories and composes them into one running system.
It is also where the platform-wide documents live, since they describe all ten repositories and belong to none of them:
- docs/ecosystem/ — the ecosystem programme: what exists today verified against source, the target architecture, and fourteen executable phases for getting there. Start at docs/ecosystem/README.md.
- MAP.md — what runs, and what happens between the repositories.
- audit.md — the defect register (
CF-NN), with upgrade.md as its phased remediation plan and VERIFICATION.md as the record of what was actually run.
The ecosystem programme builds into a new, parallel set of
micro-*repositories undermicro/. It does not modify, delete or archive anything inrepos/. The existing estate keeps running throughout and is the rollback target. See docs/ecosystem/README.md for the policy.
git clone https://github.com/cloudsforge-online/stack.git
cd stack
cp .env.example .env # then fill in the secrets — see below
./scripts/clone-all.sh # clone all 9 product repos into repos/
docker compose up -d --build| Service | URL | Repository |
|---|---|---|
| Account / SSO (nimbus) | http://localhost:4001 | platform |
| Game API | http://localhost:4002 | ninety-days-after |
| Pay | http://localhost:4003 (loopback only) | forge-pay |
| ForgeMint | http://localhost:4004 | forge-mint |
| ForgeKeyvault | http://localhost:4005 (loopback only) | forge-keyvault |
| Crucible (trading + UI) | http://localhost:4006 | crucible |
| Marketing site | http://localhost:3000 | platform |
| Game client | http://localhost:3001 | ninety-days-after |
| Admin | http://localhost:3002 | platform |
| Hearth site | http://localhost:3003 | hearth |
| Hearth explorer / wallet | http://localhost:8080 | hearth |
| Hearth testnet (seed + 2 miners) | 8645 / 8647 / 8649 (loopback) | hearth |
| Lantern (logs & errors) | http://localhost:4010 | here (infra/lantern) |
| CV | http://localhost:8081 | here |
Build from source — the development path. docker compose up --build builds
every image from the checkouts in repos/. Requires clone-all.sh first.
Pull prebuilt images — the deploy path. No checkouts needed at all:
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -dCLOUDSFORGE_TAG defaults to main, which is the tip of seven repositories and
therefore not a thing you can roll back to. Pin it to a release number for
anything you care about — see Pinning a release, which
also explains why a commit sha is not an option here.
The images are public — pulling needs no login. That is a setting on each package, not a consequence of the source repositories being public: a GHCR package inherits no visibility from the repository that published it, so one published while its repo was private stays private until someone flips it by hand at
github.com/orgs/cloudsforge-online/packages→ the package → Package settings → Change visibility. A package that is still private fails in a way that reads like a missing image:docker login ghcr.iosucceeds and the pull then fails with403 Forbiddenon the manifest, because GHCR wants theread:packagesscope specifically.gh auth refresh -s read:packageslocally, or a granular PAT on a server.
The images are public, so the server needs no registry login and no product checkouts — only this repository.
# 1. On the server (amd64 Linux; see the note below)
# The repository is public, so this needs no key and no token — and it is the
# only one the server needs, because the product repos are never cloned.
git clone https://github.com/cloudsforge-online/stack.git
cd stack
cp .env.example .env2. Fill in .env. The four that actually stop it working:
| Variable | Why |
|---|---|
POSTGRES_PASSWORD |
Defaults to cloudsforge. Change before first boot — changing it later does not change the password of an already-initialised volume. |
KEYVAULT_SERVICE_TOKEN, KEYVAULT_MASTER_SECRET |
≥24 chars, no dev- placeholders. The keyvault refuses to boot otherwise, deliberately. openssl rand -hex 32 each. KEYVAULT_MASTER_SECRET cannot be rotated — changing it makes every custodied key undecryptable. |
ADMIN_PASSWORD |
Seeds the admin account. |
PAY_SERVICE_TOKEN |
≥24 chars. How Crucible charges its performance fee on a timer, when there is no user token to forward. Unset, Pay's /internal routes answer 401 and no fee is ever collected. |
3. Set the public URLs — the block at the bottom of .env.example. This is
the step that is easy to skip and breaks authentication in a way that looks like
a frontend bug:
NIMBUS_ISSUER=https://nimbus.cloudsforge.online
CORS_ORIGINS=https://cloudsforge.online,https://play.cloudsforge.online,...
PORTAL_ALLOWED_ORIGINS=https://cloudsforge.online,https://play.cloudsforge.online,...NIMBUS_ISSUER is baked into every JWT and verified by every service. Left at
its localhost default, real users get tokens whose issuer nothing on the
internet can reach.
4. Start it, from the registry:
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -d --wait5. Put it behind the tunnel. Cloudflare terminates TLS, so there is no nginx/Caddy on the box:
cp deploy/cloudflared/config.example.yml deploy/cloudflared/config.yml
# fill in the tunnel id + credentials path, then create the DNS routes once:
# cloudflared tunnel route dns <TUNNEL> nimbus.cloudsforge.online (etc.)
cloudflared tunnel --config deploy/cloudflared/config.yml runThe example config maps every public subdomain to its local port, with two deliberate holes in it:
- ForgeKeyvault (4005) has no rule at all. It holds custody keys. Never add one.
pay.refuses/internal/*at the edge, with apathrule that returns 404 before the rule that routes the hostname to the service. Everything the browser needs on Pay — the wallet, deposits, coin rates, conversion — is unaffected; see below for why this matters.
cloudflared runs as a host process and dials 127.0.0.1, so binding a service
to loopback in compose does not hide it from the tunnel. Loopback and the
ingress rules are two different locks, and /internal needs both.
6. Updating. A deploy is a pull, not a build:
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml pull
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -d --waitLeft at its default that pulls the tip of main from seven repositories at
once. For anything you care about, pin it.
CLOUDSFORGE_TAG is one value shared by fourteen images built by seven
different repositories — platform, ninety-days-after, forge-pay,
forge-mint, crucible, forge-keyvault and hearth. That single fact
decides what can and cannot be pinned:
- A commit sha cannot work. Each repo's
publish.ymlemitstype=sha,format=long, sosha-<commit>only ever exists for the one repo that commit belongs to. SetCLOUDSFORGE_TAG=sha-…and thirteen of the fourteen pulls fail — as403/manifest unknown, which reads like a permissions problem rather than a tag that was never going to exist. - A shared version tag does work. Every
publish.ymlalso builds ontags: [ 'v*' ]withtype=semver,pattern={{version}}. Push the same git tag in all seven repos and every image gets the same version tag.
{{version}} strips the leading v, so the git tag is v0.3.0 and the pin is
the number:
# in each of the seven product repos, on the commit you want in the release
git tag v0.3.0 && git push origin v0.3.0# then, on the server
echo 'CLOUDSFORGE_TAG=0.3.0' >> .env # note: no leading v
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml pull
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -d --waitA release is therefore seven tag pushes, done by hand — there is no tooling
that fans a tag out across the org, and a repo you forget simply has no image at
that version, which the pull reports immediately rather than silently serving
something older. Tag this repository too: lantern builds from infra/ here and
is versioned by the stack commit you deployed, not by CLOUDSFORGE_TAG.
To hotfix one service without moving the whole release, override that single
image in an extra overlay file rather than changing CLOUDSFORGE_TAG:
# hotfix.yml
# services:
# pay:
# image: ghcr.io/cloudsforge-online/pay:sha-<commit>
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml -f hotfix.yml up -dThe images are
linux/amd64only — GitHub's hosted runners are x86. On arm64 they need--platform linux/amd64and emulation. Addplatforms: linux/amd64,linux/arm64to each repo'spublish.ymlif you deploy to arm.
Nothing backs itself up.
infra/backup.shdumps every database and everykv-*custody volume, but it is not scheduled and writes locally. A backup on the same disk is not a backup.
Secrets live in .env beside this file, which every service reads via
env_file:. It is not committed. KEYVAULT_SERVICE_TOKEN and
KEYVAULT_MASTER_SECRET must be real values of at least 24 characters —
ForgeKeyvault refuses to boot on placeholders, deliberately.
Frontends take no build-time configuration. Nothing reads
import.meta.env.VITE_*; the browser resolves service hosts at runtime through
cloudsforgeHosts(), so the same image serves localhost and production.
Lantern — http://localhost:4010. One page for every log line in the platform. It tails the other fifteen containers over the Docker socket, so nothing has to be configured to be collected and a service that dies before its logger starts still has its last words captured.
It skips itself on purpose, so Lantern's own failures are the one thing it will
not show you — those are in docker logs cloudsforge-lantern.
- Issues groups errors by fault rather than listing lines: "this failure, 1,240 times, first seen 09:12", not 1,240 rows.
- Live is a filtered tail across every service at once.
- Search by request id is the one to remember. Every service now stamps
x-request-idon its responses and propagates it across service hops, and every error page shows it. Paste the id a user quotes and you get their exact request through nimbus, pay and keyvault in order. - Browser errors are collected too, via
POST /ingest/client— before this, a front-end failure left no trace anywhere in the system.
Sign in with any CloudsForge account holding the admin role; it then appears
in the product switcher at the top of every site. Behind the tunnel, add a
lantern.<apex> DNS route like the others.
Set
LANTERN_TOKEN. It is the break-glass entry (?token=…) for when Nimbus is the thing that is broken — which is precisely when SSO cannot let you in to read the logs about it.
LOG_LEVEL=debug in .env turns detail up across every service without a code
change or a rebuild.
Crucible is the trading product. Backtesting, its whole strategy catalog and paper bots are free and touch nothing. A live bot is different: it buys and sells the coin ForgeKeyvault custodies for a user, settling every fill against Forge Pay's median price oracle, and is charged a share of its gains.
Two switches decide whether any of that can happen:
-
CRUCIBLE_LIVE_ENABLEDships asfalse. Off, live bots cannot be created or started at all and the UI says so rather than offering a control that fails when pressed. Backtests and paper bots are unaffected. -
PAY_SERVICE_TOKENgates Pay's/internalroutes. They take auserIdas a parameter rather than reading it from a token, which is the whole point — a settlement running an hour after the user left has no token to read — and that also means anything able to reach them can read any balance, debit any user, credit any user and liquidate any user's custodied coin, with that one shared secret as the only lock.This is what keeps them off the internet, and it is two locks rather than a promise:
- Compose binds Pay to
127.0.0.1:4003, like ForgeKeyvault and Postgres, so nothing on the LAN can reach it. Peer services usehttp://pay:4003over the compose network, which never touches the published port. - The tunnel returns 404 for
pay.<apex>/internal/*before it routes the hostname, indeploy/cloudflared/config.example.yml.
Both are asserted in CI, because this file previously claimed the routes were compose-network-only while
docker-compose.ymlpublished 4003 on every interface and the tunnel forwarded the hostname with no path filter — sohttps://pay.<apex>/internal/chargewas live on the public internet, one guessed token away from everyone's money. If you copy the tunnel config, keep the refusal rule first: cloudflared takes the first matching rule. - Compose binds Pay to
The fee is a high-water mark performance fee, 15% by default: charged only on equity above the highest the bot has ever been billed at. The same gain is never billed twice, and a bot that loses and recovers is billed nothing for the recovery. A bot that ends where it started has paid nothing.
The platform is not the counterparty. A live bot trades the user's own custodied coin, so Crucible's exposure is custody, not a directional position against its customers. This was the deciding constraint on the design: the simpler build — the bot holding Shards and the house taking the other side — would have made every winning customer a liability.
docker compose updoes not update the code.repos/are ordinary git checkouts; run./scripts/pull-all.shto fast-forward them all and print which ones moved, so you know whether a rebuild is needed. (clone-all.shalso updates, but it merges origin's default branch into whatever is checked out —pull-all.shfollows the branch you are actually on.) Neither script touches a checkout with local changes rather than discarding work.clone-all.shclones over HTTPS, which needs no key and no account now that the product repositories are public. UseCLOUDSFORGE_GIT_PROTO=ssh ./scripts/clone-all.shif you push with a key. That variable is also the only thing that rewritesoriginon an existing checkout: the default changed from SSH to HTTPS when the repos went public, and a changed default must not silently repoint a remote somebody pushes through.- Ports 3000–3003, 4001–4006, 4010, 8080–8081 must be free. Anything else already
bound on them will make the affected container fail to start — and a container
that fails its network setup this way stays in a restart loop without a
network, which then looks like a DNS bug (
EAI_AGAIN postgres). Free the port anddocker compose rm -sf <service>before recreating. - Containers are named
cloudsforge-<service>.docker logs cloudsforge-payworks from any directory, and one host runs one copy of this stack. up --waitnow waits for the whole stack. Every service has a healthcheck, including the six nginx front-ends and the three chain nodes. Before that,--waitreturned when nginx had started, and Pay was allowed to boot against a chain RPC that was not yet listening — a deposit watcher polling nothing, which credits nothing and says nothing.- ForgeKeyvault runs as root, alone among the services, because it drives the host Docker socket to provision a container per custodied key. See its repo.
- Postgres is
postgres:17-alpine, unpinned by digest — the one base image that still floats.
infra/backup.sh dumps every database and every kv-* custody volume. It is
not scheduled — that is a host decision, and a backup on the same disk is
not a backup.
MIT — see LICENSE.
Parts of this repository were produced with AI assistance, and it seems worth saying so plainly rather than leaving it to be inferred.
- Code — written with Claude Opus 5 and Claude Opus 4.8 (Anthropic), reviewed and directed by a human, and gated on the same tests and CI as anything else here.
- Artwork — brand marks, icons and in-game art generated with OpenAI's image models
(GPT Image 1, 1.5 and 2), driven by the manifest pipeline in
asset-forge.
The models were used under paid API access and the output is the project's to use. Nothing here is claimed to be hand-written that is not, and nothing is claimed to work that has not been tested.