Skip to content

Building a Layerbase App

We will use our first-party Session Replay app as the running example to explain our whole philosophy for hosting apps. So before anything else, here is what a Layerbase app schema looks like:

ts
const SESSION_REPLAY = {
  appType: 'session-replay',
  image: { repository: 'ghcr.io/.../session-replay', defaultVersion: 'v1' },
  ports: [{ name: 'http', portEnv: 'PORT', exposure: 'public', primary: true }],
  resources: { memoryMb: 1024, diskMb: 1024, cgroup: 'user-slice' },
  backingStore: { engine: 'postgresql', connectionStringEnv: 'DATABASE_URL' },
  authConsole: { enabled: true, store: 'backingStore' },
  env: { /* the env-source contract */ },
  display: { author: { name: ..., url: ... }, overview: ... }, // -> About tab
  docs: { markdown: ... },                                      // -> Docs tab
  lifecycle: { hibernation: 'never', stopStart: false, imageRollout: 'manual' },
}

Do not worry if parts of it look unfamiliar yet. Keep this schema in mind: it adds context to the snippets you will see throughout these docs. We dissect how Session Replay works piece by piece, and by the end of this page this whole definition will make perfect sense to you. (Submissions are not open yet; see the end.)

What Session Replay is

Session Replay records real user sessions on a website and lets you play them back: a DOM-accurate reconstruction of what the visitor saw and did, with privacy masking, error markers, and retention controls. You drop a small recorder script on your site; it streams events to your own Session Replay instance, which stores them and serves a dashboard to watch them.

Why it is a good fit for a Layerbase app

Two things make it a clean fit:

1. It uses a database we already support. Every Session Replay instance is assigned one of our supported databases. Behind the scenes, when you create the app, Layerbase provisions a fresh Postgres database for it and injects the connection string as an env var. The app never manages its own database; it just reads DATABASE_URL.

2. It is a single process behind one image. Session Replay is Node + Vite + React. Node runs the API and is the main process in the container; Vite bundles the React dashboard into a static single-page app, which the same Node process serves. One process, one image.

You could build the same thing with Next.js, or any framework you can put in a Dockerfile. The rule that matters: a Dockerfile runs one process. So an app is a good fit when it (a) runs as a single long-running process, and (b) uses a database we support, reached by a connection string. Session Replay is exactly that.

You get an auth tab for free

Session Replay manages its own logins with Better Auth, storing users in its Postgres database (the user, session, account, verification tables). Because Layerbase already understands that schema on our supported engines, your app gets a free Auth tab in the dashboard - list users, create them, reset passwords, disable, delete - with no UI work on your side. You opt in from the definition:

ts
backingStore: { engine: 'postgresql', connectionStringEnv: 'DATABASE_URL' },
authConsole: { enabled: true, store: 'backingStore' },

The dashboard probes the backing store; once it recognizes the auth schema, the Auth tab appears. It works on any auth-console engine (libSQL, Postgres, MySQL, MariaDB).

One process only: no docker-compose

We do not support docker-compose or multi-container apps. Your app is a single image running a single process. This is a security boundary, not just a simplification: every submitted app is audited to confirm it interacts with its database and the filesystem in an acceptable way, and a single audited process is what makes that review tractable. If you need a database, you get one of ours (above); you do not bring your own container for it.

Resources: stay within budget, or get paused

Your app declares its minimum compute up front, and is responsible for staying inside it. Two limits are enforced:

  • Memory is a hard per-container cap. Exceed it and the container is stopped.
  • Storage is measured against your quota. Cross it and the backing database is set read-only (the app keeps serving reads, but writes are paused) until usage drops.

So an app must keep its own footprint bounded. Session Replay does this by pruning: it deletes the oldest recordings that you have not explicitly kept. Retention is driven two ways - by age (RETENTION_DAYS, off by default) and by a size safety net (it trims back to stay under ~1 GiB of stored events). Recordings you pin (kept) are never auto-deleted. The lesson for your own app: decide what is disposable and reclaim it on a timer.

Resources are declared in the definition:

ts
// Hard caps the platform enforces. memoryMb is the container memory limit.
resources: { memoryMb: 1024, diskMb: 1024, cgroup: 'user-slice' },

The anatomy of an app

text
  your image  ----->  +---------------------------------+
  (one process)       |  Dedicated container            |
                      |  binds $PORT on HOST=0.0.0.0    |
                      +----------------+----------------+
                                       |  DATABASE_URL (injected)
                      +----------------v----------------+
                      |  Backing database (Postgres)    |   provisioned for you
                      |  - connection string injected   |
                      |  - powers the free Auth tab     |
                      +---------------------------------+

  HTTPS domain  ---->  app-name.cloud.layerbase.dev   (managed TLS; add your own)

  Dashboard tabs -->   Overview . Domain . Install . Settings . Auth . Docs . About
                       ^^^^ universal ^^^^            ^^^^ from your definition ^^^^

A submission is three things: the image, the app definition (the declarative contract below), and optional content (docs + author info) that lights up the Docs and About tabs. You write no dashboard code; filling in the definition renders the UI.

Supported backing stores

The backing-store contract is connection-string based: you name an env var, and the platform injects the provisioned database connection string into it. That covers every connection-string engine we host: the SQL engines (Postgres, MySQL, MariaDB, CockroachDB, libSQL, SQLite, DuckDB, QuestDB, ClickHouse), the document and key-value engines (MongoDB, FerretDB, Redis, Valkey), and more.

The HTTP / URL-only engines (Qdrant, Meilisearch, InfluxDB, Weaviate, libSQL) are supported too. Their connection string is just the base URL with no embedded credentials, so the definition adds a second env source for the key:

ts
  QDRANT_URL:     { kind: 'connectionString' },     // https://<store-host>:<port>
  QDRANT_API_KEY: { kind: 'backingStoreSecret' },   // the provisioned key/token

The one engine that cannot back an app is TigerBeetle: it is a file-based ledger reached only through its client SDK, with no network connection string to inject. Every other engine works.

Domains and TLS

Each app is assigned an HTTPS subdomain (for example your-app.cloud.layerbase.dev) with managed TLS - you do nothing. To use your own domain, add it on the Domain tab: we show you a CNAME (or A) record to set at your registrar, verify it, and issue the certificate. Once it is verified, the app's public base URL switches to your domain, and anything derived from it (like an install snippet) updates to match.

Restricting who can talk to your app

If your app accepts calls from other sites (Session Replay accepts recorder events), you control which origins are allowed. Session Replay gates ingest with two checks: a write key (the primary gate - an invalid key is rejected) and an allowed-origins list (a secondary check on the browser Origin). The origins list supports exact values and wildcards (https://*.example.com), and defaults to * (any origin) so you can start immediately, then lock it down to your domains from the Settings tab.

Worked example: Session Replay

The Dockerfile

A two-stage build: compile the recorder bundle and the React SPA, then ship a slim runtime that runs the Node server.

dockerfile
# build stage: install deps, build the recorder bundle + the React SPA
FROM node:22-slim AS builder
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
RUN pnpm build            # tsx src/build.ts (esbuild + tailwind) && vite build

# runtime stage: production deps only, the built assets, and the server
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/dist-web ./dist-web
COPY --from=builder /app/node_modules ./node_modules
COPY . .
ENV HOST=0.0.0.0
ENV PORT=4000
CMD ["pnpm", "start"]     # tsx src/server/index.ts (the single main process)

Binding the port

The platform sets PORT; the server must bind to it on 0.0.0.0:

ts
const PORT = Number(process.env.PORT ?? 4000)
const HOST = process.env.HOST ?? '127.0.0.1' // set to 0.0.0.0 in the container
server.listen(PORT, HOST)

The env contract

The app declares where each env var draws from; the platform computes the values at provision time. Session Replay, abbreviated:

ts
env: {
  PORT:               { kind: 'port' },               // the allocated port
  DATABASE_URL:       { kind: 'connectionString' },   // the provisioned Postgres
  PUBLIC_BASE_URL:    { kind: 'baseUrl' },             // public origin (custom domain once verified)
  BETTER_AUTH_URL:    { kind: 'baseUrl' },
  BETTER_AUTH_SECRET: { kind: 'generatedSecret', bytes: 48 }, // minted + persisted for you
  HOST:               { kind: 'literal', value: '0.0.0.0' },
  RETENTION_DAYS:     { kind: 'userSetting', setting: 'retentionDays', valueType: 'number', default: 0 },
}

The env-source kinds available today: literal, port, baseUrl, connectionString, generatedSecret, and userSetting (a typed, user-editable value with an optional default).

The full definition (shape)

This is the same schema from the top of the page. By now each field should read naturally: the image, the assigned backing store, the auth opt-in, the env contract, and the display/docs content that fills the About and Docs tabs.

ts
const SESSION_REPLAY = {
  appType: 'session-replay',
  image: { repository: 'ghcr.io/.../session-replay', defaultVersion: 'v1' },
  ports: [{ name: 'http', portEnv: 'PORT', exposure: 'public', primary: true }],
  resources: { memoryMb: 1024, diskMb: 1024, cgroup: 'user-slice' },
  backingStore: { engine: 'postgresql', connectionStringEnv: 'DATABASE_URL' },
  authConsole: { enabled: true, store: 'backingStore' },
  env: { /* the env-source contract */ },
  display: { author: { name: ..., url: ... }, overview: ... }, // -> About tab
  docs: { markdown: ... },                                      // -> Docs tab
  lifecycle: { hibernation: 'never', stopStart: false, imageRollout: 'manual' },
}

Technical specs (Session Replay)

AspectValue
Processsingle Node server (tsx src/server/index.ts)
FrontendReact SPA built by Vite, served by the same Node process
Recorderrrweb, bundled with esbuild
DatabasePostgres via Drizzle + pg, migrations on boot
AuthBetter Auth (email + password, admin plugin)
Memory cap1024 MB (hard, enforced)
Storage1024 MB; auto-pruned by age + a ~1 GiB size net; pinned recordings kept
Port / host$PORT on 0.0.0.0
Ingest gatingwrite key (primary) + allowed-origins (secondary, wildcards)
Heavy featuresvideo export off by default (opt-in via env)

Submissions

App submissions are not open yet - today, apps are first-party only. Everything above is the contract we are building toward, so when submissions open you already know the shape: a single-process image, a supported database reached by a connection string, a declarative definition, and optional content for the Docs and About tabs.