Self-hosting Zeq
The entire open-source stack runs locally via Docker Compose. You get:
- The full 156-endpoint API server
- Postgres backend with fresh migrations
- Docusaurus docs site (this site)
- Every reference application on
/apps/* - A working HulyaPulse loop at 1.287 Hz
What you don't get from a self-host: signatures issued against the hosted public key. Your self-hosted instance issues CKOs signed against its own locally-generated key. That's fine for development and private production; it doesn't attest across parties.
Clone
git clone https://github.com/hulyasmath/zeq-framework
cd zeq-framework
Boot the stack
The framework boots with exactly two environment variables —
ZEQ_FIELD_KEY (the 32-byte master key that encrypts the ZSC vault)
and DATABASE_URL. Every other secret (session secret, BYOK keys,
Stripe, admin recovery, etc.) lives encrypted in the zsc_secrets
table and is hydrated into process.env at boot by the launcher.
Do not create
.envfiles. The legacy.env.localpattern is deprecated. See CLAUDE.md §9.5 and ZSC bootstrap.
First-time seed
# Pick the master key once. 64 hex chars = 32 bytes.
ZEQ_FIELD_KEY=$(openssl rand -hex 32)
# Pre-pin the fork's public origin (also seeds Postgres + ZSC).
DATABASE_URL=postgres://zeq:PASS@localhost:5432/zeq \
node infra/setup-zeq.mjs https://your-fork.example
setup-zeq.mjs is idempotent. Re-run it with a different URL to
rename the fork; the runtime picks up the change within 60 seconds
without a restart.
Launch
ZEQ_FIELD_KEY=<64-hex> \
DATABASE_URL=postgres://zeq:PASS@localhost:5432/zeq \
node infra/zeq-dev-launch.mjs
Or just ./infra/dev-launch.sh, which sources the two pointers from
~/.zeq-bootstrap (gitignored, host-local) and execs the launcher.
The launcher reads the vault, hydrates process.env, then spawns the
bundled dist/server.mjs. Expected output: one Node process bound to
localhost:3099 serving every framework surface (admin, portal,
state explorer, SDK, all apps).
Verify
curl http://localhost:3099/api/health
# {"ok": true, "hulyapulse_hz": 1.287, "zeqond_s": 0.777, "version": "1.287.5"}
curl http://localhost:3099/api/transparency/snapshot
# Live network snapshot (Active N, supply, mint/day, foundation share).
Run the docs site locally
cd sdk-docs/source
npm install
npm run start
# Opens http://localhost:3000
Run an app locally
Apps are static bundles served directly by the API server. Visit e.g. http://localhost:3010/apps/aero-wind-tunnel/ after the stack is up.
Using your self-hosted stack from the SDK
const zeq = new ZeqClient({
apiKey: "zeq_local_...", // your locally-issued key
baseUrl: "http://localhost:3010", // your stack
});
Issue yourself a local API key:
curl -X POST http://localhost:3010/zeq-auth/register \
-H "content-type: application/json" \
-d '{"email":"you@example.com","password":"..."}'
# Returns an API key in the response.
Migrating to hosted
If you outgrow self-hosting, migration is simple: swap baseUrl back to https://api.zeq.dev and supply a hosted key. No code changes. CKOs from the hosted service will be verifiable against the hosted public key; CKOs your self-host issued are verifiable against your key. You can run both side-by-side.
Known limitations of self-host
- No authoritative time. Your Zeqond is synced off your local clock, which drifts at whatever rate NTP allows.
- No multi-tenant isolation. The open-source stack is single-tenant by design — one database, one key.
- No HSM. Signing keys are stored in
.env.localor a local keystore — not production-grade. - Limited scale. The stack is single-Postgres; load beyond a few RPS needs tuning.
If any of these matter, use the hosted service.
Forking a framework domain (the ZG bring-up runbook)
A fork is a per-domain Docker container running the same source as
the canonical zeq.dev, against its own Postgres, its own ZSC vault,
and its own admin roster. zeqstate.com was the first fork shipped
under this pattern (2026-05-28); the steps below are the canonical
checklist for any subsequent domain.
The invariant: Desktop = GitHub = VPS for source. The Docker image
mounts apps/zeq-dev/public/ read-only from the VPS, so static
changes propagate via git pull with no rebuild. API changes need a
fresh dist/server.mjs shipped via SCP + docker cp.
ZG.0 — Pre-flight schema diff
Before booting a fresh container, sanity-check the per-domain DB schema against the latest migrations:
SELECT to_regclass('public.tally_transitions') IS NOT NULL,
to_regclass('public.domain_economy_config') IS NOT NULL,
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_name='network_snapshots'
AND column_name='econ_version');
All three should be t. If any is f, run the recovery runbook
(see CLAUDE.md §9.7) before booting — tally_transitions missing
crashes the API in a restart loop on the first compute tick.
ZG.1 — Mount the canonical static tree
Per-domain compose YAML at infra/per-domain/<domain>.yml mounts
the canonical apps/zeq-dev/public/ (NOT a per-domain fork):
volumes:
- /opt/zeq-framework/apps/zeq-dev/public:/app/public:ro
- /opt/zeq-framework/sdk-docs/source/build:/app/public/sdk:ro
The second mount resolves the apps/zeq-dev/public/sdk symlink
through the container — without it, /sdk/ returns 404.
ZG.2 — Bootstrap pointers in host shell, NOT a .env
Two pointers per fork. Store in /root/.zeq-bootstrap-<domain>
(gitignored, host-local) or your shell's rc:
export <DOMAIN>_FIELD_KEY=$(openssl rand -hex 32)
export <DOMAIN>_DB_PASSWORD=$(openssl rand -hex 24)
Source them before docker compose up -d with the auto-export idiom:
set -a
source /root/.zeq-bootstrap-<domain>
set +a
docker compose -f infra/per-domain/<domain>.yml up -d
Without set -a + source + set +a, compose can't read the env vars
and the container will exit with ZEQ_FIELD_KEY missing.
ZG.3 — Seed the vault + pin the genesis admin
docker exec <domain>-api-1 node infra/setup-zeq.mjs https://<domain>
This writes every secret into zsc_secrets (SESSION_SECRET,
HITE_SECRET, ALLOWED_ORIGINS, etc.) and pins the first genesis admin
into domain_genesis.admin_zid. The container hydrates them on next
boot. Note setup-zeq.mjs is idempotent — safe to re-run.
System-default LLM credentials (Fireworks deepseek-v4-pro) live in
user_llm_credentials, not zsc_secrets. BYOK keys are per-user
with FK → users.id, while ZSC is framework-wide infra. Seed them
separately if your fork needs LLM out of the box.
ZG.4 — nginx timeout for LLM-bearing routes
The default 60-second proxy_read_timeout cuts the build flow off
mid-stream (typical BUILD-mode LLM completion is ~50 seconds, just
inside the limit on a good day, well over on a bad one). On the VPS
nginx block for the fork:
location / {
proxy_pass http://127.0.0.1:<port>;
proxy_read_timeout 600s; # required for /api/zeq/agent/* + /api/chat/*
proxy_send_timeout 600s;
# ... rest of headers
}
This was ZG.AUDIT Fix 4. Don't skip it — without it, the LLM-bearing
routes throw 504 Gateway Timeout and the wizard hangs.
ZG.5 — Smoke test (the gate)
Every fork must pass the same end-to-end sweep before sign-off:
DOMAIN=your-fork.example
# Health + transparency + auth surfaces
for path in /api/health /api/transparency/snapshot /api/chain/recent \
/api/chain/state-machines /api/machines/top \
/ /s/<test-machine>/ /state/ /admin/ /portal/ /sdk/; do
echo -n "$path "
curl -sk -o /dev/null -w "%{http_code}\n" "https://$DOMAIN$path"
done
All should return 200 (except admin-protected paths returning 302 to
sign-in). /api/transparency/snapshot returns 400 with
INVALID_ZEQOND if called without ?zeqond=N — that's correct.
ZG.6 — User-surface E2E (the real gate)
The smoke test only proves routing. The real bar is the four-surface
workbench driving Skill → Plan → Build → Deploy to a published
machine page. Open the fork in Chrome, register a fresh admin, claim
a machine, run all four surfaces, click BUILD & DEPLOY, then load
/s/<machine>/p/<page>/ and verify the page renders the deployed
HTML rather than the welcome canvas.
If a fork passes ZG.0 through ZG.6, it's production-ready.
ZG.7 — Cache invalidation note
After a fresh deploy, the user's browser may show one more canvas-cached render before the routing fix lands. Verify with curl or incognito to distinguish a cache stale from a routing bug — this misdiagnosed FIX 5 in the original zeqstate bring-up.