Building a Layerbase App
A Layerbase app is one stateless container plus a managed database. You ship an image; we run it, wire it to a database we provision, give it HTTPS, and self-heal it. One declarative app definition describes everything else, and the dashboard renders itself from that definition: no dashboard code, no admin UI, no settings screens for you to build. Two first-party apps run in production exactly this way, and this page uses both as worked examples: Session Replay (Postgres-backed, with a login) and secret-store (libSQL-backed, headless, a pure API with no UI at all).
The whole contract, in five rules
| Your app must | Why |
|---|---|
Listen on PORT, bound to 0.0.0.0 (not 127.0.0.1) | Our proxy terminates TLS and routes to your container; a loopback bind is unreachable |
Persist ONLY to DATABASE_URL, and migrate on boot idempotently, without crash-looping | The container is disposable, and the store may start empty or turn read-only; we never migrate it for you |
Answer GET /health (readiness) and GET /healthz (liveness) | We probe readiness, Docker probes liveness; splitting them keeps a database blip from killing a container that is serving fine |
Exit cleanly on SIGTERM | Redeploys drain in-flight requests instead of dropping them |
| Ship as a prebuilt image with an OCI version label | Nothing builds on boot; we detect updates from the label |
That is the entire platform-facing surface. Everything else - TLS, domains, DNS, the database itself, backups, restarts, and the whole dashboard - is our job, not yours.
Start from the starter
The fastest way to see the contract is to run it:
Layerbase-LLC/layerbase-app-starter
is the same minimal todo app implemented twice - once as Node + Vite (a Hono
server serving a React SPA) and once as Next.js (App Router, standalone
output). Each folder is a few hundred lines, and each README maps every contract
rule to the exact file that satisfies it. secret-store was scaffolded from the
node-vite folder and shipped the same day.
- Copy the folder that matches your stack.
- Run it locally:
spindb create todos --engine postgresql, put the connection string in.env,pnpm install && pnpm dev. - Replace the todo parts with your real app. The platform-facing plumbing - port binding, boot migration, health, shutdown, Dockerfile, image publishing - is already done.
What the platform provides
- A dedicated container for your image, with a hard memory cap.
- A provisioned database, injected as a connection string. Any
connection-string engine we host works (Postgres, MySQL, MariaDB, MongoDB,
Redis, ClickHouse, and more). URL-only engines (Qdrant, Meilisearch,
InfluxDB, Weaviate, libSQL) get a second env var carrying the provisioned key
(secret-store reads its libSQL token from
DATABASE_AUTH_TOKEN). The one exception is TigerBeetle: a file-based ledger with no connection string. - An HTTPS subdomain (
your-app.cloud.layerbase.dev) with managed TLS, and custom domains from the Domain tab: we show the DNS record, verify it, issue the certificate, and switch the app (and anything derived from its URL, like install snippets) to your domain. - Self-healing: a reconciler probes
GET /health(readiness) and recreates a wedged container automatically. Liveness is your image's own separateGET /healthzprobe (see the next section). - A dashboard with no dashboard code. Overview, Domain, and Settings are present for every app. Everything else lights up ONLY when your definition declares it: an Install tab (embed snippets), a Docs tab, an About tab, an Auth tab (a full user-admin console over your backing store), a masked primary secret on Overview, editable settings, and an external-settings deep link. The section "The dashboard surface, declared" below maps each one to its field.
Boot, health, and a database that misbehaves
Your container has to come up cleanly against a store it does not control, and stay up when that store has a bad moment. Three rules cover it, all wired correctly in the starter and in secret-store.
Bind to 0.0.0.0, never 127.0.0.1. Read the port from the injected PORT
and the host from HOST, defaulting HOST to 0.0.0.0. A loopback bind is
invisible to the proxy, so a container that is running fine looks dead. The
starter used to default HOST to 127.0.0.1; that was a real footgun the
platform had to override, so default it to 0.0.0.0 yourself.
Two health endpoints, not one.
GET /healthzis liveness: no auth, no database, an instant 200, reading onlyprocess.env.PORT. Your DockerfileHEALTHCHECKprobes this, and it is whatdocker inspectreports (we inject no healthcheck of our own).GET /healthis readiness: it pings the database and reflects migration state, returning non-200 when the store is unreachable or the schema has not converged. Our reconciler probes this and revives the backing store when it stops answering.
Keep them separate on purpose. If liveness touched the database, a brief database
hiccup would flip a container that is serving perfectly into "unhealthy" and get
it killed. Liveness answers "is this process up?"; readiness answers "can it
serve right now?". secret-store's Docker HEALTHCHECK runs, with no curl in the
slim image:
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD \
node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"A failed boot migration must not crash-loop. We hand your app an empty store
and never run migrations for it, so you migrate yourself on boot, idempotently
(CREATE TABLE IF NOT EXISTS plus additive ALTERs) so boot converges from any
prior state. But the store can also turn read-only (we write-lock it near 100%
storage), and a write against a read-only store fails. Do not let that kill the
process: catch it, keep /healthz at 200, report the failure on /health, and
retry the migration on an interval. secret-store learned a useful wrinkle here:
against a store a prior boot already migrated, the CREATE TABLE IF NOT EXISTS
statements no-op successfully even when the store is read-only, so reads keep
serving while writes are paused.
Picking a backing store
Postgres is not a default you must reach for. The backing store is whatever
connection-string engine fits your data. Session Replay keeps a lot of recording
data and uses Postgres; secret-store holds a few hundred KB of ciphertext, so a
dedicated always-on postmaster would be pure waste and it uses libSQL instead
(SQLite durability behind a connection string, and file:local.db locally with
no server at all). Pick the smallest engine that fits.
One option that does not exist: a container-local SQLite (or any) file. App storage is not persistent; the container is replaced on every redeploy and its disk goes with it. All durable state lives in the injected store, never on the container.
The database is not on the internet
- No public surface. The database we provision has no public hostname, no DNS record, and no open port. It is reachable only by your app's container, over an isolated per-user network.
- Nothing outside can reach it. It cannot be scanned, brute-forced, or connected to from anywhere else. The only paths to the data are through your app and the Layerbase dashboard's own consoles.
- No credentials or firewall to manage.
DATABASE_URLis injected at provision time and works only from inside the platform. You never rotate its credentials or write firewall rules; that is our job.
Keep the env contract small
Environment is a first-boot seed at most, never live config. Anything the owner can change at runtime belongs in the database as the source of truth, not in an env var you re-read. secret-store bakes in the lessons from tightening Session Replay's env contract:
- One canonical base-URL variable, if you need one at all. Session Replay
collapsed several URL aliases into a single
PUBLIC_BASE_URL; secret-store, being headless, needs no public-origin variable and ships none. - No JSON-blob variables in your own contract. Env vars are flat strings; a
config object belongs in the database. (Session Replay still ships one legacy
JSON blob,
SITE_KEYS, that the platform assembles for it; new apps should not add one.) - Deliberately few variables. secret-store ships six:
DATABASE_URLandDATABASE_AUTH_TOKEN(both platform-injected: libSQL is URL-only, so the endpoint and the token arrive as two vars),ADMIN_TOKEN(a platform-minted secret), andPORT,HOST,NODE_ENV(plain first-boot literals). Not one is something you compute.
Headless apps and platform-minted secrets
Your app can declare a generated secret the platform mints once, persists on
the app row (so redeploys never change it), injects as an env var, and shows
masked and copyable in the dashboard. Session Replay surfaces its recorder write
key this way; secret-store uses one as ADMIN_TOKEN, the single bearer
credential its admin API checks.
That one masked field is enough to build a headless app: no UI, no login
system, no session or cookie surface at all. secret-store is pure API. Its whole
admin surface is the dashboard's masked ADMIN_TOKEN, an Install tab of
copy-paste curl / Node / Python snippets (with this instance's URL already
filled in), and a Docs tab, all rendered from its app definition; the owner
drives everything with those. If your app needs no interactive screens, skip the
entire front-end and auth stack and let the platform-minted secret be the
credential.
The free Auth tab
If instead your app manages logins with Better Auth tables in its backing store, the dashboard recognizes the schema and renders a full Auth tab - list users, create, reset passwords, disable, delete - with zero UI work from you. Opting in is two lines of the definition:
backingStore: { engine: 'postgresql', connectionStringEnv: 'DATABASE_URL' },
authConsole: { enabled: true, store: 'backingStore' },The tab appears only when the app opts in AND its backing store is an
auth-console engine (libSQL, Postgres, MySQL, MariaDB) AND a recognized schema is
detected. Headless apps set authConsole: null and skip it entirely.
Rules of the box
- One process, one image. No
docker-compose, no sidecars. This is a security boundary: every listed app is audited, and a single process is what keeps that review tractable. If you need a database, you get one of ours. - Memory is a hard cap. Exceed it and the container is stopped.
- Storage is a quota. Cross it and the backing database flips read-only (reads keep serving, writes pause) until usage drops. Design for this: decide what is disposable and reclaim it on a timer. Session Replay prunes its oldest unpinned recordings by age and by size.
Restricting who can talk to your app
If your app accepts calls from other sites (Session Replay accepts recorder
events), gate them two ways, like it does: a write key as the primary check
(invalid key = rejected) and an allowed-origins list as a secondary check on
the browser Origin. Origins support exact values and wildcards
(https://*.example.com) and default to * so recording works immediately;
users lock it down from the Settings tab. A headless API like secret-store gates
differently: bearer tokens on every route and no CORS headers at all.
Versioning and one-click upgrades
The dashboard compares the OCI version label on your latest published image to the running container's, shows an Update available badge, and upgrades with one click (re-pull + recreate; data and settings unchanged). To participate, a release is a single commit that does all of:
- bump semver in
package.json, gated by check-version CI (no merge without it) - add a matching
CHANGELOG.mdentry in that same commit - tag the image with the exact semver (
:0.2.0) next to moving:v1/:latest - bake the
org.opencontainers.image.versionlabel into the image
That OCI label is load-bearing. Remove or rename it and the dashboard silently loses the version badge and falls back to a coarser digest compare, so the upgrade prompt breaks. The starter's publish workflow already does the tagging and labeling, and secret-store stamps it on every release.
The app definition
When an app is listed, we describe it with one declarative app definition (a
TypeScript constant in layerbase-cloud). It names the image, the backing store,
the resource caps, the env contract, and all the content and behavior the
dashboard renders. Nothing is read off your running image except the version
label, so listing a new app is a code change on our side that tells the platform
what to inject and how to frame the app.
The heart of it is the env map: for each container variable, you name WHERE the platform gets its value, and it fills that in at provision time and on every container recreate. The source kinds:
| Env source kind | The platform injects |
|---|---|
literal | a fixed string you provide (HOST, NODE_ENV) |
port | the allocated container port |
baseUrl | the app's public origin (custom domain once active) |
connectionString | the backing store's DATABASE_URL |
backingStoreSecret | a URL-only store's standalone key/token (e.g. secret-store's libSQL DATABASE_AUTH_TOKEN) |
generatedSecret | a secret minted once and persisted, stable across redeploys |
siteRegistry | Session Replay's assembled SITE_KEYS blob (legacy; do not add new ones) |
userSetting | a typed, optionally user-editable value with a default (see below) |
You never compute any of these. A connection-string engine embeds its password
in DATABASE_URL, so it needs no backingStoreSecret; a URL-only engine
(libSQL, Qdrant, Meilisearch, Weaviate, InfluxDB) gets the endpoint via
connectionString and the credential via backingStoreSecret. Every hosted app
needs one definition, and today it is hardcoded in layerbase-cloud, so listing
a new app is a code change we make.
The dashboard surface, declared
The definition also carries the app's surface and firstRun, and the detail
page renders itself from them. Three tabs are unconditional for every app -
Overview, Domain, and Settings (which always holds Redeploy and a
Delete danger zone). Everything else is opt-in through the definition:
- A masked primary secret on Overview, via
surface.primarySecret. You give it alabel("Write key", "Admin token"), ahelpMarkdownblurb rendered beneath it, and asensitivity.sensitivitydefaults to'secret'; only an explicit'publishable'lets the dashboard frame the value as safe to ship in client code (Session Replay's write key). The dashboard never upgrades a value to publishable on its own, and never tells the user a'secret'value is safe to expose. secret-store'sADMIN_TOKENis'secret'; its help copy says treat it like a root password. - An Install tab, via
surface.install, for any client-side or consumer instructions: an embed snippet (Session Replay) OR a task-first quickstart (secret-store ships curl / Node / Python tabs, no embed at all). You provide per-frameworksnippets(each alabel,language, anote, optional per-snippetcspguidance, and atemplate), and, only if you embed, an optionalattributesreference table andcspHintTemplate; afooterMarkdownTemplateis optional either way. Templates carry{{var}}placeholders the platform resolves against the live instance: built-ins are{{baseUrl}},{{primarySecret}},{{appType}}, and{{hostname}}, andresolveExtraVarslets a definition derive its own (Session Replay computes{{srcUrl}}, a content-hashed recorder filename, from the write key). Absent this field, there is no Install tab. - A hard rule on
{{primarySecret}}in snippets. Only inline it when the primary secret issensitivity: 'publishable'(Session Replay's write key is, and its snippet inlines it: it is meant to ship in client HTML). When the secret is'secret', NEVER template it into a snippet: the resolved value would render in the page DOM and get captured in session-replay recordings of the dashboard. Use a shell/env placeholder instead (secret-store uses$ADMIN_TOKEN) and tell the reader to paste it from the masked Overview field. - An external-settings deep link on the Settings tab, via
surface.externalSettings(heading,bodyMarkdown,linkLabel,linkPath). Use it for state the app itself owns and is authoritative for - Session Replay's Sites & allowed origins screen. The dashboard links out to{app url}{linkPath}rather than trying to edit that state. - A redeploy note, via
surface.redeployNote: one line describing what a redeploy preserves, shown under the Redeploy button. - A first-run hint, via
firstRun.{ kind: 'setup-screen', hint }prints a "create your admin login from the app's Setup screen" note on Overview (Session Replay);{ kind: 'none' }prints nothing (secret-store, headless).
Docs and About are driven by the sibling docs and display fields (below), not
by surface. Any app gets all of these for free by filling in the fields; older
apps that predate a field simply render nothing for it (the web defaults every
piece safe-off).
App settings the owner can edit
A userSetting env source is the one env var the owner CAN change after
provisioning, from the Settings tab. You declare it inline in the env map:
RETENTION_DAYS: {
kind: 'userSetting',
setting: 'retentionDays', // stable key the value persists + PATCHes under
valueType: 'number', // 'string' | 'number' | 'boolean' | 'string[]'
default: 0, // used until the owner sets a value
editable: true, // false = shown but managed; omit a label to hide
label: 'Retention days',
helpMarkdown: 'How many days to keep recordings before they are pruned...',
validation: { min: 0, max: 3650 },
}How it behaves end to end:
- Persistence. Owner-set values live as a JSON object on the app row
(
app_settings), keyed by thesettingname. An unset key falls back to thedefault. The value is rebuilt into the container env at create AND on every recreate, so it survives redeploys (this is exactly why Session Replay's retention no longer resets when you redeploy). - Editing. The Settings tab renders a generic, typed form straight from the
projected settings (number input, text input, switch, or comma-separated list).
Saving PATCHes
/v1/apps/:id/settings, which validates each value against itsvalueTypeandvalidation(naming only the offending key, never echoing the value), persists the merge, then recreates the container so the new env applies. Errors are value-free by design. - Non-editable settings. A declared setting with
editablefalse but alabelshows grayed out with a "managed by the app definition" tooltip and its current value; a non-editable setting with nolabelstays invisible. AuserSettingbound to the app's primary secret is never editable through this endpoint, whatever its flag.
Every value in this form is masked from session replay; labels, env-var names,
and help copy stay visible. An app with no userSetting (secret-store) renders
no settings form at all.
Staged rollout: from listed to GA
A new app does not go live the moment it is listed. Each listing carries two
independent switches - active (the master on/off) and adminOnly (the
staged-rollout gate) - that together decide who can see and deploy it. Both
default to the safe side: inactive, admin-only.
active: falsehides the app from everyone, including admins. It is the hard off switch, and admins do not bypass it.active: true, adminOnly: trueis the admin-preview stage: only admins see it and can create it, so you can test it in production before opening it up.active: true, adminOnly: falseis GA: visible to everyone.
So an app walks a path: listed but off -> admin preview -> GA, flipped per environment without a code change once it is listed.
Enforcement is dual-layer. The web UI and the create action both honor these flags, so an unavailable app never appears in the catalog. Independently, the same state is mirrored to the cloud control plane, where the provisioning gate fails closed: a missing rollout row, an inactive app, or an admin-only app requested by a non-admin all block the deploy with one identical, reason-hiding error. That means a raw API key cannot deploy an app the marketplace has not activated, even though it never touched the dashboard.
Worked example: Session Replay (the maximal app)
Session Replay exercises nearly every field: a Postgres backing store, an auth console, a publishable primary secret, a full install surface, an external-settings link, an editable retention setting, and a first-run setup screen. The env map and install snippets are trimmed here to the interesting parts; the real definition also carries the other base-URL aliases and the full per-framework snippet set.
const SESSION_REPLAY_DEFINITION = {
appType: 'session-replay',
image: { repository: 'ghcr.io/robertjbass/session-replay', defaultVersion: 'v1' },
resources: { memoryMb: 1024, diskMb: 1024, cgroup: 'user-slice' },
backingStore: { engine: 'postgresql', connectionStringEnv: 'DATABASE_URL' },
authConsole: { enabled: true, store: 'backingStore' }, // -> Auth tab
env: {
DATABASE_URL: { kind: 'connectionString' },
PORT: { kind: 'port' },
PUBLIC_BASE_URL: { kind: 'baseUrl' }, // custom domain once active
BETTER_AUTH_SECRET: { kind: 'generatedSecret', bytes: 48 },
SITE_KEYS: { kind: 'siteRegistry' }, // legacy assembled blob
RETENTION_DAYS: {
kind: 'userSetting', setting: 'retentionDays', valueType: 'number',
default: 0, editable: true, omitWhenZero: true,
label: 'Retention days', validation: { min: 0, max: 3650 },
},
// ... HOST, NODE_ENV, CDN_BASE_URL, BETTER_AUTH_URL, HOSTING_PLATFORM
},
surface: {
primarySecret: {
label: 'Write key', from: 'generatedSecret', env: 'SITE_KEYS',
sensitivity: 'publishable', // safe in client HTML; help copy says so
helpMarkdown: 'It ships in your site snippet, so it is safe to expose...',
},
install: {
attributes: [ /* the data-* recorder contract */ ],
snippets: [ { key: 'html', label: 'HTML', language: 'html',
template: '<script src="{{srcUrl}}" data-write-key="{{primarySecret}}" ...' },
/* nextjs, nuxt, sveltekit, astro, remix, spa */ ],
cspHintTemplate: "script-src 'self' {{baseUrl}}\nconnect-src 'self' {{baseUrl}}",
resolveExtraVars: (ctx) => ({ srcUrl: `${ctx.baseUrl}/v1/<hash>.js` }),
},
externalSettings: {
heading: 'Sites & allowed origins', linkLabel: 'Manage sites & origins',
linkPath: '/sites', bodyMarkdown: 'One write key across every surface...',
},
redeployNote: 'Your write key, allowed origins, custom domain, and recordings are preserved.',
},
firstRun: { kind: 'setup-screen', hint: 'Create your admin login from the Setup screen. It survives redeploys.' },
display: { author: { name: ..., url: ... }, overview: ... }, // -> About tab
docs: { markdown: ... }, // -> Docs tab
}Two subtleties worth naming. The primary secret's env points at SITE_KEYS
(the assembled blob), and the actual write-key value the dashboard masks is
minted and persisted separately, so it never rotates on redeploy. And
omitWhenZero on RETENTION_DAYS drops the var entirely when it is 0, which the
app reads as "keep recordings until deleted".
Worked example: secret-store (the minimal headless app)
secret-store is the opposite end: a headless API with no login, no
external-settings screen, no editable settings, and no setup screen. It DOES
ship an Install tab, but a task-first one (an instance-resolved quickstart plus
curl / Node / Python consume snippets, no embed), alongside the masked
ADMIN_TOKEN and a Docs tab. It uses a libSQL backing store, so its credential
arrives as a backingStoreSecret alongside the URL. The full env map is shown
(it really is this short):
const SECRET_STORE_DEFINITION = {
appType: 'secret-store',
image: { repository: 'ghcr.io/layerbase-llc/secret-store', defaultVersion: 'v1' },
resources: { memoryMb: 384, diskMb: 512, cgroup: 'user-slice' },
backingStore: { engine: 'libsql', connectionStringEnv: 'DATABASE_URL' },
authConsole: null, // headless, no login
storage: { persistent: false },
env: {
DATABASE_URL: { kind: 'connectionString' },
DATABASE_AUTH_TOKEN: { kind: 'backingStoreSecret' }, // libSQL is URL-only
ADMIN_TOKEN: { kind: 'generatedSecret', bytes: 32 }, // masked in dashboard
PORT: { kind: 'port' },
HOST: { kind: 'literal', value: '0.0.0.0' },
NODE_ENV: { kind: 'literal', value: 'production' },
},
surface: {
primarySecret: {
label: 'Admin token', from: 'generatedSecret', env: 'ADMIN_TOKEN',
sensitivity: 'secret', // root credential; NEVER inline it into a snippet
helpMarkdown: 'The single admin credential for this instance API...',
},
install: { // task-first: no attributes table, no cspHint (nothing to embed)
snippets: [ { key: 'quickstart', label: 'Quick start', language: 'bash',
// {{baseUrl}} resolves per instance; the token is a $ADMIN_TOKEN shell
// placeholder, NEVER {{primarySecret}} (see below).
template: 'export ADMIN_TOKEN=paste-from-overview\ncurl {{baseUrl}}/api/... ' },
/* consume (bash), node, python */ ],
footerMarkdownTemplate: 'How it works and rotation live in the Docs tab.',
},
// no externalSettings, no redeployNote
},
firstRun: { kind: 'none' }, // the platform mints ADMIN_TOKEN; nothing to set up
docs: { markdown: ... }, // -> Docs tab
}That is the entire declarative difference between an app with a login, an embed,
and editable settings and a pure-API service: the same schema, with the fields it
does not need left off. Because ADMIN_TOKEN is a generatedSecret, the
platform mints it once, keeps it stable across redeploys, and surfaces it masked;
there is nothing for the owner to set up, which is why firstRun is none. And
because it is sensitivity: 'secret', its Install snippets never inline
{{primarySecret}} (that would leak the token into the DOM and into dashboard
recordings): they use a $ADMIN_TOKEN placeholder pasted from the masked
Overview field. Session Replay's publishable write key is the mirror image: it IS
inlined, because it ships in client HTML anyway.
Submissions
App listings are not self-serve yet - today, apps are first-party, and the definition above is written by us when an app is listed. If you have built something on this contract (start from the starter and you have), we want to hear about it: get in touch.