Building apps

Deploying

What a deploy does, secrets, the version marker, CI, app access and editors, and reading the backend function's own logs.

railcode deploy [--private] [--no-source] [--force]

Run it from the app directory. It reads railcode.json, runs the CLI-owned build, uploads the static files and the backend function as one unit, ratifies the manifest, and prints the live URL: https://<app>.<org>.<serving-domain>/.

The two halves are one deploy. The static files and the backend module activate together and revert together. Each deploy's backend function is uploaded as its own immutable script, and the runtime ABI is recorded on the deploy row. So a revert reproduces the original executable rather than rebuilding it.

Where it lands

  • Which server: resolved from --api-url, then RAILCODE_API_URL, then the saved CLI config, then https://api.railcode.app.
  • Auth: the saved personal API token, or RAILCODE_API_TOKEN for non-interactive deploys. On a 401 the token is cleared and you are asked to log in again.
  • The app is created or resolved by slug in your saved org. The first successful deploy creates the app. A failed first deploy leaves nothing behind.
  • Project source is uploaded with the built files so railcode pull can bring it back. It honors your .gitignore plus a built-in exclude list. Env files never ship: .env, .env.local, and similar files are excluded even when .gitignore misses them, while .env.example still ships. .claude/ ships on purpose, so whoever pulls the source inherits the app's instructions.

Secrets

Secrets are live app state, not part of a deploy. Every activation (deploy, revert, cold revert) re-applies the current set before the switch is visible. So a revert can never bring back a rotated value.

railcode secrets set NAME        # hidden prompt, or piped on stdin. Never inline
railcode secrets import .env     # every NAME=value line of an env file
railcode secrets ls              # names + set-at + digest, never values
railcode secrets rm NAME

Values are write-only. They can be replaced or removed, never read back. The backend function reads them as secrets.NAME.

Caps: 64 per app, 5 KB per value.

The version marker

Every successful deploy and every railcode pull writes a small .railcode file. It records which deploy the folder matches:

{ "api_url": "…", "org_uuid": "…", "app": "demo", "app_uuid": "…", "deploy": 3 }

The next deploy sends that number back. The server refuses to publish over work the caller has not seen. Think of it as an If-Match for deploys.

  • Deploy numbers count from 1 per app.
  • A stale base is a 409 naming the live version, who moved it, and when. Run railcode pull, then deploy again.
  • A base the app does not have is a 422 naming the app's real range. This is usually a copied folder. railcode pull resyncs it.
  • Never commit .railcode. It is per-folder sync state. A stale one handed to a colleague makes their next deploy claim a base that is not theirs. railcode init adds it to .gitignore.

Pulling source back

railcode pull [<deploy>] [--app <slug>] [--dir <path>] [--force]

Downloads the source tree stored with a deploy: the live one by default, or a deploy number you name. Existing local files are only overwritten with --force, and the command lists which ones differ before it refuses. Nothing is ever deleted.

Working in a shared app

An app can have several people deploying it, so do not assume it is yours alone.

railcode apps show <app>      # prints `your role`, `can manage`, `can edit`
  • Pull before you deploy. A 409 means a colleague published after your last sync.
  • --force overwrites a teammate's deploy. It marks the history row as knowingly deploying over a conflict. Pull, look at what changed, and ask before forcing.
  • Do not merge blind. railcode pull lists differing files rather than overwriting them. That list is where your tree and the live one differ. Read it.
  • Never deploy from a tree missing manifest.yaml. A manifest absent from the uploaded tree reads as removed. Removal only removes authority, so it auto-applies with no permission required. The app silently drops to pass-through and every declared grant is removed. The only signal is one deploy line. This is the most likely way to break a shared app.

Manifest ratification

On deploy, the platform ratifies the manifest against the deployer's own grants. The unit is the diff against what is already ratified, not the whole document:

The diffOutcome
Unchanged (content hash matches)Deploys as ordinary code, whatever you hold
Adds operations you hold, and/or only removes operationsAuto-ratifies on the spot
Adds an operation you don't holdRejected up front with a 403. Nothing is published
railcode manifest validate        # strict local parse, before deploying
railcode manifest show <app>      # the ratified doc, who ratified it, its content hash

railcode deploy also needs the org-level app:deploy capability in your role. An editor grant alone is not enough.

App access

A new app defaults to organization access.

ModeWho can open it
organizationEvery org member (the default)
privateOwners and editors only
restrictedOwners and editors, plus explicitly granted members

Org admins and owners bypass per-app access. A user who lacks access sees a 404, not a 403.

railcode apps access <app>                          # current mode + per-user grants
railcode apps set-access <app> --mode private
railcode apps set-access <app> --mode restricted --members [email protected],[email protected]
railcode apps add-editor <app> [email protected]            # atomic; never rewrites the rest
railcode apps add-viewer <app> [email protected]             # restricted mode only

railcode deploy --private is a one-shot action. It sets access to private for that deploy only. It is never persisted, and it does not work from CI (setting access is a separate call that a deploy token is refused on).

Three tiers

TierCanCannot
ownerEverything below, plus delete / archive / transfer / set-access, and app kv / app filesNothing
editorDeploy, read and revert deploy history, pull source, read analytics, read the access policy, add/remove viewersDelete, archive, transfer, change the mode or editor list, app kv / app files
member (viewer)Open the app while it is restrictedAnything else

Editor is a working right rather than an audience setting. An editor can open the app in every mode, and the grant survives mode changes.

Prefer the atomic add-editor / remove-editor over set-access --editors when you grant one person. set-access rewrites the whole policy and can race a concurrent edit. Omitting --editors leaves the list alone; --editors "" clears it.

Transferring ownership demotes the previous owner to editor rather than cutting them off.

Deploying from CI

Do not hand a pipeline your personal token. It carries every power you hold, on every route, and never expires. Use a deploy token: an app-scoped credential that can POST that one app's deploy route and nothing else.

railcode ci github [--app <slug>] [--repo <owner/name>] [--branch <name>]

Run it inside the project. It resolves the app and repository, mints a deploy token, and hands the plaintext to GitHub as the repository secret RAILCODE_API_TOKEN through the gh CLI over stdin. The token never reaches your screen, your shell history, or an argv that another process can read. Then it writes .github/workflows/railcode-deploy.yml.

The generated workflow carries all three values a runner needs, because a runner has no ~/.railcode/config.json:

- run: npx --yes railcode@latest deploy
  env:
    RAILCODE_API_URL: https://api.railcode.app
    RAILCODE_ORG_UUID: <org uuid>          # an identifier, not a secret
    RAILCODE_API_TOKEN: ${{ secrets.RAILCODE_API_TOKEN }}

Without RAILCODE_ORG_UUID the command stops at "No organization on file" despite a valid token.

Manage tokens directly:

railcode token create [--app <slug>] [--name <label>] [--expires-in-days <n>]
railcode token list   [--app <slug>]
railcode token revoke [--app <slug>] <token-prefix>

The plaintext is shown once, at mint time. Every owner, editor, and admin of the app can see and revoke every token on it. Tokens are long-lived by default, because an expiring CI credential breaks a pipeline with no warning. --expires-in-days is opt-in.

What a stolen deploy token can do: replace the served code of that one app. It cannot raise that app's authority. A deployed manifest that adds power is still blocked.

After the deploy

Open the printed URL and check:

  • Unauthenticated visitors go through the platform login, not a custom app login.
  • The frontend loads with no console errors and no failed /api/* calls.
  • A backend route returns the expected ctx.user.
  • SQL, LLM, connector, and agent features show configured, empty, or disabled states cleanly, never a raw error or a hang.

Then read the backend function's own record of what happened:

railcode logs app --app <slug>                   # every invocation: path, status, duration, who
railcode logs app --app <slug> --follow          # tail
railcode logs app <invocation_id> --app <slug>   # ONE full trace, as JSON

Every invocation, http or cron, produces a record. A single trace also carries the backend function's console lines, any uncaught error, and every governed operation with its verdict. So a refusal appears as denied with the resource name instead of a silent failure.

invocation_id joins the gate record, the logs, and every data-plane audit row, so one request reads as one trace. Retention is about 14 days.

On this page