Arc Forge Labs Docs v2026.10.53

Flow on Replit

Flow can run as a published Replit app. This is the runbook for an installation on Replit whose workspace is imported from this repository, for people and agents alike. It is the same model as Field on Replit.

There is a second way: a private deploy repository that pins one npm release (deploy/replit/, docs/SELF_HOSTING.md, "Replit"). Use this runbook for a workspace of this repository.

What runs here

  • The workspace's Run button brings the workspace's database to exactly the migrations production runs (scripts/replit-deploy.sh update-workspace, "The workspace database"). It starts no server. The workspace updater does the same by itself ("The workspace updater"). The published app is built and started by scripts/replit-deploy.sh from a release or commit fetched from GitHub ("Publishing").
  • A Flow server started in the workspace by hand (from the Shell or by an agent) shares the published app's Secrets. It runs no background jobs and never writes to Xero, and Sentry labels its errors workspace (FLOW_BACKGROUND_JOBS, docs/SELF_HOSTING.md). Only the published app syncs and sends. It applies the checkout's own migrations when it starts: when the checkout is newer than production, that puts the workspace's database ahead of production ("The workspace database").
  • Runtime: Node 24 (.replit module nodejs-24), as in CI and the Dockerfile.
  • Flow keeps its records in PostgreSQL (DATABASE_URL) and runs its migrations when it starts (docs/DATA_MODEL.md). A published Replit app keeps no files between publishes, so nothing else holds state.
  • Checks: npm test.

Configuration

Add a Replit PostgreSQL database; it sets DATABASE_URL. Sign-in settings (FLOW_AUTH, the Clerk keys, FLOW_FIRST_OWNER_EMAIL, FLOW_OWNER_GOOGLE_DOMAINS, FLOW_PUBLIC_ORIGIN) are in docs/SELF_HOSTING.md, "Sign-in (Clerk)". Keep every secret in Replit Secrets, and never in a file in the repository.

The published app's settings (Publishing > Adjust settings) are separate from the workspace's. Set them there too.

scripts/replit-start.sh serves on PORT (19300 in .replit) and sets FLOW_RUNTIME=replit.

Publishing

Publish as a Reserved VM (Flow will run its integration outbox and scheduled invoice email in-process, so it must stay up).

A publish deploys GitHub, not the workspace's files. Replit publishes a snapshot of the workspace, so the deployment's build command (scripts/replit-deploy.sh build, .replit [deployment]) fetches the deploy ref from the public repository into .flow-deploy/, builds it there (scripts/replit-build.sh: npm ci --omit=dev) and records its commit. The run command (scripts/replit-deploy.sh start) starts Flow from .flow-deploy/. With no FLOW_DEPLOY_REF, the ref is the tip of main.

  • The fetch needs no token. If it fails, the build fails and the publish stops; it never deploys the workspace copy instead.
  • Only a commit that passed GitHub CI is built. After the fetch, the build asks GitHub's API, without a token, for the commit's check runs (scripts/deploy-checks.js). test (CI) and gitleaks (Secret Scan) must each have completed with success, as reported by GitHub Actions. Otherwise the build fails and the publish stops. A pinned FLOW_DEPLOY_REF is checked the same way.
  • It only writes .flow-deploy/ (gitignored). It never resets or deletes the workspace's own files.
  • The build log names what it deployed and its CI result: [deploy] fetched main at <commit>, [deploy] checks: passed (test, gitleaks) at <commit>, then [deploy] built github main at <commit> into .flow-deploy/ (checks: passed). The start log repeats it.

The check refuses the build, and names the reason in the log, when:

Build log What it means What to do
checks: NOT passed, then <check>: still in progress (or queued) CI is still running on the commit Wait for the commit's checks on GitHub, then publish again
checks: NOT passed, then <check>: failure (or another result) The commit failed CI Fix it on main, or pin a commit that passed
<check>: no run on this commit The check never ran on that commit Pin a commit on main
checks: could not be read: GitHub's API rate limit ... is used up. It resets at <time> Without a token, GitHub allows 60 API requests an hour from one address, and Replit's build machines may share one Publish again after that time
checks: could not be read: GitHub did not answer (or HTTP 5xx) The network or GitHub failed, after three tries Publish again; check githubstatus.com

It never treats an unreadable answer as a pass.

To publish:

  1. Publishing > Republish. Nothing needs pulling or running first: the workspace's database already matches production ("The workspace database"), so Replit shows no database changes.
  2. If Replit stops at "Development database changes detected", press Cancel, whatever the list says. Never pick an option there. Flow migrates production itself when it starts. Cancel also stops the publish, and the old build stays live. Then press Run, read its last line ("The workspace database"), and publish again.
  3. When Replit says it is done, open GET /health. Its build shows what is live: revision (the commit), ref (main, the pinned ref, or the channel stable or beta), release (the release tag, when the build is one), source (github, or workspace for an emergency build) and checks (passed, or unchecked when an emergency override built it). Check that revision and release are what you meant to deploy, and that checks is passed. Its schema.migrations counts the migrations the production database has applied, per journal.

.replit, scripts/replit-deploy.sh, scripts/deploy-checks.js, scripts/deploy-release.js and server/release-channel.js are the only files a publish takes from the workspace. Of scripts/replit-deploy.sh, only the fetch and its checks run from the workspace: once they pass, the fetched commit's own copy builds and migrates ("Each release brings its own build steps"). When a commit changes one of these files, the build log says note: ... differs from <ref>; pull main into the workspace when you can, so the new fetch steps take effect.

Publishing from an agent

An agent with Replit's connector can publish after a release, so a fix reaches an installation without a person pressing Republish. The connector signs in to one Replit workspace per connection: connect it to the installation's workspace. Its publish_app republishes with the deployment's own settings (a Reserved VM stays one), and get_publish_status says success, failed, suspended or still running.

scripts/publish-guard.js decides, before and after:

node scripts/publish-guard.js before https://<app> stable   # 0 publish, 3 nothing to do, 1 stop
node scripts/publish-guard.js after https://<app> <tag>     # 0 live, 1 stop

before passes only when GitHub's latest release and npm's latest name the same tag, its release-manifest.json matches its .sha256, its commit passed test and gitleaks, and the live /health is ok, follows stable from github and runs an older release. after asks /health for up to five minutes until build.release is the tag with checks: passed. An integration that is not ok is a warning. Set GH_TOKEN where many machines share one address, so GitHub's anonymous limit (60 requests an hour) is not used up; the guard sends it to api.github.com only.

The agent's steps:

  1. Run before. On 3 stop quietly. On 1 stop and report the problems.
  2. Call publish_app once.
  3. Call get_publish_status every 30 seconds, for at most 15 minutes.
  4. On success, run after. On anything else, stop.
  5. Report the result. Never publish again in the same run, and never retry a publish that failed: the build refused it for a reason, and the old build stays live.

The workspace database follows production ("The workspace database"), so a publish proposes no database changes. If a publish stops at "Development database changes detected", a person presses Cancel; an agent never answers it.

The workspace database

The workspace and the published app have separate databases. Before each publish, Replit compares the workspace's tables with production's. If they differ, it stops at "Development database changes detected" and offers to make production match the workspace. Flow never needs that: it migrates production itself when it starts (docs/DATA_MODEL.md), in Git, under its migration lock, and the same way on Docker, npm and Replit. Replit's offer guesses from a difference between two databases, keeps no history, and can read a renamed column as a drop. So:

  • The rule: at "Development database changes detected", always press Cancel. The same rule as Field.
  • Flow's tables are not in public. Replit compares only the public schema, and Flow keeps its tables in flow (migration 0021, docs/DATA_MODEL.md), so a difference between the two databases' migrations shows no list. The same rule holds for every app on Replit: keep the app's tables out of public.
  • The workspace database follows production as well, so the two databases also match in flow. The tables differ only when the workspace has fewer migrations than production (production gained a release's migrations when it started) or more (the workspace applied migrations production does not run).

scripts/replit-workspace-db.js sync keeps it level. The workspace updater and Run call it with the tools of the build they made. It:

  1. Reads GET <FLOW_PRODUCTION_URL>/health (no credentials) for build.revision, the commit production runs, and schema.migrations, how many migrations each journal has applied. Without FLOW_PRODUCTION_URL it uses FLOW_PUBLIC_ORIGIN, which an installation with sign-in already has. Without either, it says how to set one and does nothing.

  2. Fetches that commit's migrations from GitHub, without a token, into .flow-workspace-db/ (gitignored): Flow's own (server/db/migrations/) and each integration's (server/integrations/<id>/migrations/). It checks that the fetched commit is build.revision. FLOW_DEPLOY_REPO names a fork's repository, as for the publish.

  3. Compares each journal with the workspace database's rows (drizzle.__drizzle_migrations, and drizzle.__drizzle_migrations_<id> for each integration: each migration file's hash and its journal time). An integration production has not turned on has no migrations there, so the workspace gets none of its tables either.

    Production runs its release and migrates when it starts, so it has applied every entry of each journal, except an entry whose journal time is older than an entry before it (core 0013_line_mappings) when it applied the newer one first: drizzle never applies an older entry. When production reports exactly that many fewer, those entries are skipped: the rest are production's, and a workspace row for a skipped entry counts neither way. A test keeps every new entry later than the ones before it. Then it acts:

state What it means What it does
level The workspace database has exactly production's migrations Nothing
behind It lacks some of production's migrations Applies them, from production's commit, under Flow's migration lock (appliedNow counts them)
ahead It has migrations production does not run (extra names them) Nothing. Exit code 3
diverged It has different migrations from production Nothing. Exit code 3

It only touches the workspace's database (DATABASE_URL), never production's: production is only asked for its public /health. It refuses to run in the published app (REPLIT_DEPLOYMENT set), which migrates itself as before. It prints one JSON line and never prints the connection string.

ahead corrects itself: when production publishes the release that has those migrations, the workspace is level again. Until then, a publish stops at the prompt; press Cancel. To be level sooner, replace the workspace's database (delete it in the Database tool and add it again), then press Run. The workspace database holds no production data, but this deletes everything in it.

By hand, in the Shell: npm run db:follow-production (the same as sync) and npm run db:follow-production:status (compares, changes nothing; exit code 2 when the workspace is behind). Both need npm ci in the workspace first; Run does not, because it uses the build's packages. Do not run flow-server migrate in an installation's workspace: it applies the checkout's migrations, and every integration's, not production's.

scripts/db-guard-migrations.js stays as a second safety net: every migration skips what already exists (docs/DATA_MODEL.md, "Change the schema"). If someone approves the prompt by mistake, Flow still starts.

A release's migrations are tried before they reach production in CI, on a throwaway PostgreSQL: the test job runs every migration from an empty database, and the docker-build and package jobs start the image and the npm package on one. They no longer run on the workspace's database first.

The workspace updater

The workspace updater does what Run does, by itself, so no person has to remember Run before a publish (issue #100).

  • .replit onBoot starts it (scripts/replit-deploy.sh boot) each time the workspace starts, in the background. It runs until the workspace stops.
  • At start, and then every FLOW_WORKSPACE_UPDATE_SECONDS (600, at least 60), it brings the workspace's database to production's migrations ("The workspace database"). It builds FLOW_DEPLOY_REF again only when that names a new commit; the build supplies the tools and their packages. When a build is already there, it follows production with that build first, and builds again after: a new build takes minutes, and a publish pressed meanwhile must find the database level.
  • One at a time: a lock keeps it and Run from updating together, and a second updater does not start.
  • A failed step is logged, and the next one tries again. It never drops anything, never migrates the workspace past production, and never touches the workspace's own files: it writes only .flow-deploy/, .flow-workspace/ and .flow-workspace-db/ (gitignored), where .flow-workspace/updater.log is its log.
  • FLOW_WORKSPACE_UPDATER=off in the workspace's Secrets stops it starting. Run still works.

Its log ends each step with one of:

Last line Meaning At Publish
the workspace database matches production level No list
WARNING: the workspace database is not production's ahead or diverged A list may appear: press Cancel
FAILED: the workspace database could not follow production Production did not answer, or the fetch failed A list may appear: press Cancel

flow-server doctor --live <address> compares the workspace's database with the live app's GET /health counts, and says same, behind, ahead or unknown. The answer at the prompt is Cancel in every case.

The updater reaches a workspace once, from a release that has it. Nothing from GitHub runs in a workspace by itself, so a workspace imported before the updater existed (its .replit has no onBoot) never follows production: each publish after a release with new tables offers to DROP them. Its scripts/replit-deploy.sh also has no FLOW_DEPLOY_DRIVER line, so it does not hand any step over. Take the deploy files from the newest stable release once, in the Shell, then follow production and start the updater. The fetch names the public repository, so it works without an origin remote:

R=https://github.com/arcforgelabs/flow.git
T=$(git ls-remote --tags --refs "$R" 'v20*' | sed 's|.*refs/tags/||' | grep -v beta | sort -V | tail -1)
git fetch -q "$R" tag "$T"
git checkout "$T" -- .replit .gitignore scripts/replit-deploy.sh \
  scripts/deploy-checks.js scripts/deploy-release.js server/release-channel.js
bash scripts/replit-deploy.sh update-workspace
bash scripts/replit-deploy.sh boot

update-workspace needs FLOW_PRODUCTION_URL or FLOW_PUBLIC_ORIGIN in the workspace's Secrets; without either it says so and changes nothing.

A workspace copy of scripts/replit-deploy.sh that has the FLOW_DEPLOY_DRIVER line hands the workspace step to the fetched release's own copy ("Each release brings its own build steps"), so it already follows production. It also rebuilds the release at each check until production runs it; taking the deploy files ends that.

A workspace is fetch-only ("A production installation: fetch only"): fetch, never push.

Each release brings its own build steps

scripts/replit-deploy.sh in the workspace fetches the commit and checks it (release manifest, commit, CI). It then hands the build and the workspace migration to the fetched commit's own copy of the script, when that copy says it can (# FLOW_DEPLOY_DRIVER=2). So a fix to those steps reaches an installation with the next release, without a pull. A release from before this builds the old way. As in OpenClaw's updater, the part that cannot update itself (the fetch and its checks) stays small.

Moving to the flow schema (once)

The release with migration 0021 moves an installation's tables from public to flow. Until both databases ran it, public still differs once:

  1. Before publishing that release, press Run. Its last line must say the workspace database matches production.
  2. Publish. Replit shows no list; production moves its tables when it starts.
  3. Press Run again (or leave the workspace open for ten minutes): the workspace follows production and moves its tables too. A publish before that may list tables to create in public: press Cancel.

After that, public is empty in both, and no release's migrations show a list again.

Later: removing a table or column

Migrations only add today ("Change the schema" in docs/DATA_MODEL.md). Removal is planned in two releases, so a rollback stays safe and the publish step can name each DROP in advance:

  1. Expand: one release stops reading and writing the column. The column stays.
  2. Contract: a later release drops it, in a migration that names the release that stopped using it. The doctor then reports the DROP as expected, and Flow refuses it while production is older than the expand release.

This is not built yet; the migration guard still refuses every drop.

Following stable releases

Production should run published releases (docs/RELEASING.md), not every merge to main. Set the deployment secret FLOW_DEPLOY_REF to stable (Publishing > Adjust settings > Secrets), then publish. Each publish then deploys the release the stable channel points at:

  1. The build asks GitHub, without a token, for its latest release (scripts/deploy-release.js, server/release-channel.js). That is the newest stable release, or an earlier one if Flow Release Promote rolled latest back. If GitHub cannot answer, it asks npm for @arcforgelabs/flow's latest dist-tag instead.
  2. It downloads that release's release-manifest.json and checks it against its .sha256.
  3. It fetches the release's tag and requires the commit to be the one the manifest names.
  4. The CI check above applies as before.

If any step fails, the build fails and nothing is deployed; it never falls back to main. The build log names the release: [deploy] release: stable is v2026.10.18 at <commit> (from github; release-manifest.json sha256 <hash> verified), then [deploy] built github stable (v2026.10.18) at <commit>. GET /health shows it as build.ref stable and build.release v2026.10.18.

FLOW_DEPLOY_REF=beta follows the beta channel the same way: the newest release of all, betas and stable releases alike, so it is never behind stable.

A publish deploys whatever the channel points at when it runs; a new release does not publish itself. When one is out, the server log says [flow] update available: …, and an owner can read the same from GET /api/updates (docs/SELF_HOSTING.md, "Update notice"). To upgrade, read the release's notes, then publish again.

Pinning a release and rolling back

Set the deployment secret FLOW_DEPLOY_REF to a release tag such as v2026.10.18, a branch or a full 40-character commit hash, then publish. A release tag is reported as build.release too. To roll back, set it to the older tag or commit and publish; to follow the channel again, set it back to stable. Use a secret, not a deployment Configuration value: a Configuration value is written into .replit.

Flow's database migrations only move forward. An older release runs against the newer schema, which works while the migrations are additive; check the migrations in the newer release's manifest before you roll back.

Replit's documentation does not say whether deployment secrets reach the build command. The start log checks it: WARNING: FLOW_DEPLOY_REF is '<ref>' now, but this build fetched 'main' means the build did not see the secret. Then put the ref in the build command itself, in .replit: build = ["env", "FLOW_DEPLOY_REF=<ref>", "bash", "scripts/replit-deploy.sh", "build"].

A fork sets FLOW_DEPLOY_REPO to its own repository's https:// URL; the fetch is anonymous, so the repository must be public.

Emergency: build a commit whose CI has not passed

FLOW_DEPLOY_ALLOW_UNCHECKED=1 (deployment secret, or in the build command as above) builds the fetched commit even when its CI did not pass or GitHub could not say. The build still asks, and logs the answer, then WARNING: FLOW_DEPLOY_ALLOW_UNCHECKED=1: building <ref> at <commit> although CI did not pass or could not be read. /health then shows checks: "unchecked". Use it only when production must change and CI cannot pass in time, for example during a GitHub Actions outage, and remove it at the next publish.

Emergency: publish the workspace files

FLOW_DEPLOY_FROM_WORKSPACE=1, together with FLOW_DEPLOY_ALLOW_UNCHECKED=1 (no CI ran on these files), builds the workspace's files as they are, uncommitted edits included, with no fetch. Without FLOW_DEPLOY_ALLOW_UNCHECKED=1 the build refuses. Files .gitignore excludes (node_modules/, config, .env) are left out. /health then shows source: "workspace" and checks: "unchecked". Use it only when GitHub cannot be reached or a fix cannot wait for a merge, and remove both at the next publish.

GitHub

The workspace tracks main on arcforgelabs/flow (or your fork). Nothing commits to main directly; the ruleset on main refuses a direct push (CONTRIBUTING.md, "Merging to main"). Work on a branch, open a pull request, merge it on GitHub, then pull. Publishing does not need the pull.

  • scripts/github-sync.sh status: remote, ahead/behind, and whether GITHUB_TOKEN works.
  • scripts/github-sync.sh pull: fast-forward main from GitHub. It needs no token for a public repository. It drops Replit's "Published your App" commits when together they change no files, and stops if a local commit changes files.
  • scripts/github-sync.sh open-pr "<title>" [body-file]: push the current branch and open a pull request.
  • scripts/github-sync.sh handoff: what to click in the Git pane when no token is set.

GITHUB_TOKEN (Replit Secret) is needed only to push or open pull requests from the workspace: a fine-grained token scoped to this repository only, with Contents and Pull requests read/write. An installation that only follows releases needs none. Replit Agent reads replit.md, and its GitHub instructions are in .agents/skills/github-sync/SKILL.md.

A production installation: fetch only

A workspace that runs a production installation should only follow releases. It reads from GitHub and never writes to it. Development happens elsewhere and reaches the installation as a release.

To make the workspace fetch-only:

  1. Delete the GITHUB_TOKEN secret (Secrets). Revoke the token on GitHub too (Settings > Developer settings > Personal access tokens).

  2. In the Shell:

    git remote set-url --push origin no-push--this-workspace-is-fetch-only
    git config --unset credential.helper
    printf '#!/bin/sh\ncase "$2" in *github.com*|no-push*) echo "push refused: this install is fetch-only" >&2; exit 1;; esac\n' > .git/hooks/pre-push
    chmod +x .git/hooks/pre-push
    

    git remote -v then shows the GitHub URL for fetch and no-push--this-workspace-is-fetch-only for push. A push from the Shell or the Git pane fails. The hook refuses only GitHub, so Replit's own backup remote keeps working.

  3. Do not press Push or Sync Changes in the Git pane. Pull is safe.

Publishing is not affected: the build fetches from GitHub without a token. Replit's "Published your App" commits stay local. They change only .replit (for example a port Replit detected), and scripts/github-sync.sh pull drops them when together they change no files.

Where an installation's settings go

Nothing specific to one installation goes in git. The repository is shared by every installation.

Setting Where
Secrets: keys, tokens, client secrets Replit Secrets (workspace) and Publishing > Adjust settings > Secrets (published app)
Start-up settings: FLOW_AUTH, FLOW_DEPLOY_REF, FLOW_THEME, FLOW_SIGN_UP_DOMAINS, integration modes Replit Secrets, as above. A deployment Configuration value is written into .replit, so use a secret.
Business data and in-app settings: people and roles, organisation, prices, integration connections Flow's database (PostgreSQL), changed in Flow's own screens
Code, migrations, defaults for every installation This repository, through a pull request

When an installation needs a setting that does not exist, add it to Flow as a generic option (an environment variable or an in-app setting) by a pull request, and set its value in the installation.