Skip to content

Deploy

This page ships the stack you ran locally to your own Cloudflare account. It is for anyone who has built the first app, and it is the one place the Ramose deploy resources are listed.

Alchemy is the TypeScript deploy tool Ramose uses: one file declares your Workers and storage; bun alchemy dev runs it on your laptop, bun alchemy deploy ships it to Cloudflare. (glossary)

A stage is one isolated copy of the whole stack (glossary): bun alchemy deploy ships your personal stage, --stage prod ships production.

The todos app’s stack is two files. The deploy file names the three things the Ramose server needs on Cloudflare — a storage bucket where every version is kept, and two Durable Objects (Cloudflare’s single-instance stateful Workers): the writer and the read copy. You name them; you never write them. That file is resources.ts; alchemy.run.ts installs the schema and declares the stack. The Ramose server is the one Cloudflare Worker that serves all your databases; Ramose’s code calls it the peer (glossary). Why these three things →

Copy these two files as-is
examples/todos/resources.ts:1-14
import * as Ramose from "ramose";
import * as Cloudflare from "alchemy/Cloudflare";
const Store = Cloudflare.R2.Bucket("Store");
const Transactor = Cloudflare.DurableObject("TransactorDO", { className: "TransactorDO" });
const Replica = Cloudflare.DurableObject("QueryReplicaDO", { className: "QueryReplicaDO" });
export const RamoseWorker = Cloudflare.Worker("Peer", {
main: import.meta.resolve("ramose/worker"),
compatibility: { date: "2025-06-01", flags: ["nodejs_compat"] },
env: { STORE: Store, TRANSACTOR: Transactor, REPLICA: Replica },
});
export const Server = Ramose.Server("Ramose", { worker: RamoseWorker });

The excerpt above is from examples/todos, and it is what your own app writes too — main is a path, not a module specifier, so import.meta.resolve is what turns the ramose/worker subpath into one. A bare main: "ramose/worker" resolves to nothing and produces a server that reports ready and then never answers. Getting started sets it up.

examples/todos/alchemy.run.ts:20-77
import * as Ramose from "ramose";
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Command from "alchemy/Command";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { Server } from "./resources.ts";
import { Todos } from "./schema.ts";
// …
export const TodosDb = Ramose.Database("todos", { server: Server, catalog: Todos });
// …
export default Alchemy.Stack(
// …
"ripple-todos",
{
providers: Layer.mergeAll(
Cloudflare.providers(),
Ramose.providers(),
Command.providers(),
),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const server = yield* Server;
yield* TodosDb;
const ui = yield* Ui;
return { peerUrl: server.url, uiUrl: ui.url };
}),
);

The elided Ui resource starts Vite under alchemy dev only. state: Alchemy.localState() keeps deploy state on disk; the repo’s root stack switches to Cloudflare.state() when ALCHEMY_STATE is not local.

Ramose.Server resolves the Worker’s URL and, on a real deploy, waits until GET /health answers. Ramose.Database runs db.install() on that server, so a redeploy costs one empty write. Reef has no Ramose.Database at all: its databases are created by users (One database per customer).

commandwhat it does
bun alchemy dev <stack>runs the stack on your laptop in the local Cloudflare emulator (bucket and both Durable Objects included)
bun alchemy deploy <stack>deploys your personal stage
bun alchemy deploy <stack> --stage proddeploys production
bun alchemy destroy <stack>tears a stage down — see Tearing down

The stack argument is the file, for example examples/todos/alchemy.run.ts.

namepurpose
CLOUDFLARE_API_TOKENWorkers Scripts Write (covers Durable Objects), Workers R2 Storage Write, Account Settings Read
CLOUDFLARE_ACCOUNT_IDthe account to deploy into

Local dev needs neither for real: CI=1 ALCHEMY_STATE=local, any 32-hex placeholder account id and CLOUDFLARE_API_TOKEN=x keep everything on your machine.

Reef’s UI is served by its auth Worker, so it deploys in two passes with a build between them:

Terminal window
bun alchemy deploy examples/reef/alchemy.run.ts # first pass: the Workers, the R2 bucket, and Better Auth's D1 database
VITE_RAMOSE_URL=<peerUrl> bunx vite build examples/reef # bake the peer URL into the SPA
bun alchemy deploy examples/reef/alchemy.run.ts # second pass: ship the assets

The API token also needs Account / D1 / Edit for Better Auth’s database.

Spread Ramose.authEnv(auth) into the server Worker’s env and pass the same auth to Ramose.Server:

alchemy.run.ts
const auth: Ramose.PeerAuth = {
policy: process.env.RAMOSE_POLICY, // Ramose.Policy.compile(policy, { pulls })
jwksUrl: process.env.RAMOSE_JWKS_URL,
issuers: process.env.RAMOSE_JWT_ISS,
aud: process.env.RAMOSE_JWT_AUD,
allowedOrigins: process.env.RAMOSE_ALLOWED_ORIGINS,
};
const Worker = Cloudflare.Worker("Peer", {
// …
env: { /* … */ ...Ramose.authEnv(auth) },
});
export const Server = Ramose.Server("Ramose", { worker: Worker, auth });

auth on Ramose.Server is a deploy-time check only: a policy with no jwksUrl, issuers, or aud fails the deploy instead of denying every request at run time. Reef’s real wiring is on Sign in and roles.

ramose re-exports all of ramose/db and adds these. Import it as * as Ramose.

namewhat it is
Ramose.Server(id, props)the deployed server. Props: worker (a Cloudflare.Worker, { url, workerName? }, or a bare URL string), url? (custom domain), token? (the server’s RAMOSE_TOKEN), auth? (deploy-time check), probe? (GET /health retries: 30 attempts, 2 s apart; false skips). Outputs { url, workerName, token }. Delete is a no-op.
Ramose.Database(id, props)“install this schema on that name”, after the server. Props: server, catalog, name? (defaults to id). Outputs { name, server, t }. Delete is a no-op.
Ramose.ReadWriteDatabases(server)a capability — what your Worker may do (glossary): read and write every database on that server
Ramose.ReadDatabases(server)the same with writes removed: no transact, install, or principal
Ramose.ServerBindinghow calls travel — a service binding, Worker to Worker, no URL (glossary). A Layer<ReadWriteDatabases | ReadDatabases, never, WorkerEnvironment>; needs worker to be a Cloudflare.Worker. No live queries.
Ramose.ServerHttpthe same over the server’s public URL. A Layer<ReadWriteDatabases | ReadDatabases>.
Ramose.providers()merge into your stack’s providers next to Cloudflare.providers()
Ramose.authEnv(auth)turns a PeerAuth (policy, jwksUrl, issuers, aud, maxTtl, auth, allowedOrigins, internalSecret) into the server Worker’s environment variables

Using a capability inside a Worker is on Use it from a Worker.