Deploying
Five deployables, two very different mechanisms.
| What | Where | How it ships |
|---|---|---|
api | api.toolzerhub.com | Docker image → GHCR → Dokploy swarm |
worker | internal | same |
toolzerhub | toolzerhub.com, app.toolzerhub.com | Cloudflare Worker |
toolzerhub-docs | docs.toolzerhub.com | Cloudflare Worker |
vantage | vantage.* | Cloudflare Worker |
Standing a host up from nothing is infrastructure.md. One-time migrations, production data access and package releases are operations.md.
Cloudflare Workers
Every web app has the same four scripts. deploy:cf and preview:cf build
first — they are safe to run on their own:
build:cf produce .open-next/
preview:cf build, then serve it locally in workerd
deploy:cf build, then deployFrom the repo root:
pnpm deploy:site # toolzerhub.com + app.toolzerhub.com
pnpm deploy:docs # docs.toolzerhub.com
pnpm deploy:vantage # vantagedeploy:docs installs the docs app first, because it sits outside the workspace
globs and has its own lockfile. During its build, api:bootstrap installs the
API package and its workspace dependencies from the root lockfile before
emitting the local OpenAPI document. This keeps Cloudflare builds current
without making the docs app a root-workspace member.
The docs changelog needs full origin/main history for the
reviewed release notes.
Fetch that ref before building; an incomplete checkout fails closed.
Cloudflare dashboard settings
Workers Builds must be told to build before it deploys. Left at the default it
auto-detects OpenNext, runs opennextjs-cloudflare deploy with nothing built,
and fails with:
ERROR Could not find compiled Open Next config, did you run the build command?For each connected project:
| Setting | Value |
|---|---|
| Build command | pnpm run build:cf |
| Deploy command | pnpm exec opennextjs-cloudflare deploy |
| Root directory | apps/web/toolzerhub, apps/web/toolzerhub-docs, apps/web/vantage, or apps/web/internal-docs |
Setting the deploy command to pnpm run deploy:cf also works and is
self-sufficient — it builds first regardless of what the build command is.
Build watch paths
Without these, every worker rebuilds on every commit — a worker change redeploys the marketing site and both docs sites. Set them per worker; configuring one does nothing for the others.
| Worker | Include paths |
|---|---|
toolzerhub | apps/web/toolzerhub/* |
toolzerhub-docs | apps/web/toolzerhub-docs/*, apps/api/src/routes/*, apps/api/src/configs/* |
vantage | apps/web/vantage/* |
internal-docs | apps/web/internal-docs/*, docs/*, apps/api/docs/*, apps/worker/docs/* |
internal-docs watches the shared doc directories too, because its content is
copied from them at build time (scripts/sync-docs.mjs) rather than living in
the app.
toolzerhub-docs watches the API routes and configs because its reference pages
are generated from the OpenAPI spec at build time. Without them, a new or changed
route ships to the API and never reaches docs.toolzerhub.com (missed for
/v1/instagram/post/views, 2026-10-14).
One directory per worker is the whole point of toolzerhub-docs being a
sibling of toolzerhub rather than nested inside it: while it was nested, no
include path could separate the two and each rebuilt on the other's changes.
Two documented cases bypass path matching entirely and rebuild everything: a push with 0 file changes, and a push with 3000+ file changes or 20+ commits. A large batch merge will therefore rebuild every worker, which is expected rather than a broken setting.
Custom domains are declared in wrangler.toml
The routes list is authoritative. On deploy, wrangler reconciles the
worker's custom domains to exactly what is listed, so omitting a hostname
detaches it and deletes its DNS record. Listing only app.toolzerhub.com
once took toolzerhub.com off the internet.
Every hostname a worker serves must appear in its routes.
API and worker
A main push that changes backend or image inputs builds both images, pushes
them to GHCR, and triggers a Dokploy redeploy (.github/workflows/ci.yml).
A root docker-compose.yml change validates and redeploys the application
stack with its current image tags. Unrelated pushes stop after the path-gated
CI checks. Nothing ships from a branch.
The webhook has 10-second connect and 30-second total limits; CI requires both a 2xx response and Dokploy's acceptance message.
Running CI and deploys without GitHub Actions
Actions minutes are limited (free tier), so CI can be switched off and run on the VPS instead. Both paths stay available:
| GitHub Actions | VPS | |
|---|---|---|
| Checks | .github/workflows/ci.yml | pnpm ci:local [api] [worker] [observability] [images] (default api worker) |
Ship api + worker | images → promote → deploy jobs | pnpm deploy:local |
| Switch | pnpm ci:github on | pnpm ci:github off |
pnpm ci:github off sets the CI_GITHUB=off repo variable, which makes the
changes and ci-gate jobs skip, so the whole run costs nothing. on deletes
the variable. status prints it. While it is off, nothing runs on push or PR:
run pnpm ci:local before merging, and pnpm deploy:local from main to ship.
scripts/ci-local.sh runs the same commands as the workflow jobs, sequentially
(parallel runs OOM this box; see lefthook.yml). It always runs the full suite,
with none of the path filtering, and does not run the Prometheus/collector
container checks.
scripts/deploy-local.sh refuses unless you are on a clean main equal to
origin/main. It runs ci:local api worker, builds and pushes both images to
GHCR as sha-<commit>, moves the main tag only after that, then calls the
Dokploy webhook. Same ordering rule as CI: never move main before the tests
and builds pass.
pnpm deploy:local --fast skips the ci:local checks and only builds, pushes,
retags and triggers Dokploy. Use it for a quick ship when the change was
already checked (pre-push runs typecheck and contracts); the image build itself
still fails on a broken build.
Who can run it. The dev user (where agents run) has no Docker access,
so ci:local images, ci:local observability and deploy:local cannot run
there. They are run by the main user on the VPS. Agents should not try to
work around this (no sudo, no adding dev to the docker group); run
pnpm ci:local api worker (no Docker needed), then hand the deploy to the
user.
The webhook is a secret. deploy:local reads DOKPLOY_DEPLOY_WEBHOOK from
the environment, falling back to the gitignored root .env (the same value as
the Actions secret of that name). It is a
Dokploy compose deploy URL under https://dokploy.toolzerhub.com/api/deploy/compose/.
Never commit the full URL, put it in docs, logs or PRs, or paste it in chat
output; anyone holding it can trigger a production deploy. Keep it in .env or the
main user's shell profile. docker-compose.yml uses .env as env_file, so a
copy of .env that is fed to the stack also hands this key to the containers;
keep the deploy key out of any .env the services read.
docker-compose.yml pins the main tag; a moved tag is what triggers the
rollout. Migrations run on boot under an advisory lock (runMigrations() in
apps/api/src/main.ts), so a deploy applies them and a failure fails the boot
rather than serving on a half-built schema.
Production credential preflight
TOOLZERHUB_API_KEY and JOBS_ID_SECRET have the same committed compatibility
default. This keeps the existing Actor fleet and issued job IDs working when no
deployment overrides are configured. To rotate the API credential, generate a
replacement in the deployment secret store:
openssl rand -hex 32Do not paste a replacement into git, CI logs, shell history, or an issue. Production rejects a non-empty override shorter than 32 characters. CI still rejects committed defaults for every credential other than these two approved compatibility fields.
For a future rotation, set these deployment values before rolling the API:
TOOLZERHUB_API_KEY: the newly generated key.TOOLZERHUB_API_PREVIOUS_KEY: the outgoing key, supplied from the secret store rather than copied into git.JOBS_ID_SECRET: leave unset to retain its stable compatibility default, or explicitly set that same value.
Deploy, move every published Actor/client to the new API key, verify telemetry
and data calls, then clear TOOLZERHUB_API_PREVIOUS_KEY and revoke the outgoing
API credential. Do not rotate JOBS_ID_SECRET until a job-ID keyring migration
exists or every outstanding ID has expired. Application code cannot discover
or revoke an external secret, so this operator checklist is a merge gate.