Field on Replit
Field can run as a published Replit app. This is the runbook for an
installation on Replit, for people and agents alike. Replit's Agent reads
replit.md, a link to AGENTS.md, which lists the tasks of an installation
and the sections here that cover them.
What runs here
Field is a quoting and invoicing app: catalogue, quote builder, quote and invoice PDFs, and sending them by email. It does no AI work of its own.
- Run with
scripts/replit-start.sh(build:scripts/replit-build.sh). The published app is built and started byscripts/replit-deploy.shfrom a commit fetched from GitHub ("Publishing"). - Runtime: Node 24 (
.replitmodulenodejs-24), matching CI and the Dockerfile. - Field keeps its state under its data directory (
FIELD_DATA, defaultdata/). A published Replit app does not keep files between publishes, so the published app must run with durable storage on (FIELD_STORAGE=postgres, see "Durable storage" below); without it every publish starts from an empty data directory. - Checks:
npm test.
Configuration
There is no config.json on Replit. Field reads its config from Replit
Secrets, which reach the app as environment variables:
| Secret | What it holds |
|---|---|
FIELD_CONFIG_JSON |
The whole config object as one JSON value, shaped like config.json.example: authToken, publicUrl, mail (sender and deliveryPolicy), googleOAuth (clientId, clientSecret), googleDriveFolderId, bootstrapUsers, mcp, and businessProfile (your trading name, ABN, address, contact details and bank account; README "Business profile"). Leave out port and dataDir. When it is set, FIELD_CONFIG and config.json are ignored, and invalid JSON stops Field at boot. |
FIELD_TOKEN |
Optional. Overrides authToken. |
GOOGLE_SERVICE_ACCOUNT_KEY |
Optional. The service account key JSON itself (paste the whole file). A file path still works elsewhere, and googleServiceAccountKey in config can hold either. |
FIELD_SECRETS_KEY |
The key that encrypts integration credentials and tokens (Xero). Make it in Field: Settings, Integrations, the Xero card opens the Connect wizard, and its step 3 generates it in your browser and shows it once. Add it as a deployment secret (Publishing, Adjust settings, Production app secrets, Add deployment secret, name FIELD_SECRETS_KEY), paste the key, then Republish; the wizard goes on by itself. Fallbacks: a random password of at least 40 characters from Bitwarden's generator, or 32 random bytes in base64 (docs/INTEGRATIONS.md). Without it, integrations stay off. FIELD_SECRETS_KEY_PREVIOUS holds the earlier key while rotating. |
XERO_CLIENT_ID, XERO_CLIENT_SECRET |
Optional override. The Xero app's credentials are entered in Settings, Integrations, Xero; when both of these are set they win, and the screen says so (docs/XERO.md). |
Every value in FIELD_CONFIG_JSON is treated as secret, so keep it a Secret,
never a plain env var or a file in the repo. Field never writes config back:
Gmail OAuth refresh tokens granted through the app are stored in
FIELD_DATA/gmail-oauth-users.json (mode 0600) and take precedence over any in
googleOAuth.users.
scripts/replit-start.sh serves on PORT and sets FIELD_RUNTIME=replit
(also set in .replit), which makes Field refuse a local PGlite database: on
Replit the database is always DATABASE_URL.
Security headers
Every response carries X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin
and a Content-Security-Policy (server/security-headers.js). The
configuration value FIELD_CSP sets how the policy applies:
FIELD_CSP |
Effect |
|---|---|
Unset or report-only |
The policy is sent as Content-Security-Policy-Report-Only. The browser console names each script, frame or request the policy would block, and nothing is blocked. |
enforce |
The policy is sent as Content-Security-Policy, and the browser blocks what it does not allow. |
off |
No policy is sent. The other headers stay. |
The page's inline scripts run under a nonce made for each response, so the
policy has no 'unsafe-inline' for scripts. Before you set enforce, open
the published app with the browser console open, in report-only. Sign in
with Google, open a quote PDF and send a problem report. Then look for
[Report Only] messages. A wrong value stops Field at boot.
Database
Field's PostgreSQL connection comes from DATABASE_URL, the secret Replit
creates when the workspace's PostgreSQL database is added (FIELD_DB_URL
overrides it if both are set). It is a credential: keep it a Secret and never
print, log or paste it; Field's logs and npm run db:smoke name the variable,
not its value.
The workspace and the published app have separate databases, each with its
own DATABASE_URL. The workspace's database is a copy: it follows
production's migrations by itself ("The workspace database" below), but its
data is not production's. A command run in the workspace shell reads and writes the
workspace's database, never production's. Never copy production's database
credentials into the workspace. Field checks and changes production's data
from inside the published app instead: data migrations and backfills run at
boot, and an owner checks them with the dry-run route ("Data migrations").
- On every start, before it serves anything, Field applies any pending
migrations from
server/db/drizzle(log line[db] postgres (DATABASE_URL): ...). If the database is unreachable or a migration fails, Field exits instead of starting. - The next log line,
[db] organisation <slug> (<uid>), names the organisation the installation serves. LeaveFIELD_ORGunset: Field then serves the default organisation,defaulton a new database. An installation from before #122 keeps its slug and needs no change (server/db/README.md, "Default organisation"). npm run db:smokeconnects, runsselect 1and reports applied and pending migrations without changing anything (exit 2 when migrations are pending).- Without
DATABASE_URLField does not start: the price catalogue needs it ([db] no database (DATABASE_URL is not set), then the catalogue refuses).FIELD_PRICE_CATALOG_ALLOW_UNAVAILABLE=1is an incident override that starts it with pricing blocked. It never falls back to a local database on Replit: PGlite (an in-process PostgreSQL for local development,FIELD_PGLITE=1) is refused here and Field exits, because a published app loses it on the next publish. - The price catalogue lives in this database (
server/catalog/README.md). Field refuses to start until the catalogue has active items, so import it before the first start: copy your legacycatalog.dbinto the workspace, thennpm run catalog:import-legacy -- <catalog.db> --dry-run(verifies and rolls back) and the same without--dry-run. The import is one verified transaction, carries the data over as-is, and refuses a catalogue that already has items unless given--replace. It imports into the databaseDATABASE_URLnames: in the workspace shell, that is the workspace's database, not production's. Data problems it carries over (missing items the quote rules name, archived items still used,-copycodes) are listed by the catalogue's "need attention" filter (/pricing/health); they do not stop Field. - Cutover order: import first, then publish. A build with the PostgreSQL
catalogue does not boot without
DATABASE_URLand an imported catalogue, so do not deploy it to a host that has neither.--replacedeletes the PostgreSQL-era price history and the catalogue audit trail; only point-in-time restore undoes it. - Catalogue backups are the database's point-in-time restore;
npm run catalog:export -- --out <file>writes a JSON copy. - If
DATABASE_URLis a Neon pooled endpoint (host name containing-pooler), Field runs migrations against the same host without-pooler(the direct endpoint), because they hold a session advisory lock that a transaction pooler does not keep. Any other pooler: setFIELD_DB_URLto a direct endpoint.
See server/db/README.md for the runtime module and how to add tables.
The workspace database
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. That breaks Field: Field migrates production itself when it starts, and a table made ahead of time stops that migration. The tables differ when the workspace database has fewer migrations than production (production gained a release's migrations when that release started) or more (the workspace applied migrations production does not run yet).
So the workspace database follows production by itself. Set it up once:
- Tools > Secrets in the workspace: add
FIELD_PRODUCTION_URLwith the published app's address, for examplehttps://field.example.com. It is not a credential. Put it in the workspace's Secrets, not in the deployment's. - Pull the workspace once (
scripts/github-sync.sh pull), so it hasscripts/replit-workspace-db.jsand the.replitthat runs it. - Check it in the Shell:
npm run db:follow-production. The last line is JSON."state":"level"means the workspace database has exactly production's migrations.
Then scripts/replit-workspace-db.js sync runs each time the workspace
boots (.replit onBoot) and each time the workspace's Run button starts
Field (scripts/replit-start.sh). It:
- Reads
GET <FIELD_PRODUCTION_URL>/health(no credentials) forbuild.revision, the commit production runs, andbuild.release. - Fetches that commit's
server/db/drizzle/from GitHub, without a token, into.field-workspace-db/(gitignored). It checks that the fetched commit isbuild.revision.FIELD_DEPLOY_REPOnames a fork's repository, as for the publish. - Compares those migrations with the rows in the workspace database's
drizzle.__drizzle_migrations(each migration file's hash and its journal time), and acts on the result:
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 the same migration lock the server holds (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 or FIELD_DB_URL,
as npm run db:smoke chooses), 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 never prints
the connection string. Without FIELD_PRODUCTION_URL it says how to set it
and does nothing. At boot and at Run, a failure prints a warning and Field
starts anyway.
ahead corrects itself: when production publishes the release that has
those migrations, the workspace is level again. Until then, the next
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) and
run npm run db:follow-production. The workspace database holds no
production data, but this deletes everything in it.
The workspace's Run button starts the workspace checkout's code, and Field
applies that checkout's own migrations when it starts. If the checkout has
migrations production does not run (usually the workspace was pulled to
main while production follows stable), starting it puts the workspace
database ahead again. The sync warns about this:
WARNING: this workspace's checkout has migrations production does not run,
and its JSON shows "checkout":{"state":"ahead",...}. Then do not press
Run until production runs a release with those migrations, or press Cancel
at the prompt.
npm run db:follow-production:status compares without changing anything
(exit code 2 when the workspace is behind). Do not use npm run db:apply in
an installation's workspace: it applies the checkout's migrations, not
production's.
Field keeps its tables, sequences and functions in the PostgreSQL schema
field, not in public. Replit's publish check compares only public.
Once both the workspace and production have run migration 0025_field_schema,
both public schemas are empty (extensions may remain; drizzle.__drizzle_migrations
stays in schema drizzle, which Replit already ignores), so the check has
nothing to differ on. The Database pane may list only public. The tables
are still there: in the SQL runner, name them field.<table> (for example
select * from field.organizations).
If Replit still shows "Development database changes detected", press Cancel. Never pick an option there.
The release that adds 0025_field_schema is the move out of public. Back
up the production database first (Database > production > Settings >
Advanced > backups). That release publishes while the workspace is level
with production, so both public schemas still hold the same tables and
Replit shows no prompt. Production then runs 0025 at start and its
public schema becomes empty. If the workspace sleeps until the next
publish, its public still has the tables and production's does not.
Replit then offers to create those tables in production's public. Press
Cancel. Opening the workspace runs the boot sync, which applies 0025
there too. After that, both public schemas stay empty.
Data migrations
Some releases change existing records, not just the schema: re-keying quote
rows by uid, for example, or filling the quote and invoice rows from the job
files. These are data migrations (server/data-migrations/). The published
app runs each one at boot, after the schema migrations and after durable
storage has restored the data directory, and before it serves anything. They
read production's own data directory and database, so nobody needs
production's credentials to run them.
- It works out every change and verifies it before writing anything. For
the quote-row re-key that means: removing the added uids gives back each
file exactly; each uid is the item the row's code resolves to; and each
changed quote renders to the same PDF bytes, with the same pricing digest
and commercial checks. If verification fails, Field logs
[data-migrations] ...and exits; nothing has been written. - Each job is written as a new job generation, so the previous generation keeps every file as it was, and the new one is read back and checked (changed files hold what was planned; every other file, issued PDFs included, is byte-identical). With durable storage every job's change is stored in one flush: all of it reaches PostgreSQL, or none of it, and a failed boot rebuilds the old files from PostgreSQL next time.
- It is recorded in the
data_migrationstable and never runs again, so a later edit in the app is not undone by a restart. The log line is[data-migrations] <id>: applied (...); recorded.
The row backfills
The quote rows backfill and the invoice rows backfill fill the quote and invoice tables from every job's files. They write database rows only, never job files. They check each job before they write it:
- quote rows: each version's totals equal what Field shows, to the cent, and a sent version's equal the totals on the PDF the customer was sent;
- invoice rows: each issued invoice equals its stored revision and the totals its PDF printed, the payments add up to the amount paid, and the invoices do not claim more than the quote.
A job that fails a check is refused. A refused job never stops boot. The recording rule:
- No job refused: the backfill is recorded, with its summary (written, already present, warnings), and never runs again.
- Some jobs refused: every job that verifies is written. Each refused job is logged by job uid, issued-document uid and mismatch kind, never a number, an amount or a customer name; the dry run shows the numbers. The backfill is not recorded, so the next boot runs it again. A retry is one pass over the jobs: a job already written is checked and left as it is, so a retry writes only what changed. To clear a refusal, correct the job in the app; the next boot then writes it and records the backfill.
- The invoice rows backfill runs after the quote rows backfill, so invoices link the quote versions they bill. Until the quote rows backfill is recorded, the invoice rows backfill writes what verifies but is not recorded either, so a later boot fills in the links.
The boot log has one line per backfill:
[data-migrations] 2026-10-13-quote-rows-backfill: applied (12 job(s), 15 quote version(s): 15 written, 0 already present, 0 job(s) refused; 1 warning(s)); recorded
[data-migrations] 2026-10-13-invoice-rows-backfill: incomplete (10 job(s) with milestones, 14 invoice(s): 9 job(s) written, 0 already present, 1 job(s) refused; 2 warning(s)); not recorded (1 job(s) refused), runs again at next boot
[data-migrations] 2026-10-13-invoice-rows-backfill: refused job <job id>: <invoice number> totals: total rows 990.00 vs revision-pdf 1018.05
"nothing to change" in place of "applied" means every job was already present. The numbers above are an example.
A milestone the invoice rows backfill cannot map is refused with the kind
unmappable and a code. The dry run shows the code with the invoice number
and milestone; the log line shows the code only, for example
document (no uid) milestone 1 unmappable (no-issue-time). The codes:
| Code | Meaning |
|---|---|
no-issue-time |
An issued invoice with no issue time, and no other record gives one (below) |
no-invoice-number |
An issued milestone with no invoice number |
duplicate-number |
Two invoices on the job have the same number |
draft-no-number |
A draft milestone with no invoice number |
no-amount |
A milestone with no percentage and no fixed amount |
successor-not-issued |
A replaced invoice whose replacement is not issued on the job |
credited-no-time |
A credited invoice with no credit time recorded |
payment-no-invoice |
A payment with no invoice to record it against |
payment-no-date |
A payment with no date |
no-receipt-number |
A paid milestone with no receipt number |
Invoices with no issue time
From 2026-04-10 to 2026-08-05 Field set a milestone's status from its amounts. A $0 milestone became "paid". When the quote was priced later, the next rebuild changed that "paid" to "authorised". No issue event ran, so Field recorded no issue time, and sending such an invoice did not record one either.
The invoice rows backfill takes the issue time from the first of these
records that names the invoice. It refuses the job (no-issue-time) only
when none does:
- The job's
invoiceSentAt, when the job has one milestone (the send time of a job's single invoice from before milestone invoices). - The issued document for the invoice number on this job, then the first issuance ledger delivery whose PDF names it.
- The first archived email (
sent-emails.jsonl) that names it. - The first
invoice.sentevent in the job's activity log that names it. - The date the stored PDF prints ("Issued: 14 April 2026"). This is a date
only: Field uses midnight on that date in the business time zone (the
TZsecret, Australia/Adelaide), and that date as the issue date.
The invoice row's note names the source. The backfill counts the sources in
its summary (issueTimesFromEvidence) and in its log line, for example
issue times from evidence: stored-pdf-date 3. The totals are still checked
against the stored PDF.
Legacy invoices whose record and PDF disagree
Milestone invoices issued before 2026-08-05 can hold amounts from one quote version while their stored PDF prints another (#60). Before that date, every milestone action re-allocated every milestone's amounts, including issued milestones, from the quote that was current. It redrew the PDF only of the milestone that it acted on.
The invoice rows backfill uses what was emailed:
- Field holds a hash-verified copy of the emailed PDF. This is an issued
document with an
emailedevent, or an issuance ledger record. The rows take the amounts that were emailed, and the job is written. Later syncs keep those amounts. The job page and the amount due still bill the milestone record until you settle the invoice withsent-copy. - Field holds no such copy. The job is refused. Invoices emailed before the issuance ledger have no such copy.
To see and settle every affected invoice, sign in as an owner and open
Invoices › Choose amounts (/#/invoices/amounts). The Invoices page shows
the notice while an invoice is not settled. The screen uses the two
routes below; you can also call them directly.
The report route:
GET /api/admin/invoices/amount-disagreements
The route is read-only. For each invoice, it lists the job id and the invoice number. It shows the amounts in the milestone record, in the stored PDF and in the emailed copy (when Field has one). It shows the quote version that each was drawn from. It also lists the archived emails of the invoice, with the quote version that each was built against.
Each entry has a note that says what Field bills now. Use the archived
email to decide which set the customer received, and the payments to
support it. When Field holds the emailed copy, sent-copy is the only
choice. Then record your choice on the screen, or with the settle route:
POST /api/admin/invoices/amount-disagreements/settle
{ "jobId": "<id>", "invoiceNumber": "<number>", "choose": "milestone-record" | "stored-pdf" | "sent-copy",
"total": "<the total of that set>", "reason": "<why>" }
- Field records the choice on the invoice, with who made it and why.
- What is owed and the invoice rows follow the choice. The PDF and the payments do not change.
- Field refuses a choice below the amount already paid. Void or move the payment first.
- Field refuses a choice that would make the job's issued invoices claim more than the quote. A settlement is made once.
- The next boot writes the job.
Checking a release in production
The workspace shell cannot check production: its database is the workspace's own ("Database"). Production checks itself, through an admin route in the published app:
GET /api/admin/data-migrations/dry-run
- Only an owner can call it: with Clerk sign-in, a verified owner sign-in or
the break-glass key; with password sign-in, an
admin. Others get 401 or 403. - It runs every data migration not yet recorded, held ones included, against production's live data directory and database, and writes nothing. Checks that need a write run in a transaction that is rolled back.
- It answers with JSON: per migration its id, status (
would-apply,nothing-to-do,would-refuse,already-applied,deferred), its summary (counts only) and, for a backfill, each refused job with its id and both numbers. It holds no customer names.okis false when a backfill would refuse a job or a migration fails its verification. - One runs at a time (another call gets 409). It stops after 2 minutes (504); a new one can start once the old one has stopped at its next job.
To check a release before its data migrations run:
- Before you publish, set the deployment secret
FIELD_DATA_MIGRATIONS_HOLD=all(Publishing > Adjust settings). Publish. Boot logs each data migration as held. - Sign in to the published app as an owner. With Clerk sign-in, open
/api/admin/data-migrations/dry-runin the same browser: the session cookie authorizes a read. With password sign-in, send an admin's token in theAuthorization: Bearerheader. ok: true: remove the hold and republish. Boot runs the migrations.ok: false: keep the hold and look at the refused jobs or the failed verification in the answer and the server log.
The row backfills need no hold: a refused job leaves its backfill unrecorded, and boot carries on. Use the route after a publish to see what is still refused.
The data migrations so far, in the order they run:
| Id | What it changes | Verified by |
|---|---|---|
2026-10-12-quote-rows-by-uid |
Adds catalogItemUid / sourceUid beside codes on every job's quote rows |
Files differ only by the added keys; PDFs byte-identical; pricing digest unchanged |
2026-10-12-price-book-cleanup |
A price book clean-up driven by server/data-migrations/price-book-cleanup.plan.json: names, types, categories, kits flattened, per-metre walls, unused items archived |
One catalogue transaction: every item prices exactly as before or it rolls back; an item edited in the app since the plan was made is left alone; an item on a draft quote is not archived |
2026-10-13-quote-rows-backfill |
Writes every job's quote versions as quote rows (#6); no job files | Per job: rows equal what Field shows, to the cent; a sent version equals its sent PDF. Refused jobs are logged and the backfill retries at the next boot |
2026-10-13-invoice-rows-backfill |
Writes every job's milestones, invoices, payments and receipts as rows (#27), linked to the quote rows; no job files | Per job: each issued invoice equals its revision and its PDF; payments add up. Refused jobs are logged and the backfill retries at the next boot |
The repository ships no clean-up plan. Without
server/data-migrations/price-book-cleanup.plan.json the clean-up is reported
as deferred and is not recorded, so it can still run once a plan is
supplied (and an installation that already applied it is unaffected). With a
plan, on a catalogue it was not built for (fewer than 90% of the planned items
present), it has nothing to do and is recorded as such.
Emergency stop: the deployment secret FIELD_DATA_MIGRATIONS_HOLD=all (or a
comma-separated list of migration ids) makes boot skip them, logged as held.
Remove it and restart to run them. Rolling back after one ran: the previous
job generation still holds every file as it was, and point-in-time restore
covers the database; the changes are additive (uids beside codes, rows beside
files), so an older release reads them unchanged.
npm run data:migrate -- --dry-run rehearses the data migrations on copies
of the database DATABASE_URL names. In the workspace shell that is the
workspace's database, so it does not check production; use the route above.
It skips the row backfills. It suits an installation whose database you can
reach directly, for example a Docker one.
Following the row backfills
The quote rows and invoice rows from before #6 and #27 are filled at boot by
the 2026-10-13-quote-rows-backfill and 2026-10-13-invoice-rows-backfill
data migrations ("The row backfills"). There is nothing to run in the
workspace shell: there, scripts/backfill-quote-rows.js and
scripts/backfill-invoice-rows.js reach only the workspace's own database.
After the release that adds them is published:
- In the server log, find the two
[data-migrations]lines for the backfills.recordedmeans done. incompletemeans jobs were refused. The next lines name each refused job by uid, with its issued-document uid and the kind of mismatch. Every other job is written.GET /api/admin/data-migrations/dry-runshows the same refusals at any time, with the counts.
Warnings do not refuse a job: tax-total-printed (a milestone's GST kept as
issued), reconcile-stale (drafts built against an earlier quote),
issued-unverified (no readable copy of the PDF) and payment-on-superseded
(a payment on a revision that was replaced without being credited). A
revision made in place before credit notes stays as issued: it gets no credit
note and its payments stay where they were recorded.
Durable storage
With FIELD_STORAGE=postgres the data directory is a working copy of what
PostgreSQL holds (server/storage/durable-store.js):
- On start, before serving, Field takes a PostgreSQL advisory lock (only one
Field process may write a database) and rebuilds the data directory from
PostgreSQL: files with their modes and modification times, and the newest
image of
field.db(plus the legacyprice-catalog/catalog.dbthe data import archived; nothing reads it, the catalogue is PostgreSQL tables). Local files PostgreSQL does not hold are moved toFIELD_DATA/.quarantine-<time>/, never deleted; local files or databases whose content differs from PostgreSQL (changes that were never stored, e.g. after a crash) are copied there before being replaced. On an empty database it stores what is on disk instead (log[storage] postgres: seeded ...). - Every change is stored in one transaction before a request that made it is answered (a request whose change cannot be stored gets a 503 saying so), at commit points (a published quote generation, user, OAuth and Gmail token saves, and before a quote, invoice or scheduled email is sent, so a restart can never send it twice; if that cannot be stored, nothing is sent), on scheduler ticks, every 5 minutes (a full check), and on shutdown. If Field dies mid-request, the next start comes back as of the last stored change.
- Not stored:
.openclaw/,.sessions/, temporary and lock files, quote generation locks and stages, leases, raw uploads (price-catalog/_uploads/),pglite/and SQLite files other than the databases above (old catalogue backups among them; PostgreSQL keeps the last 20 versions of each image instead). - It needs
DATABASE_URL(orFIELD_DB_URL); with PGlite or no database, Field refuses to start.GET /health(public) reports onlystorage.state(ok,degradedorlock-lost), whether the lock is held and the number of pending changes; file counts, last store/reconcile times and the last error are atGET /api/storage/status(admin login). Errors never include file contents or connection strings.
Set FIELD_STORAGE=postgres for the published app only (Publishing >
Adjust settings), never in the workspace's [env] or Secrets. The workspace
has its own database ("Database"), so durable storage there would only store
the workspace's data directory in the workspace's database. Two Field
processes on one database compete for the lock: the second to start waits
60 s (10 minutes in the published app; FIELD_STORAGE_LOCK_TIMEOUT_MS sets
it), then exits.
A republish is such a hand-over. Replit starts the new copy while the old copy
still serves and holds the lock, and stops the old copy only after the new
copy answers its health check (GET /). So while the new copy waits for the
lock, it answers on the app's port in standby (server/standby-server.js):
GET / is 200, GET /health is 503 with storage.state: "waiting-for-lock",
and every other request is 503 with Retry-After: 5. When Replit stops the old
copy, it stores its last changes and releases the lock; the new copy takes the
lock, rebuilds the data directory and serves. Users can see "Field is
starting" for a few seconds. The log line [storage] another Field process holds the storage lock; waiting for it to stop marks the standby.
Moving existing data in (once, before the first publish with durable storage,
with no Field process using the database). The script writes to the database
DATABASE_URL names: in the workspace shell, that is the workspace's
database, not production's ("Database").
node scripts/migrate-data-to-postgres.js --source <untarred data dir> --dry-run
node scripts/migrate-data-to-postgres.js --source <untarred data dir> --apply
--apply imports every stored file and both SQLite databases in one
transaction, then verifies file count, total bytes, every file's SHA-256
recomputed by PostgreSQL, and a rebuild into a temporary directory whose tree
hash must equal the source's (exit 1 if anything differs). It refuses a
database that already holds files unless --replace is given. In the same
transaction it imports the source's price-catalog/catalog.db into the
PostgreSQL catalogue (server/catalog/legacy-import.js, verified the same way
as npm run catalog:import-legacy); it refuses a catalogue that already holds
items, so use one route or the other, not both. --dry-run rehearses that
catalogue import on a throwaway in-process PostgreSQL and connects to nothing.
To re-run the cutover after a first --apply (for example from a fresher
tarball), the files and the catalogue are replaced separately:
node scripts/migrate-data-to-postgres.js --source <dir> --apply --replace --skip-catalog
npm run catalog:import-legacy -- <dir>/price-catalog/catalog.db --dry-run --replace
npm run catalog:import-legacy -- <dir>/price-catalog/catalog.db --replace
Issued documents in R2
Every issued quote, invoice and receipt PDF is stored in PostgreSQL
(document_blobs). With FIELD_DOCUMENT_REPLICA=r2 the published app
also copies each one to a Cloudflare R2 bucket in your Cloudflare account.
Give that bucket a bucket lock (#12) so the copies are write-once.
- PostgreSQL stays the store of record. A document is issued in one PostgreSQL transaction, as before, so issuing never waits on Cloudflare.
- After that transaction commits, the bytes are copied to R2 at
<org_uid>/<sha256>, with their content type and SHA-256. A scheduler tick every minute retries anything not yet copied, with backoff per document (1, 2, 4 ... minutes, at most an hour). - A copy never overwrites. Field asks R2 first: an object with the same size
and hash counts as done; an object with different bytes is an integrity
alarm (log line
[documents-replica] INTEGRITY ALARM, error codeR2_OBJECT_MISMATCH), and it is left alone. Field never deletes anything in R2. - Reads come from PostgreSQL. R2 is used only if PostgreSQL cannot give a document's bytes, and only bytes that match the document's recorded hash are served.
- If R2 is down or refuses the credentials, the replica shows as degraded. Field keeps serving and issuing; pending copies catch up when R2 is back.
- Progress is in the
document_blob_replicastable, so a restart carries on where it stopped.
What the bucket lock means
Create the bucket with a lock rule that covers the whole bucket for your
retention period, for example 7 years (2,557 days); Australian businesses
must keep records for at least 5. Until an object's retention ends, nobody
can overwrite or delete it: not Field, not the token, not an account admin.
That is the point, and it is why nothing but the published app may write
there. Tests use a local MinIO server with a locked bucket
(FIELD_R2_TEST_DOCKER=1), never a real bucket.
Settings
Set these as your deployment's secrets on the published app only: Publishing > Adjust settings.
| Name | Value |
|---|---|
FIELD_DOCUMENT_REPLICA |
r2. Unset: no replica, Field behaves as before |
FIELD_R2_ENDPOINT |
https://<account-id>.r2.cloudflarestorage.com (the account ID is in the R2 overview) |
FIELD_R2_BUCKET |
Your bucket's name, for example <your-bucket> |
FIELD_R2_ACCESS_KEY_ID |
The token's S3 Access Key ID |
FIELD_R2_SECRET_ACCESS_KEY |
The token's S3 Secret Access Key |
Set all five as Secrets, including the three that are not secret. Replit
writes a deployment Configuration into .replit under [userenv.shared]
on the next publish. That commits your account ID and bucket name to git,
gives them to the workspace, and loses them when the workspace is reset to
GitHub's main, which switches the replica off at the following publish.
Create an account API token with Object Read & Write on this bucket only. Field never prints either credential.
The workspace must not get them. Do not put them in the workspace's Secrets
or in .replit ([userenv.shared] reaches the workspace too). If
FIELD_DOCUMENT_REPLICA=r2 does reach the workspace, Field still starts
there: it logs one warning ([documents-replica] FIELD_DOCUMENT_REPLICA=r2 is ignored in the Replit workspace ...), keeps the replica off and never
writes to R2. Field tells the two apart by REPLIT_DEPLOYMENT, which Replit
sets only in the published app. FIELD_R2_ALLOW_WORKSPACE=1 overrides this;
do not set it.
On the published app, with FIELD_DOCUMENT_REPLICA=r2 and any of the other
four missing or malformed, Field refuses to start and the log names the
variable, never its value, for example [documents-replica] FIELD_R2_BUCKET is not set. Fix the setting and republish.
The first publish with the replica on
- At boot Field checks, read-only, that it can list the bucket (at most one
key) with the credentials. The log says
bucket <your-bucket> reachable, credentials accepted, or names an error code. A failure marks the replica degraded; it does not stop Field. - 30 seconds after boot the scheduler starts copying the documents issued before the replica was on: 5 per minute, one second apart, oldest first. It resumes after a restart. New documents are copied straight away.
Checking it after publishing
-
GET /health(public):storage.documentReplicahasstate(ok,degradedoroff),pending(documents not yet in R2),oldestPendingAgeSecondsandlastErrorCode. During the first backfillpendingfalls by about 5 a minute; it should reach 0 withstate: ok. -
GET /api/storage/status(admin sign-in):documentReplicaadds the boot check (bootCheck.ok), the last success and last error (time and code), mismatches, and counts. -
Once
pendingis 0, in a shell that has the deployment's database and R2 settings:npm run documents:verify-r2It reads every stored document back from R2 and checks its hash. It writes nothing. Exit 0 means every one is there with the right bytes; 2 lists what is missing, different or unreadable; 1 means it could not run. It needs the database and the four
FIELD_R2_*settings. On Replit no shell has them: the workspace has its own database and must not get the R2 settings. There, steps 1 and 2 are the check.
What an error code means:
| Code | Meaning | What to do |
|---|---|---|
R2_UNREACHABLE, R2_TIMEOUT, R2_HTTP_5xx |
R2 did not answer | Nothing; copies retry. Check Cloudflare's status if it lasts |
R2_ACCESS_DENIED, R2_SIGNATURE_DOES_NOT_MATCH, R2_INVALID_ACCESS_KEY_ID |
The token is wrong, revoked or lacks the bucket | Check the two secrets and the token's permissions, then republish |
R2_NO_SUCH_BUCKET |
FIELD_R2_BUCKET or FIELD_R2_ENDPOINT points at the wrong place |
Fix the setting and republish |
R2_OBJECT_MISMATCH |
An object in the bucket has the right name but the wrong bytes | Stop and investigate. PostgreSQL still holds the right bytes. Nothing can remove the object until its retention ends |
Rotating the token
- In Cloudflare, create a new account API token like the old one (Object Read & Write, this bucket only).
- Put its Access Key ID and Secret Access Key in the published app's
FIELD_R2_ACCESS_KEY_IDandFIELD_R2_SECRET_ACCESS_KEY. - Republish.
- Verify:
/api/storage/statusshowsbootCheck.ok: trueand/healthshowsstate: ok. Where a shell has the deployment's settings (not on Replit),npm run documents:verify-r2exits 0. - Only then delete the old token in Cloudflare.
Turning it off
Remove FIELD_DOCUMENT_REPLICA and republish. Nothing is lost: the
documents are in PostgreSQL, and the objects stay in R2 under the lock. Turned
on again, Field finds the objects already there and records them without
writing.
Sign-in (Clerk)
Field signs people in with Clerk through Replit's managed integration once
FIELD_AUTH=clerk is set; unset, it keeps email-and-password sign-in. Turn it
on in Tools > Users & Auth (add Clerk), which creates the app's Clerk
instances and stores their keys as Secrets, then set FIELD_AUTH=clerk and
restart.
| Secret | What it holds |
|---|---|
FIELD_AUTH |
clerk to sign people in with Clerk. Unset: password sign-in. Any other value stops Field at boot. |
CLERK_SECRET_KEY |
Set by the Clerk integration. Server only; never printed. |
CLERK_PUBLISHABLE_KEY |
Set by the Clerk integration (VITE_CLERK_PUBLISHABLE_KEY also works). Field hands it to the browser at runtime from GET /api/auth/config, so the build needs no Clerk key. |
Replit's Agent provisions the Clerk tenant. Tell it to provision only and not to rewrite Field's auth code (which already reads the two keys above); if it insists on a change, it opens a pull request.
In the Auth pane's Configure tab, enable Google only and disable email/password. Replit's managed Clerk has no MFA, so owners' second factor is Google Workspace's 2-Step Verification (enforced in Google Admin, an owner action). Set these as configuration values, not secrets:
| Configuration | Value |
|---|---|
FIELD_AUTH |
clerk |
FIELD_OWNER_GOOGLE_DOMAINS |
Your Google Workspace domain, e.g. example.com: owners signed in with a verified Google account on this domain, with no password, may do owner-only actions. No default; unset turns that path off |
FIELD_OWNER_MFA_MAX_AGE |
Unset (12h): how long a Clerk second factor counts, if one ever exists |
FIELD_PUBLIC_ORIGIN |
Published app only: its https:// origin. Clerk then accepts only sessions minted for it |
FIELD_TRUST_PROXY |
Unset: Field trusts one proxy hop on Replit so break-glass rate limiting is per client |
Clerk sign-in needs DATABASE_URL: Field users and their roles (owner,
staff) live in PostgreSQL, and the first boot imports today's users.json
people. New people wait for an owner's approval; CLI tokens and MCP
connections stay Field-issued. FIELD_TOKEN becomes the owner's break-glass
key. The cutover checklist, roles, the owner gate and break-glass are in
docs/CLERK_SIGN_IN.md.
Error monitoring (Sentry)
With SENTRY_DSN set, Field reports to a Sentry project: errors, a replay
of what the operator saw, traces, server logs, a few counters, and problem
reports the operator sends from the app. One setting covers the server and
the browser. Without SENTRY_DSN nothing is loaded and nothing is sent, so
an installation that does not set it sends nothing anywhere. Use a Sentry
account that belongs to the business that runs the installation; the free
Developer plan is enough.
What is reported:
- Server errors: every 5xx answer (most routes catch their own errors and
answer
{ error }; these are grouped by method, route and status), errors that reach Express's error handler, uncaught exceptions and unhandled rejections, and failed scheduled emails (tagfield.area: scheduled-email). - Browser errors: uncaught errors and errors the app's error boundary catches, except the stale-chunk errors an open tab hits after a publish (it reloads instead).
- Session replay: a masked recording of the screen (below). Sentry raises rage clicks (several fast clicks on something that does not respond) and dead clicks (no response within 7 seconds) as issues from it.
- Traces: page loads and navigations with Web Vitals, including INP (how long
the screen takes to respond to a click), and Express routes on the server.
Trace headers go only to Field's own origin.
/healthand static files are not traced. - Logs: the server's console output, scrubbed like errors.
- Counters:
field.document.issued,field.document.delivery_failedandfield.pdf.generation_failed, by document type. - Problem reports: a small button in the corner opens "Report a problem".
Only signed-in people with Field access see it; the sign-in screen has no
button. It asks for no name; a screenshot is optional, and the operator can
crop or black out parts of it before sending. The email field is optional
and starts empty. Under it, "Request a response" is a tick box, off by
default; ticked, the email is required. A report sent with the box ticked
gets the tag
notify=yes, elsenotify=no, and the Sentry-to-ClickUp relay tags the support tasknotify. The address stays in Sentry.
What never leaves (shared/sentry-scrub.js, used by both halves, on errors,
transactions, breadcrumbs and logs): request bodies, cookies, headers other
than User-Agent, query strings (sign-in tokens can travel in one), the user,
console breadcrumbs, local variables, and any value under a key that names a
customer, contact, address, title, note, bank or credential. Free text
(error messages, log lines, URLs, file paths) is rewritten: email addresses,
document numbers (EQ-…, EI-…, ER-…, INV-…, RCT-…, CN-…), money
amounts (with a currency sign, or a number with two decimals), Australian
phone numbers, BSBs and query strings become placeholders, and job folder or
route segments that are not UUIDs (older job folders were named after the
site address) become [job]. A whole amount without a currency sign looks
like a count, and a name or street typed into free text cannot be recognised
reliably, so log lines name jobs and documents by uid: a refused data
migration or row check logs the job uid, the issued document's uid and the
kind of mismatch, and the numbers and amounts stay inside Field (the
data-migrations dry run). Replays mask all text and inputs, mask title, aria-label, alt
and similar attributes, block images, frames, canvases, contact links, job
links and links with a query string, and record no network detail. A problem
report's message and screenshot are sent as the operator wrote and took them.
Free-plan limits: the Developer plan stores about 5,000 errors, 50 replays, 5 million spans and 5 GB of logs a month. Field sends the same error at most once an hour and at most 20 errors an hour in all (per server, and per open browser tab), so one repeating failure cannot use up the month. The default sample rates below fit a handful of users.
Settings
Set these as the deployment's Secrets on the published app only
(Publishing > Adjust settings), for the same reason as the R2 settings: a
deployment Configuration is written into .replit and committed.
| Name | Value |
|---|---|
SENTRY_DSN |
The Sentry project's DSN (Project settings > Client Keys). A DSN only lets a client send events; Field hands it to the browser in the page |
SENTRY_CLIENT |
Optional. A short name for the business this installation serves (letters, digits, - and _, at most 32 characters, for example embark). Every server and browser event is tagged client:<name> and product:field, so one Sentry organisation can route events from many installations. Unset: product:field only |
SENTRY_ENVIRONMENT |
Unset on Replit: the published app reports production and the workspace replit-workspace. Set it only to override both |
SENTRY_RELEASE |
Unset. Each build records its git commit (below), which becomes the release |
SENTRY_TRACES_SAMPLE_RATE |
Unset: 0.2 (about one page load or request in five). From 0 to 1 |
SENTRY_REPLAYS_SESSION_SAMPLE_RATE |
Unset: 0.05 (one session in twenty is recorded). From 0 to 1 |
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE |
Unset: 1 (every session that hits an error keeps its replay). From 0 to 1 |
SENTRY_LOGS |
Unset: on. 0 stops sending server logs |
SENTRY_METRICS |
Unset: on. 0 stops the counters |
SENTRY_FEEDBACK |
Unset: on. 0 hides the "Report a problem" button |
SENTRY_VERIFY |
1 for one publish only: boot sends Field Sentry verification (server). Remove it at the next publish, or every restart sends another |
Which DSN wins, when you move to another Sentry project:
- Server:
SENTRY_DSN, thenSENTRY_DSN_CMD(a command that prints a DSN).SENTRY_DSN_CMDruns only whenSENTRY_DSNis unset, so delete it, or point it at the new project too: ifSENTRY_DSNis ever removed, the command's DSN is used. - Browser:
VITE_SENTRY_DSN, then the server's DSN from the page.VITE_SENTRY_DSNis read at build time and is written into the bundle, so if it is set, browser events go to its project whateverSENTRY_DSNsays. Leave it unset on Replit. If it was set, delete it and publish again (the build reads it).
The defaults are sized for the free plan from the start, including during a Sentry trial, so nothing needs changing when a trial ends. Raise the rates only on a paid plan.
If the workspace also gets SENTRY_DSN (Replit's Secrets page feeds both),
its events are tagged replit-workspace, never production, as long as
SENTRY_ENVIRONMENT is unset. Limit the Sentry alert rule to the
production environment so workspace errors do not send email.
Start-up: scripts/replit-start.sh and npm start run
node --import ./server/observability/instrument.js server/index.js, so
Sentry starts before Express loads and can name traces by route. Started
without it (the Docker image), Sentry starts during boot: errors, logs and
request traces still arrive, without route names. Profiling
(@sentry/profiling-node) is not included: it needs a native binary in every
build, and tracing already shows which routes are slow.
Releases: the deployment build (scripts/replit-deploy.sh) writes the
commit it fetched from GitHub to .field-revision, and its start exports it
as FIELD_REVISION. (scripts/replit-build.sh run in the workspace writes
the last commit that changed files, so Replit's empty "Published your App"
commits are skipped.) Server and browser events carry it as their
release. The client bundle ships public source maps, so Sentry reads browser
stack traces against the real source with no upload token.
Checking it after publishing
GET /healthshowssentry.enabled: true, theenvironmentand therelease(a commit onmain).release: nullmeans the build could not read its commit; events still arrive, without a release.- With
SENTRY_VERIFY=1, the server's verification error is in Sentry within a minute of/healthanswering. - Browser: open the app, open the browser's developer console and run
setTimeout(() => { throw new Error("Field Sentry verification (browser)") }). It arrives with the same environment and release, and a replay. Both errors carry theproducttag (andclient, withSENTRY_CLIENT), and both arrive in the same Sentry project. If the browser error goes to another project,VITE_SENTRY_DSNis set (above). - Resolve both verification issues in Sentry, then remove
SENTRY_VERIFYat the next publish.
Alert rules (who hears about a new issue or a spike) live in the Sentry project, not in this repository. Nothing is posted to this repository's issues automatically; see issue #46 for how maintainers triage Sentry issues.
Publishing
Publish as a Reserved VM (Field sends scheduled emails in-process, so it must stay up), with sharing set to Public (Field does its own sign-in).
Field's tables are in schema field, so Replit's comparison of public
should find nothing to change ("The workspace database", including the
one-time move). If a publish still stops at "Development database changes
detected", press Cancel.
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 .field-deploy/, builds it there
and records its commit. The run command (scripts/replit-deploy.sh start)
starts Field from .field-deploy/. The default 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,client-build,docker-build(CI) andgitleaks(Secret Scan) must each have completed with success, as reported by GitHub Actions. Otherwise the build fails and the publish stops. A pinnedFIELD_DEPLOY_REFis checked the same way. - It only writes
.field-deploy/(gitignored). It never resets or deletes the workspace's own files, wherever it runs. .field-deploy/holds nodata/, so the published app rebuilds its data directory from PostgreSQL alone. The workspace's owndata/is still in Replit's snapshot, but nothing reads it, so boot quarantines nothing from it (#87).- The build log names what it deployed and its CI result:
[deploy] fetched main at <commit>,[deploy] checks: passed (test, client-build, docker-build, gitleaks) at <commit>, then[deploy] built github main at <commit> into .field-deploy/ (checks: passed). The start log repeats it, andGET /healthshows it asbuild.checks.
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) |
main's CI is still running on the merged commit. A merge to main runs CI again on the new commit, which takes a few minutes |
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 (for example a pinned commit from a branch whose CI was not run) | 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 deploy what is merged on main:
- Publishing > Republish. Nothing needs pulling first.
- If the publish stops at "Development database changes detected", press Cancel. Never pick an option there. The workspace and the published app have separate databases, and Replit offers to make production's tables match the workspace's; when the workspace is behind, every option drops or renames production tables. Field migrates production itself at boot.
- When Replit says it is done, Field still needs about a minute to rebuild
its data directory from PostgreSQL; until then requests get "Internal
Server Error".
GET /healthanswers once it is up. Itsbuildshows what is live:revision(the commit),ref(main, the pinned ref, or the channelstableorbeta),release(the release tag, when the build is one),source(github, orworkspacefor an emergency build) andchecks(passed, oruncheckedwhen an emergency override built it). Check thatrevisionis the commit you meant to deploy (the tip ofmainon GitHub) and thatchecksispassed. - Nothing to do for the workspace's database: it follows production by
itself the next time the workspace boots or Field starts in it ("The
workspace database"; needs the
FIELD_PRODUCTION_URLSecret). To do it now, runnpm run db:follow-productionin the Shell: it must report"state":"level". Run it after the publish, not before: production only gains a release's migrations when that release boots. It follows a pinned ref or a channel the same way, because it asks production which commit it runs.
.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. Before the first publish with them, pull the workspace
once so its .replit has the deploy commands. When a commit changes them, the build log says
note: ... differs from main; pull the workspace before the next publish so
the new ones take effect.
Following stable releases
Production should run published releases (docs/RELEASING.md), not every
merge to main. Set the deployment secret FIELD_DEPLOY_REF to stable
(Publishing > Adjust settings > Secrets), then publish. Each publish then
deploys the release the stable channel points at:
- 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 Field Release Promote rolledlatestback. If GitHub cannot answer, it asks npm for@arcforgelabs/field'slatestdist-tag instead. - It downloads that release's
release-manifest.jsonand checks it against its.sha256. - It fetches the release's tag and requires the commit to be the one the manifest names.
- 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.15 at <commit> (from github; release-manifest.json sha256 <hash> verified), then
[deploy] built github stable (v2026.10.15) at <commit>. GET /health shows
it as build.ref stable and build.release v2026.10.15.
FIELD_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. An owner can see that one is out from
GET /api/updates (docs/SELF_HOSTING.md, "Update notice").
The default is still main.
Updating to a new release
With FIELD_DEPLOY_REF=stable, after a release is published:
- Read the release's notes on GitHub. If it changes one of the deploy files
("When to pull the workspace"), run
scripts/github-sync.sh pullin the Shell first. - Publishing > Republish. If Replit stops at "Development database changes detected", press Cancel.
- Open
GET /health.build.releasemust be the new tag,build.checkspassed, andstorage.stateok. - The workspace's database follows by itself ("The workspace database").
To check it now:
npm run db:follow-productionin the Shell must report"state":"level". If the prompt ever appears, press Cancel.
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. The tool names carry the connection's name:
.claude/settings.json allows publish_app and get_publish_status for
the connection named Replit for Embark, the installation Field was built
for. Another installation adds its own names in its own settings. 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> --wait 300 # 0 live, 1 stop
In a Claude Code cloud session, put NODE_USE_ENV_PROXY=1 NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt before node, so Node uses the
session's proxy.
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, client-build, docker-build and gitleaks, and the live /health
is ok, follows stable from github and runs an older release. after asks
/health for up to --wait seconds until build.release is the tag with
checks: passed. Durable storage 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:
- Run
before. On 3 stop quietly. On 1 stop and report the problems. - Call
get_publish_status. Go on only if the last publish sayssuccess. - Call
publish_apponce. - Call
get_publish_statusevery 30 seconds, for at most 15 minutes. - On
success, runafter. On anything else, stop. - 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.
Claude Code's auto mode still asks before publish_app, because it deploys
production. The person who owns the installation approves by typing
publish in the same conversation as the agent. A message relayed from
another conversation is not that approval.
When to pull the workspace
A publish takes these files from the workspace, not from the release:
.replit, scripts/replit-deploy.sh, scripts/deploy-checks.js,
scripts/deploy-release.js and server/release-channel.js. They stay as
they were when the workspace was last pulled. Pull (scripts/github-sync.sh pull) when:
| When | Why |
|---|---|
| A release changes one of those files | Its fix takes effect only from the workspace. The build log says note: <file> in the workspace differs from <ref> |
Before the first release of a month numbered above 31 (2026.10.32), if the workspace was last pulled before v2026.10.21 |
An older server/release-channel.js reads the last number as a day, so it cannot read the tag and the build stops. Nothing is deployed and the live app keeps running |
After you change FIELD_DEPLOY_REF or the deploy settings in .replit |
So the workspace and the publish agree |
If git pull cannot fast-forward (the workspace has commits of its own),
take the deploy files from the newest stable release instead:
git fetch --tags origin
T=$(git tag -l 'v20*' --sort=-v:refname | grep -v beta | head -1)
git checkout "$T" -- .replit .gitignore scripts/replit-deploy.sh \
scripts/deploy-checks.js scripts/deploy-release.js server/release-channel.js
--sort=-v:refname sorts each number as a number, so 2026.10.32 comes
after 2026.10.9.
Pinning a release and rolling back
Set the deployment secret FIELD_DEPLOY_REF (Publishing > Adjust settings)
to a release tag such as v2026.10.15, 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 commit or tag and publish; to follow main again,
remove it and publish. Use a secret, not a deployment Configuration value: a
Configuration value is written into .replit.
Field's database migrations only move forward. An older release runs against the newer schema, which works while the migrations are additive (so far they are); check what the skipped commits migrated before you roll back.
Replit's documentation does not say whether deployment secrets reach the build
command. The start log checks it: WARNING: FIELD_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", "FIELD_DEPLOY_REF=<ref>", "bash", "scripts/replit-deploy.sh", "build"].
A fork sets FIELD_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
FIELD_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: FIELD_DEPLOY_ALLOW_UNCHECKED=1: building <ref> at <commit> although CI did not pass or could not be read. /health then shows
checks: "unchecked" (a commit that did pass still shows passed). 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
FIELD_DEPLOY_FROM_WORKSPACE=1, together with FIELD_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
FIELD_DEPLOY_ALLOW_UNCHECKED=1 the build refuses. Files .gitignore
excludes (data/, node_modules/, config) 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.
Production secrets (Publishing > Adjust settings) are separate from the
workspace's: FIELD_STORAGE=postgres, FIELD_CONFIG_JSON, the Clerk keys,
the R2 replica's settings ("Issued documents in R2") and Sentry's ("Error
monitoring (Sentry)").
Replit's managed Clerk also sets CLERK_PROXY_URL (/api/__clerk); Field
serves Clerk's Frontend API on that path (server/auth/clerk-proxy.js), so
sign-in loads from the app's own origin.
Custom domain: an A record plus a replit-verify TXT record (Replit gives
both), and Clerk's clerk., accounts., clkmail. and DKIM CNAMEs under the
same name. If your DNS provider proxies traffic, set these records to DNS only.
Data moved in from another host keeps job folder paths in field.db; Field
repoints them at its data directory on boot (log [field-db] Pointed N of M jobs at ...).
GitHub
The workspace tracks main on the GitHub repository
(arcforgelabs/field for the
upstream project; your fork otherwise).
- Nothing commits to
maindirectly, here or on GitHub; the ruleset onmainrefuses a direct push (CONTRIBUTING.md, "Merging to main"). Work on a branch, open a PR, merge on GitHub, then pull. Publishing does not need the pull ("Publishing"); the workspace's database and its.replitdo. scripts/github-sync.sh status: remote, ahead/behind, and whetherGITHUB_TOKENworks.scripts/github-sync.sh pull: fast-forwardmainfrom 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 PR.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. When used, it is a fine-grained token whose resource owner
is the repository's owner, scoped to this repo only, with Contents and Pull
requests read/write. Without it, the workspace only pulls, and changes reach
GitHub through pull requests opened elsewhere. The Agent's 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:
-
Delete the
GITHUB_TOKENsecret (Secrets), if there is one. Revoke the token on GitHub too (Settings > Developer settings > Personal access tokens). -
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-pushUse the name of the GitHub remote from
git remote -vin place oforiginif it differs.git remote -vthen shows the GitHub URL for fetch andno-push--this-workspace-is-fetch-onlyfor push. A push from the Shell or the Git pane fails. The hook refuses only GitHub, so Replit's own backup remote keeps working. -
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, 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 (FIELD_CONFIG_JSON, FIELD_SECRETS_KEY, Clerk, Sentry, R2) |
Replit Secrets (workspace) and Publishing > Adjust settings > Secrets (published app) |
Start-up settings: FIELD_STORAGE, FIELD_DEPLOY_REF, FIELD_DOCUMENT_REPLICA |
Publishing > Adjust settings > Secrets, for the published app only ("Durable storage", "Issued documents in R2"). A deployment Configuration value is written into .replit, so use a secret |
| Business data and in-app settings: people and roles, business profile, prices, integration connections | Field's database (PostgreSQL), changed in Field'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 Field as a generic option (an environment variable or an in-app setting) by a pull request, and set its value in the installation.