Skip to content
Browse docs

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 mustWhy
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-loopingThe 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 SIGTERMRedeploys drain in-flight requests instead of dropping them
Ship as a prebuilt image with an OCI version labelNothing 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.

  1. Copy the folder that matches your stack.
  2. Run it locally: spindb create todos --engine postgresql, put the connection string in .env, pnpm install && pnpm dev.
  3. 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 separate GET /healthz probe (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 /healthz is liveness: no auth, no database, an instant 200, reading only process.env.PORT. Your Dockerfile HEALTHCHECK probes this, and it is what docker inspect reports (we inject no healthcheck of our own).
  • GET /health is 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:

dockerfile
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_URL is 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_URL and DATABASE_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), and PORT, 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:

ts
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.md entry in that same commit
  • tag the image with the exact semver (:0.2.0) next to moving :v1 / :latest
  • bake the org.opencontainers.image.version label 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 kindThe platform injects
literala fixed string you provide (HOST, NODE_ENV)
portthe allocated container port
baseUrlthe app's public origin (custom domain once active)
connectionStringthe backing store's DATABASE_URL
backingStoreSecreta URL-only store's standalone key/token (e.g. secret-store's libSQL DATABASE_AUTH_TOKEN)
generatedSecreta secret minted once and persisted, stable across redeploys
siteRegistrySession Replay's assembled SITE_KEYS blob (legacy; do not add new ones)
userSettinga 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 a label ("Write key", "Admin token"), a helpMarkdown blurb rendered beneath it, and a sensitivity. sensitivity defaults 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's ADMIN_TOKEN is '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-framework snippets (each a label, language, a note, optional per-snippet csp guidance, and a template), and, only if you embed, an optional attributes reference table and cspHintTemplate; a footerMarkdownTemplate is optional either way. Templates carry {{var}} placeholders the platform resolves against the live instance: built-ins are {{baseUrl}}, {{primarySecret}}, {{appType}}, and {{hostname}}, and resolveExtraVars lets 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 is sensitivity: '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:

ts
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 the setting name. An unset key falls back to the default. 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 its valueType and validation (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 editable false but a label shows grayed out with a "managed by the app definition" tooltip and its current value; a non-editable setting with no label stays invisible. A userSetting bound 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: false hides the app from everyone, including admins. It is the hard off switch, and admins do not bypass it.
  • active: true, adminOnly: true is 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: false is 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.

ts
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):

ts
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.