From local to live.
Build the release artifact, set the variables a compiled application needs, and run its process roles under the contract any deploy tool can rely on.
| Contract | Value |
|---|---|
| Release | 0.15.0 |
| Needs | A committed application tree, a container engine such as Docker Desktop for the build, and a Linux host with PostgreSQL for the run |
| You will have | A linux/amd64 artifact that holds bin/SHARD and public/, migrated, serving on a Unix socket and answering GET /health |
| Status | The build, the role script, migrate, serve --no-workers, GET /health and SIGTERM run in scripts/check frappe-project when a container engine runs; jobs unknown and the proxy are not run by a check |
| Proved by | frappe build · APP migrate · curl --unix-socket |
A compiled application promises any deploy tool the behaviour below, as ADR 0032 ↗ records. SHARD is the application’s name in shard.yml, such as bookshelf; APP below stands for the unpacked bin/SHARD, such as /srv/bookshelf/bin/bookshelf.
The guide in four stages
11 sections · read in order the first time
Stage 1 · Build
Build the artifact
frappe buildExpect: Prints the path of .caramel/build/SHARD-YYYYmmddTHHMMSSZ-SHA7-linux-amd64.tar.gz as its only line of output. The archive holds bin/SHARD and public/.
frappe build publishes app/assets into public/, then compiles the committed tree with crystal build --release --static in the pinned crystallang/crystal:1.21.1-alpine image for linux/amd64, keeping debug info. Alpine’s gmp-static is installed first, so an application that uses BigDecimal links GMP into the binary. It needs a container engine, such as Docker Desktop, for this build only.
| Condition | Outcome |
|---|---|
frappe build with uncommitted changes in the working tree | refuses to build |
frappe build when publishing app/assets into public/ leaves changes | refuses to build |
Serve public files
serve reads public/ from CARAMEL_ROOT when it is set, so the binary runs from wherever the artifact is unpacked.
| Condition | Outcome |
|---|---|
CARAMEL_ROOT that is not an absolute path | exits 1 with CARAMEL_ROOT must be an absolute path |
CARAMEL_ROOT with no public/ inside it | exits 1 with CARAMEL_ROOT has no public directory |
Stage 2 · Run
Set the environment
With CARAMEL_ENV unset the binary runs as production. These variables are read when a process starts:
| Variable | Meaning |
|---|---|
DATABASE_URL | The runtime role’s connection, for serve, work, seed and jobs; a missing one exits 1 with Missing database configuration: DATABASE_URL |
MIGRATION_DATABASE_URL | The migration role’s connection, for migrate |
APP_ORIGIN | The public origin, such as https://bookshelf.example; serve exits 1 with APP_ORIGIN is required without it |
APP_SECRET | The secret that signs sessions and tokens; serve exits 1 with APP_SECRET is required without it |
CARAMEL_SOCKET | The Unix socket path serve listens on, in a directory only its owner can enter; serve exits 1 with CARAMEL_SOCKET is required; use frappe dev without it, and with CARAMEL_SOCKET must be in a private owned directory when the directory is open |
CARAMEL_SOCKET_GROUP | A group that may enter the socket’s directory instead, for a proxy that runs as another user; the socket is then mode 0660 |
CARAMEL_ROOT | The absolute path of the unpacked artifact, holding public/ |
The secret’s minimum length is on the security reference, and the database timeouts and Crema’s ops socket on the Crema reference.
Read the security limits →Read the Crema variables →Run the process roles
A compiled application can serve HTTP or run Cold Brew with work, including queue selection, concurrency, and graceful completion of in-flight jobs. Run each web process as serve --no-workers, which keeps the PubSub broker and maintenance but runs no jobs or schedules, and run one work process.
APP serve --no-workersExpect: Prints TITLE is ready at APP_ORIGIN once the socket is bound, and serves until SIGTERM.
Read the serve and work flags →
Check readiness
Each process binds a socket path of its own, and the proxy moves to it once it is ready. A generated application routes GET /health to an action that touches no database; an application that removes the route has no readiness check.
curl --unix-socket "$CARAMEL_SOCKET" -H "Host: bookshelf.example" http://localhost/healthExpect: Prints ok. The Host is the authority of APP_ORIGIN; another host answers 421.
Shut down gracefully
| Condition | Outcome |
|---|---|
| SIGTERM | stops serve accepting connections; it answers in-flight requests with Connection: close and exits when they finish or after 25 s |
| An open server-sent events stream | counts as in flight, so it holds serve for the whole 25 s |
A supervisor that stops serve | must allow at least 30 s before it kills the process |
Stage 3 · Cut over
Check for unknown jobs
Before cutover, check the new binary against the queue.
APP jobs unknownExpect: Prints nothing and exits 0 when every queued class is known; otherwise it prints each class the binary lacks, one per line, and exits 1.
Set up the database
APP db roles DATABASE MIGRATION_ROLE RUNTIME_ROLEExpect: Prints, without passwords, the psql script that creates a migration role that owns the database and a runtime role with DML through default privileges.
Connect with sslmode=verify-full, plus sslrootcert for a private CA, or through a Unix socket, directly or through a session-mode pooler, never a transaction-mode one. The application needs no superuser and no extension. Each connection sets a statement and a lock timeout when it opens. Read the timeout variables →
Cut over in order
- Run the new binary’s
migratewhile the old one serves. - Stop the old workers.
- Move traffic to the new web process.
APP migrateExpect: Prints Applied N migrations. with MIGRATION_DATABASE_URL set, then a warning for each queued job class this binary lacks.
| Condition | Outcome |
|---|---|
serve, work or seed with a migration not yet applied | exits 1 with Pending migrations. Run frappe migrate first. |
Going back across a migration means restoring the database.
Stage 4 · Limits
What does not compile
Nothing on this page is checked at compile time: the artifact is the application as frappe check passed it. Each refusal above happens when the command runs, before it binds a socket or writes a row.
Not covered yet
- Caramel ships no command that provisions servers, installs a proxy or deploys.
- Artifacts are
linux/amd64only; arm64 artifacts are deferred. - Assets are published as files under
public/; embedded and fingerprinted assets are deferred.
Prove it
Check the artifact before cutover
On the build host:
frappe buildOn the server, with the artifact unpacked at /srv/bookshelf and the variables above exported:
/srv/bookshelf/bin/bookshelf migrate
/srv/bookshelf/bin/bookshelf jobs unknown
/srv/bookshelf/bin/bookshelf serve --no-workers &
curl --unix-socket "$CARAMEL_SOCKET" -H "Host: bookshelf.example" http://localhost/healthPassing: frappe build prints one artifact path. migrate prints Applied N migrations., jobs unknown prints nothing and exits 0, serve prints Bookshelf is ready at https://bookshelf.example, and curl prints ok. scripts/check frappe-project runs the build, the role script, migrate, serve --no-workers, GET /health and SIGTERM in a container when an engine is running, and prints SKIP: frappe build needs a running container engine otherwise.