Documentation

Ship it

Draft, publish, deploy, roll back.

Audience: consultant. Prerequisite: 02 — Build the app.

Getting the app live, and operating it once it is.


Draft → published → deployed

Three distinct states. Confusing them is the most common source of “but I changed that” confusion:

StateMeaningWho sees it
Draftyour working editsonly you, in Build
Publishedfrozen as a versionnobody yet — it is a candidate
Deployeda published version running in an environmentthe client’s users

Editing a published artifact forks a new draft; the published version stays frozen and unchanged. So you can always answer “what is actually live?” — it is a specific frozen version, not “whatever the builder currently shows”.

The badge on an artifact reflects its real publish state, including a “published • edited” indicator when a published thing has an unpublished draft on top of it.

⚙️ Where Publish actually is, because it is not where you will look for it. Open a page under Build ▸ Pages, then press Publish in the top bar — target Staging, then repeat for Production. It is not on Build ▸ Deploy, and it is not on the Pages list either.

⚠️ Two things are called “published”, and only one of them can deploy. Every page in the Pages list can read published — that is the artifact’s own state — while Build ▸ Deploy still says there is nothing to ship, because a deploy needs a published version (a frozen snapshot, minted by the Publish dialog through the dev → staging → production pipeline). The Deploy screen names the control that mints one, so the next step is always on the screen.

⚙️ An approval shows what it approves. The run page lists each artifact and, under it, every change by name — “requiredAttributes: (none) → {“market”:[“BR”]}” — so you approve the change itself, not a count of changes.

⚠️ Production may be gated. Publishing to Production can return “awaiting approval” rather than publishing — an approval gate on that target starts an approval run, and the release ships only once it is approved. Staging publishes immediately. So “I pressed Publish and Production is still empty” is usually an approval waiting, not a failure.

⚙️ Build ▸ Deploy is scoped by the Production/Staging tab at the top, and that includes the Instructions panel at the bottom. Both halves follow the selector, so what is shipping and what you are told to do next always describe the same target.

⚠️ A deploy refuses while any non-admin role can read nothing. The message names the role. Grant it read access under Build ▸ Permissions, or — when nobody will ever hold that role (a system role no seat is given, say) — press “Deploy anyway — nobody will hold “‹role›””, which names the roles you are acknowledging and records that you chose to. Nothing is ever granted for you, and an app with no entities yet is never blocked — there is no blind seat to have.

⚠️ A deploy refuses a version that is older than what you have published since. This is the enforcement behind the two-meanings-of-published trap above. If you published a page and never cut a new version, the deploy would ship the last version — quietly, and reporting success. It now stops and names what is missing, page by page. Publish a new version (top bar ▸ Publish — Staging, then Production) and deploy again. Deploying a specific older version on purpose — a rollback, or a promote — is exempt and still works.

⚙️ A new app’s first deploy is never refused by its own first publish. The production sign-off gate is created when the version is taken, so the gate is inside the version it governs and the first Staging → Production run is enough to ship.

⚙️ Why this check matters. A deploy reports success against the version it was given, not against what you last published: 202 from the request, “succeeded” from the job, SUCCESS from the host and an advancing release history all agree even when the version predates the pages you meant to ship. This check is the one that compares the two.

⚠️ A deploy also refuses if the runtime image cannot be pulled. The platform’s image is private, so the studio server needs GHCR_TOKEN (a GitHub token with read:packages). Without it the refusal happens before anything is provisioned — which matters, because the failure it replaces created the project, the database and the domain and then died with no logs at all.

What a deploy actually carries — and what it leaves behind

Not everything you author reaches the app, and the parts that do are not all compared against what is already there. Three buckets, and the middle one is the one that surprises people:

flowchart LR
  AUTH["Everything you<br/>author in Build"] --> D

  subgraph D["THE DEPLOY"]
    direction TB
    A["<b>✅ SHIPS, AND IS DIFFED</b><br/>Entities &amp; schema · Pages · Rules<br/>Roles &amp; permissions · Navigation · Theme<br/>Definitions (terms &amp; measures)<br/>Library (documents you file)<br/><i>the change summary lists exactly what moved</i>"]
    B["<b>⚠️ SHIPS, NOT DIFFED</b><br/>Workflows · App users · Seed records<br/>Branding &amp; logos · Connections · ETL<br/>Watchers · Surfacing · Escalations<br/>Report templates · Inbound webhooks<br/><i>it arrives — the summary just won't mention it</i>"]
  end

  AUTH --> N["<b>🔴 NEVER REACHES YOUR APP</b><br/>Designs (saved looks)<br/>Role-based UI variants · Playbooks<br/>Notifications config<br/>Installed custom widgets<br/>Outbound webhooks<br/>App settings (embed mode)"]

  A --> APP["The running app"]
  B --> APP
  N -.->|"stops here"| X["stays in the studio"]

⚙️ Which tenant the harvest reads

Everything the box lists — workflows, app users, seed records, connections, ETL pipelines, watchers, surfacing rules, escalation chains, report templates and inbound webhooks — is harvested from the tenant holding the app, not the tenant holding the snapshot. For an app published from the builder those are different tenants, so this is the difference between shipping your configuration and shipping an empty database.

⚙️ The pre-deploy summary reads the same tenant the harvest does, so what it promises and what arrives are the same count.

⚠️ “Not diffed” is not “not shipped”. A branding change lands on the client’s app and the change summary stays silent about it, so a deploy that looks like “nothing much” can still alter what they see. Read the second bucket as “will change, unannounced”.

⚙️ Definitions deploy with the app. A term is one name, one definition, one owner, one formula, and it travels to the client’s app rather than staying in the studio — because the person quoting the number in a board meeting is the client, and they need to be able to read what it means.

⚙️ What that gets your client. Any widget that claims a term now carries a small marker beside its title; opening it shows the definition and who owns it. The marker is only there when a term is claimed, so its presence tells a reader that this number has a governed meaning — and its absence honestly says nobody has defined this one yet. What they read is the same term the publish gate holds your widgets to, so the number and its definition cannot drift apart.

⚙️ The Library ships too — for the same reason terms do. The documents you file against a client — operating procedures, policies, specifications, rate cards — travel with the deploy and appear on a Library page in their app. It is a shelf, not a drop box: they can read and download, and only you can file. Two entry points would mean two repositories and, inside a month, two different answers to “what is the current price list”.

⚠️ Each document carries its own visibility, and you choose it when you file. team is everyone with a seat; admin is restricted. A technician needs the sampling procedure and the safety policy to do the work; they do not need the rate card, and commercial terms are the one thing a client will not forgive you for leaking sideways. There is no default — if you do not choose, the narrow option applies — and the restriction is enforced on the server, so it holds for the assistant’s answers as well as for the page.

⚠️ A deploy never overwrites a person who is already in the app. Adding a user in Build gives them a working login on the next deploy. But once they exist, their name, role and password belong to the deployed app — change those in the app’s own Users screen, not in Build.

⚙️ Why that boundary matters. Every deploy, upgrade, setting change and platform restart starts the app. A grant the client’s own admin made in their app survives all of them, so you can demonstrate live and deploy between screens without taking someone’s access away behind them.

⚠️ Do not confuse the Library with Documents. Documents is the client’s own filing: files they upload, attached to their records. The Library is what you hold for them. Different shelf, different owner, different page.

⚙️ A large config deploys. What a deploy carries travels in as many hosting variables as it needs and is reassembled on arrival, so the size of what you file in the Library is not a limit you have to think about.

Records: what a deploy carries, and what it tells you when it cannot

A deploy carries the app’s records too — the rows you uploaded, synced or typed in the studio — and they are loaded on the first install only: an app that already holds data keeps its own. A deploy can carry about 384 KB of everything, compressed. Configuration comes first; records ride along when they fit, and when they do not they are held back and the app starts without them. Two places say which:

  • Before — the Deploy page’s What’s shipping counts configuration apart from records (“142 configuration items will deploy · 24,063 records held back”) and, when they will not fit, says so under the list with the size and where to load them. That is an estimate made from the manifest; the deploy decides.
  • After — the finished deploy says what the deployment says about itself, under Deployed — ‹address›: “24,063 records were not sent with this deploy: they come to 1,210 KB compressed, over the 384 KB a deploy can carry. The app is running without them — load them from its Import page.”

Records that were held back are loaded in the app, by whoever’s seat may add them: the app’s Import page lists what that seat can load and opens the same dialog a table’s Import opens.

⚙️ Once an app is installed, no deploy changes its records — carried or not. The Deploy page says so for an installed app (“This app is already installed, and a deploy never changes its records — the 24,017 in the studio are not sent”), and so does a finished redeploy. New records reach an installed app through its Import page, its scheduled imports and its feeds.

⚙️ Records travel in pieces, like the rest of the payload, and a deploy says what it held back. The first deploy is the only one whose records are ever loaded, so what it carries and what it holds back are both stated before you press it and again when it finishes.

⚠️ Upgrade the app before the first deploy that needs it. An app running an older version cannot reassemble a split delivery — it refuses to boot rather than starting with half its configuration, which is the right failure but a confusing one. Deploy ▸ Upgrade first, then deploy.

⚠️ Designs are in the never-ships list. A saved look you have been perfecting is a studio artefact. Themes and branding do ship — Designs do not. This is the single most common “but I made that change” on this screen.

⚙️ The screen is authoritative, not this page. The Deploy screen renders these three lists from a registry that a build-time guard forces to classify every authorable kind — adding a new kind without declaring where it lands fails the build. So the categories cannot silently grow a fourth, unlisted member. If this page and that screen ever disagree, believe the screen and file a bug against this page.

Shipping a change, screen by screen — and where you get stopped

The diagram above says what a deploy carries. This is what you actually do, and — more usefully — the four places the platform will refuse to let you continue.

⚙️ This diagram describes a SEQUENCE, which ages faster than structure. Its checks and refusals are anchored to the code that performs them, so a renamed route or a deleted gate turns this page red. A step reordered in the UI will not. If the screens disagree with this, believe the screens — and please report it.

flowchart TD
  E1["<b>Build ▸ Schema / Pages / Behavior</b><br/>your DRAFT — nobody else sees it"]
  G1{{"🚧 PUBLISH VALIDATION<br/>refuses a nav entry pointing nowhere,<br/>a widget bound to a deleted field"}}
  F1["fix it — the refusal names the thing"]
  E2["<b>Publish</b> — frozen as a version"]
  E3["<b>Build ▸ Deploy — Configure</b>"]
  G2{{"🚧 READINESS · 4 BLOCKING<br/>signed · tenant · deploy target · no dead config"}}
  F2["each failure carries its remediation"]
  G3{{"⚠️ ADVISORY — warns, never blocks<br/>a role that can read NOTHING"}}
  E4["press <b>Deploy</b>"]
  G4{{"🚧 PRODUCTION ONLY<br/>promotion needs an APPROVAL"}}
  E5["<b>Run ▸ Deployments</b><br/>streaming log"]
  APP["<b>the client's app</b><br/>config applies live —<br/>records untouched"]

  E1 --> G1
  G1 -->|blocked| F1 --> E1
  G1 -->|clean| E2 --> E3 --> G2
  G2 -->|any fails| F2 --> E3
  G2 -->|all pass| G3 --> E4 --> G4 -->|approved| E5 --> APP

The four refusals, and what each is really telling you:

WhereBlocks?What it means
Publish validation🚧 yesSomething in the config points at something that no longer exists. Loud now beats silently inert in production.
Readiness — Engagement signed🚧 yesYou cannot deploy for work that is not contracted. Needs a signed contract PDF on file (§01).
Readiness — a check reading “unknown”🚧 yesThe check could not RUN — a fault on the studio server, not in your engagement. It blocks deliberately: “ready” is a promise about what happens when you press Deploy, and a blocking check nobody evaluated cannot support it. Report it rather than retrying; the server log records the cause.
Readiness — Tenant provisioned🚧 yesThere is no database to deploy into yet.
Readiness — Deploy target🚧 yesRailway is not configured on this server. Infrastructure, not your engagement.
Readiness — No dead configuration🚧 yesA page, rule or watcher points at a field or entity that no longer exists — it would render blank in the client’s app. Names the page, the widget and the binding. This runs the SAME validator the deploy itself enforces, so readiness never says “ready” about a config the deploy will refuse. Anything the binding itself produces counts as a real field here, not just stored columns: a declared date bucket (month), a computed field, an aggregation alias — including the default sum(amount) form you never typed — a calculation name, and an alias contributed by a combined second entity. A chart plotting Avg margin is fine; a chart plotting a column that was deleted is still refused, and the message names it. A bucket built from a column that does not exist is refused too, naming the SOURCE column rather than the bucket. A field a join supplies counts as well: with joins declaring rep, a chart may group by rep.fullName, and the check verifies both halves — that a join really supplies that prefix, and that the joined entity really has that column.
Readiness — Every role can read something🚧 yesA role has no read grant anywhere; anyone with it opens the app and sees nothing. It refuses — but it will never grant on your behalf. Either give the role a read grant in Build ▸ Permissions, or press “Deploy anyway — nobody will hold “‹role›”” to declare that no seat will ever be given that role. Declaring it does not turn the check green: it stays amber, because the app really does ship staff who can see nothing.
Readiness — Room on your plan🚧 a NEW app at the capYour plan includes a number of live client apps. Said above Deploy to cloud whether you have room or not — “1 of 3 apps on your plan are in use — deploying this one makes 2” — and at the cap it refuses before anything is built, naming the way out. An app that already has its slot always redeploys, a staging preview never needs one, and nor does a Local Docker deploy.
Readiness — The runtime image can be pulled🚧 yesThe platform’s container image is private, and the registry credential is missing or wrong. Without this the deploy creates the project, the database and the domain, and then the container never starts — with no build logs to explain why. Infrastructure, not your engagement: it names the credential for whoever administers this server.
Production promotion approval🚧 yesSomeone eligible must approve: a seat in the firm that holds Approve a publish — the same seats the Approve button works for. If none does, the promotion is refused up front and says so, rather than waiting for an approval nobody can give.

⚠️ Publish validation refuses a chart set to show a series its data does not produce. Without that check a board reader opens the page and finds “This widget couldn’t be displayed. Please contact your administrator.” where its lead exhibit should be, with the real reason visible only in a browser console. It is refused before the deploy instead, with the reason where you read it: A “line_chart” on page ”…” is set to show “spend”, which its data does not produce… Its data produces “revenue”, “cost”. The check compares the names the BINDING produces (measures, formulas, combined measures) with the series the chart asks for. It does not compare them with the entity’s fields. A version that deployed under an older platform build can be refused by this check. That is intended: rename the series to one the binding produces, or add it to the binding.

⚠️ Grouping through a join is not dead configuration. joins fetches the referenced entity and namespaces its columns, so groupBy: "rep.fullName" draws the name rather than the foreign key, and the check reads it as the live binding it is.

⚙️ groupBy takes three shapes — a single field, an array of fields, or an array of ordered group specs carrying order and limit — and the check reads all three. That matters because one chart was refused and three identical ones sailed through: the others had been written as group specs to carry a cap, and a deleted field inside groupBy: [{ field: … }] is refused like any other — which is the failure this validator exists to prevent. ⚙️ A computed value is not dead configuration. The check asks one question in one place — what names does this binding produce? — so a widget whose value is computed rather than stored (a win-rate KPI, a margin bridge, a CAC chart, an aggregation alias like “Avg margin”) deploys, while a genuine typo is still refused.

⚙️ “Every role can read something” refuses, with a declared way through — it does not merely warn.

⚙️ “A widget bound to a deleted field” means every widget — table columns and KPIs as well as forms, and all three chart types. Readiness and publish validation read the same set, so neither reports ready on a page the other would refuse.

★ The advisory one is the trap. It does not stop the deploy, and the symptom appears days later as a client saying “it’s empty for my team” — which reads as a broken app rather than a missing grant.

Before you deploy: validation

Publishing runs semantic validation over the whole configuration. It refuses to ship things that would be broken-but-silent at runtime:

  • a nav entry pointing at a page that does not exist
  • a widget bound to an entity or field that was deleted — for a chart, what it is bound by: its grouping and each measure’s column (a line, bar, area or pie chart draws from its binding, so its “X field”/“Y field” boxes are not checked; a scatter’s are, because it plots rows by them)
  • a rule or watcher targeting a removed entity
  • a watcher with no rule that could ever notify anyone
  • two widgets that claim the same term while computing it differently — one name showing two numbers is a wrong answer already on someone’s screen, and publishing makes it a wrong answer on the client’s. The message names the term and every page that disagrees.
  • a widget that claims a term and computes a different number from the term’s own measure — another entity or measure, or one of the term’s filters dropped or changed. A widget that shows the term’s measure refined — narrowed to one region, grouped by day, sorted — is the same number used and publishes; it is not compared with the term shown elsewhere.
  • a date bucket reporting in a different timezone from the rest of the app, with no reason given — see below.
  • (reported, not refused) a theme reference that does not resolve, or no active theme at all — the deploy would fall back to a guessed look. Opening Appearance settles it by making the look on screen the app’s — the tab saves as you go, so there is no button to press, and an app kept on the default look gets a theme written when you arrive.

⚙️ Definitions are checked but never blocked for being incomplete. A term nothing shows is reported in the deploy summary and publishes anyway — a release stopped over a to-do is how a check ends up switched off. ⚠️ A widget whose number differs from the term it claims is refused, because it is a wrong number under a governed name. A refinement of the term is not.

“Our month” has to mean one thing

⚠️ Found on a real client’s dashboard. Invoices were bucketed by month in New York time and deals in London time. A deal closing at 2am UTC on the 1st was September on one chart and August on the other — five hours of every month boundary landing in different months on two tiles of the same screen. Both charts looked correct. There is no symptom; the only way to notice is to reconcile the two by hand.

⚙️ So an app declares one reporting timezone, and every date bucket says which zone it uses:

  • a bucket on the app’s zone is silent;
  • a bucket that departs from it must say why — a departure is often right (an invoice issued in New York and a deal closed in London are each local), but it should be a decision somebody made and can defend. An unexplained one stops the publish;
  • an explained departure publishes, and is still listed every time. A justified exception that stops being mentioned is how an app quietly ends up with five zones;
  • if no reporting timezone is declared, the product says so rather than assuming one. Nothing falls back to UTC. A silent default is precisely what hid the two Septembers, and a guess that is right most of the time is invisible exactly when it is wrong. This one is reported, not blocked — declaring it is a to-do, not an error.

Where you do it. Declare the app’s calendar under App Settings ▸ How this app reports (nothing is pre-selected). A chart counting in another calendar then shows Why this timezone under its own calendar in the query builder — “DACH’s months are Berlin’s” — and until it has a reason it says the page cannot publish. The reason is written on the chart and on any source combined on the same axis, and the deploy report lists it every time. Both the app’s calendar and a chart’s own calendar are set in the studio, on the screen the warning points at.

⚙️ The same place declares the app’s currency and locale. Choose the currency its money is stated in and the locale its dates read in — a UK contractor declares Pounds sterling and English, United Kingdom, and its pages then read £55,159.22 in a table, £700k on an axis and 17 Sept 2026 for a date. Both ship with the app. Left undeclared, money reads in US dollars and dates in each reader’s own format, as before. A new date bucket or “in the last…” filter starts in the declared calendar rather than asking for one; a chart can still choose another, and says why.

⚙️ The declared zone is also the clock a page reads dates on. A date and time on a page — a visit in a work queue, an incident in a feed — is shown in the app’s reporting zone, in the studio and in the client’s app alike, and when the reader’s own zone is different the zone is named after the time (“7:05 AM BST”). A date with no time is never moved. ⚠️ Before this, every time was shown on the reader’s own computer clock: a care visit at 07:05 in Devon read “2:05 AM” to someone in New York, and an activity feed filed incidents under the day before. The reporting timezone now ships with every deploy for this reason; an app with none declared shows the reader’s clock, as before.

This is the point of the whole publish gate: a misconfiguration should be loud at deploy time, not silently inert in production. If validation blocks you, it has found something real.

Configure the target

Build ▸ Deploy — Configure (/engagements/:id/build/deploy).

Set where this app runs, its environment variables, and its identity. The panel distinguishes what will deploy from what won’t, so you are not surprised by a piece of configuration that stays behind.

An app deploys to Cloud: TTP hosts and runs it for your client. That is the one target a practice is offered, so the panel shows no choice of target. The number of live apps is what your plan meters. The panel says how many of your plan’s apps are in use above the Deploy to cloud button, and says it at the cap as well as below it (see Room on your plan below).

⚙️ The server decides what is offered, and refuses anything else itself, so the panel never shows a route that cannot complete. Package and On-prem — a bundle a client runs on their own machine — arrive when registry access exists, and when they do they come as part of your plan rather than as a charge per app.

Room on your plan. Your plan includes a number of live client apps.

  • Below the cap, the line reads “1 of 3 apps on your plan are in use — deploying this one makes 2”.
  • At the cap, it reads ”… so there is no room for this one”, followed by the way out: tear down an app you have finished with (open its engagement, then Run ▸ Deployments — tearing down deletes the app’s hosting and database, so it is the admin role’s action), or move to a larger plan. The button then says why it cannot fire.
  • An app that already has its slot always redeploys, so a practice at its cap can still ship a fix.
  • A staging preview never needs a slot.

⚙️ At the cap, nothing is built. The refusal happens before any project, database or domain is created, so a deploy you cannot have costs neither of us anything.

⚙️ “Nothing to deploy yet” is one sentence, in one vocabulary. Deploy speaks of a version — not of a page marked published — and names the control that makes one.

Some of that identity is filled in for you. A deploy stamps the app with which engagement it serves and that engagement’s AI tier and monthly cap, because the app’s own database holds no engagement — engagements live here, in the studio. Without it the app cannot answer “which engagement am I?”, and the assistant your client’s staff would use refuses every request.

⚠️ The AI tier is fixed at deploy time. Raise an engagement’s tier afterwards and the change applies here immediately, but the running app keeps the tier it was deployed with until you deploy again.

What the readiness check tells you

Before a deploy, the readiness panel reports each precondition. Most are blocking — the engagement must be signed, the customer tenant provisioned, a deploy target configured.

⚙️ Build ▸ Deploy says them beside the button, before you press it. Every check that fails is listed under Deploy to cloud with its remedy — “Engagement signed — needed before this can deploy: … record the client-signed copy under Operate ▸ Contracts, then move the engagement to Signed under Operate ▸ Delivery ▸ Lifecycle” — and a blocking one stops the button saying why. It is the same checker the deploy runs, read before the button rather than after it — so a refusal arrives while you can still act on it.

⚠️ “Engagement signed” is a SET, not a threshold — an ABANDONED engagement is refused. archived sits after signed in the lifecycle, so an ordering test would read an abandoned engagement as further along than every live state. Readiness and the promote route both ask canDeployFrom instead — one question, one answer, on both paths — and the refusal reads “This engagement was abandoned (state ‘archived’) and cannot be deployed.” It offers no lifecycle step, because archived has no outgoing transitions: deploy from the engagement that replaced it, or create a new one for this client.

A paused AI assistant is advisory and never blocks. “Every role can read something” blocks until you either give the role a read grant or acknowledge that nobody will hold it; acknowledged, it stays amber, because the app really does ship a role that sees nothing. It never grants anything on your behalf.

Deploy and watch

Run ▸ Deployments (/engagements/:id/run/deployments) — deploy, and watch it happen with streaming logs. Run ▸ Topology (/engagements/:id/run/topology) shows the deployed shape, with an environment selector so you can look at production or staging.

⚙️ The status pill on the Topology canvas names where this environment actually runs — Local Docker, Railway, or both. When nothing is deployed yet it falls back to what the platform could deploy to (Railway configured / not configured), which is the useful question on Build ▸ Deploy. So the pill answers a different question depending on whether anything is running, and hovering it says which.

Run ▸ Releases (/engagements/:id/run/releases) is the history: which version went out when.

Pressing Deploy hands you a job, not a wait

⚙️ Deploy returns immediately. Building a client’s app — its records, its generated documents, its branding — takes minutes, and the button does not hold the connection open while that happens. Pressing it starts a deploy job and the screen follows it: “Deploying the customer app — this can take several minutes. You can leave this page; the deploy keeps running.”

⚠️ A refusal still answers at once, with its reason. Readiness, the blind-role gate, dead config, a missing registry credential and an unknown or undeployable version are all decided before any job exists, so a deploy that will not be allowed tells you why immediately — it does not start, run for four minutes and then fail.

⚠️ A failed deploy says so, in words. The screen shows the reason the server recorded — a request that exceeded the gateway’s limit and an expired sign-in are different sentences, not the same silent button — and when a failure arrives without a reason it says that too, rather than settling back as though nothing happened.

⚙️ Auto-deploy-on-publish works the same way. Publishing with auto-deploy enabled starts the deploy and returns without waiting on it. Watch Run ▸ Releases for the outcome rather than the publish response.

⚙️ Build ▸ Deploy follows the job too. It says it is deploying until the job ends — a deploy that takes six minutes reads as six minutes of work, not as a toast and silence — then “Deployed — ‹the app’s address›”, or the reason it failed — and, under it, anything the deployment says about itself: records it could not carry, or an app that had not answered yet when the deploy finished.

Getting into the app you just deployed

⚙️ The studio keeps nobody’s password. A password is shown once, when it is made, and after that the only way to hand someone a working sign-in is to reset it — the new password is shown once and cannot be shown again.

A deploy sets up the app’s first administrator. The password the first boot used is removed from the app’s environment as soon as the app is running, so nobody reads it back. To hand the app over, open Build ▸ Deploy ▸ Sign-ins ▸ Set a new password: the app sets a new one for its first administrator and it appears once, beside the workspace, the email and the app’s address. Save it then. Done takes it off the screen for good. (A first deploy to local Docker shows the password it made once, on the Deploy panel.)

The seats are listed underneath, one row each by name and role — the same people What’s shipping counts before a deploy (“8 people have a sign-in to this app”). You never type a client’s password: a seat added under Build ▸ Permissions ▸ Users gets a generated one, shown once when you create it. If it is lost, Reset password on the seat’s row sets a new one — in the app if the seat has reached it, otherwise in Build, where it ships with the next deploy. A reset signs the person out everywhere.

⚠️ Resetting a sign-in is an administrator’s action (the Reset a client’s sign-in capability). Build and billing seats see the names and a sentence saying so, and no button. Every reset is recorded in the audit log, naming who reset it; the password never is.

What “live” means, and what it does not

⚙️ A deployment is recorded live only once the app has actually answered — the platform polls GET /health on the deployed URL after the provider reports success, and waits for a 2xx. Until then the release is recorded as deploying, and the row carries the reason (for example: did not answer within 120s — last response HTTP 502).

⚠️ This distinction is not cosmetic. Railway’s own SUCCESS means “the image built and the process was started”; it does not mean the process stayed up, bound its port, or served anything. A provider can report success for a container that has already exited; waiting for the app itself to answer is what keeps that deploy from being shown as live over a URL that returns 502.

⚙️ So deploying on a finished deploy is informative, not a hang: the container may still be starting, or it may have died. Open its logs. A redeploy is the usual fix when the cause was a service variable that had not resolved yet.

⚙️ You may see a deploy report that another deployment superseded it. That is normal and is not a failure: changing a service’s image or one of its variables makes the provider start a deployment of its own, so a single action can produce two or three, and only the last one lands. The platform follows whichever deployment the service actually ends up running rather than the one it started, and names the supersession in the detail so the extra deployments in the provider’s list are not a mystery.

Environments and promotion

Configuration is promoted between environments rather than rebuilt for each. A promotion to production goes through an approval gate — someone eligible has to approve. Eligible means a seat in your firm that holds Approve a publish, and nobody else: the gate only ever waits on people who can actually approve. When the approval requires everyone, that is everyone who holds it. If no seat holds it, the promotion is refused with the reason, rather than left waiting for an approval that cannot come.

Rollback

Run ▸ Deployments offers rollback to a previous release. It runs a safety check first and tells you what it found before you commit.

Retiring an app

Run ▸ Deployments has a Tear down control on each app’s row, and on the app’s detail screen beside Redeploy and Rollback. It deletes the hosting project and the record of it in one action, and it asks you to type the app’s own name first — because the hosting project takes the app’s dedicated database with it. Every record anyone entered in that app is gone, no backup is taken first, and there is no undo. You can deploy the app again afterwards — the definitions live in your studio — but the records do not come back.

⚙️ The studio itself has no Tear down control, and asking for one is refused. That target is the platform you are reading this in.

⚠️ Use the button rather than deleting the hosting project by hand. Deleting it directly stops the cost but leaves the studio still listing the app as live at a URL that no longer answers. If that has already happened, pressing Tear down on the leftover row now finishes the job and clears it.

Data safety — the one thing to be careful about

Deploying configuration does not delete data. A config change (a page, a rule, a row-edit form) applies to a running app live; its records are untouched. Redeploying the app preserves its database.

The destructive operation is tearing the app’s volumes down — that re-bootstraps from a snapshot and loses records. Never reach for a volume teardown to force a config change. Config changes apply live; that is what the config-update path is for.

Here is what the platform guards, and where each guard applies — because they are not all the same reach:

⚙️ Backups are restore-tested, not assumed — everywhere. Twice a day, for every workspace and for a deployed app’s own tenant, the most recent completed backup is re-verified by checksum, restored into a throwaway scratch database, and smoke-checked. A backup nobody has ever restored is a hope, not a backup.

⚠️ The check streams the dump; it never holds it. The dump flows through decompression straight into the scratch restore, so a large backup cannot exhaust the studio’s memory, and the run records the dump’s uncompressed size.

⚙️ A local teardown refuses unless you type the project name. down -v deletes the data volume, so the caller has to echo the project name exactly; a mismatch throws before Docker is called at all.

⚠️ A pre-migration backup is taken on the LOCAL Docker deploy only. That path refuses to deploy at all unless a restorable backup verifies first. A cloud deploy does not take one, so on cloud the restore point is the one you take yourself — Run ▸ Deployments ▸ Back up now, before an upgrade that migrates.

Two different databases, and the one sentence that kept confusing them

⚠️ Run ▸ Backups backs up YOUR STUDIO. It does nothing for a client’s deployed app. They are separate databases with separate backups. A studio backup can run, verify, restore into a scratch database and match its checksum while the client app’s backup evidence stays exactly where it was — because nothing about the client’s database has happened.

⚙️ To take a restore point on the CLIENT’S app, use Run ▸ Deployments ▸ Back up now. That asks the app itself to back up its own database and prove the backup restores, and the app reports the result back. It is what the upgrade pre-flight is actually reading.

How the pre-flight knows what the app is running

⚙️ It asks the app, and the app answers twice. One answer is a setting on the service; the other is stamped into the image when it was built and nothing on the running service can change it. The stamp wins, and the screen tells you which one answered — because “this app runs version N” and “something on this app claims version N” are different sentences, and you are about to change a client’s runtime.

⚙️ You may see a note saying the declared version is out of date. That happens when the image has been changed without updating the label beside it: the app is running one version and its configuration still says another. The version you are shown is the correct one — it comes from the image itself — and the note is telling you the label needs catching up. It does not stop an upgrade, because upgrading is one of the things that fixes it.

⚠️ A note like that during a restart is normal and clears itself. While a new version is rolling out, the old copy of the app is still answering, so the two readings disagree for a minute. The product can tell that case apart from a genuinely stale label — and when it cannot tell (rare), it says so rather than guessing.

⚠️ If neither answers, the upgrade stops rather than guessing. An upgrade you cannot confirm afterwards is not one you can undo.

⚙️ You may see an upgrade refuse because the registry would not talk to us. That is a different refusal from “that version does not exist”: it means our credential was rejected, so we learned nothing about the image. It matters because the same credential is what publishes a version — so a refusal here is also a warning that a recent publish may have failed quietly. The message names the credential to renew.

The backup window is a data-loss budget

⚙️ An upgrade can cost you at most a day of data. That is the whole promise, and it is the only sentence in this section a client needs. An upgrade will not proceed without a verified restore point less than 24 hours old, so if a migration goes wrong the worst case is losing whatever changed since that point. The app restore-tests itself every 12 hours to keep a fresh one on hand.

⚠️ Read the window as a budget, not a threshold. It is not “how strict is our machinery”; it is how much work is a client willing to redo. Twenty-four hours is the default because it is an honest answer for most engagements and because the alternative is expensive: every halving of the window doubles how often a production-sized database is restored into a scratch database, forever, on the client’s own infrastructure.

⚙️ A client with a tighter tolerance sets a shorter one. Set TTP_BACKUP_WINDOW_MS on their app in Run ▸ Deployments ▸ Environment, in milliseconds, and redeploy. Their upgrade gate and their app’s verify schedule both move — you set one number, never two.

TTP_BACKUP_WINDOW_MSUpgrade needs a restore point newer thanThe app restore-tests every
unset24h (the default)12h
216000006h3h
144000004h2h
72000002h (the minimum)1h

⚠️ You cannot set a longer window than the default, and the platform refuses rather than accepting it. A shorter window costs the client more restore probes, which is a price they can choose to pay. A longer one spends data that is not ours to spend — and widening the window is exactly the move that would make a refused upgrade go away without making anything safer. Below two hours is refused too, and for arithmetic rather than taste: the probe and its report need about an hour, so a one-hour window would be stale the moment it landed — a permanently blocked upgrade dressed up as a strict setting. Both refusals name the number you set and the one that would work.

⚙️ The refusal can always be cleared. Back up now refreshes the evidence on demand rather than leaving you waiting for a timer you cannot reach, and the schedule above now keeps the evidence inside the window without it. If you are reading an older screenshot or an older copy of this page telling you to use Run ▸ Backups for this, it was wrong.

Starter templates reach new apps only

A page built from a starter template is copied into the app when the app is created, not referenced from the template afterwards. So improving a starter template — a better layout, a control moved somewhere more sensible — changes what the next app gets. It does not reach apps that already exist, and redeploying an existing app will not pull it in.

This is deliberate. A consultant’s edits outrank our template. Once you have moved a widget, renamed a column or reworked a page for a client, that page is yours; a platform update that quietly rearranged it would destroy real work and would do so invisibly, on a deploy the consultant asked for for some unrelated reason.

The practical consequences:

  • Do not expect a template improvement to show up in a live client app. If you want it there, make the same change on that app’s page yourself, in the page canvas.
  • Do expect renderer and styling changes to reach existing apps — on upgrade. How a widget is drawn — its chrome, its colours, its states — ships with the app image, so it reaches a live app when you upgrade that app (Deploy ▸ Upgrade) to a platform version that carries it. A deploy on its own ships configuration, not the image. Only the page’s stored content (which widgets, where, bound to what) is frozen at creation.
  • If you are evaluating a template change, create a fresh app from it. Redeploying an old one tells you nothing about the template.

Running the app

SurfaceAnswers
App Data (/engagements/:id/run/app-data)what is actually in the app
Attention (/engagements/:id/run/attention)what the app flagged for a human
App Health (/engagements/:id/run/deployments/health)is it up and responsive
Incidents (/engagements/:id/run/incidents)what has gone wrong
Logs (/engagements/:id/run/logs)the detail behind an incident
Support (/engagements/:id/run/support)what the client raised
Workflow Runs (/engagements/:id/run/workflow-runs)what ran, what it did, where it is stuck
Anomaly Events (/engagements/:id/run/anomaly-events)what the watchers noticed
Lineage (/engagements/:id/run/lineage)where a value came from
Audit (/engagements/:id/run/audit)who changed what, in this engagement

Audit is engagement-scoped: it reads the engagement’s own tenant chain, so the numbers differ between engagements. If two engagements show identical audit counts, something is wrong.

Monitoring, backups, usage

At Firm scope:

  • Platform Health (/observability/health) and System Health (/system-health)
  • Backups (/backups) — with off-host sweeps and retention
  • Billing & Costs (/billing) — per-workspace spend, with a monthly budget you can set by clicks and breach bars once you have

Per-app usage (records, storage, AI) is visible inside the delivered app under its System ▸ Usage page.

⚠️ Nothing here pages you when an app stops answering. These screens read what the studio and the apps report, and an app that is crash-looping reports nothing — while the hosting provider can still show its deployment as successful. There is no outside uptime watcher, so the way to know an app is up is to open it.


What “done” looks like for deploy

  • Validation passes — and you read what it said rather than clicking through.
  • The deployed version is a published version you can name.
  • The client’s staff can sign in, and the login is on-brand.
  • Health is green, and you have looked at it since deploying.
  • A backup exists and has been restore-tested.
  • You know how to roll back before you need to.