Skip to content

Self-Hosting Quickstart: FileSystem + Decap CMS ​

This guide walks you through running Laika on a plain Node.js server using filesystem storage (laikacms/storage-fs) and the Laika backend for Decap CMS (@laikacms/server). It is the simplest possible self-hosted setup — no cloud provider account required.

For a broader overview of the system see architecture.


Prerequisites ​

RequirementVersion
Node.js≥ 22 (22 LTS or 24 LTS)
npm or pnpmany recent

1. Install packages ​

Install the Laika packages and a Node.js server runtime. Storage repos, document/asset repos, API factories, and serializers are all subpath exports of the single laikacms package. The Decap integration lives in @laikacms/server:

bash
# npm — add hono explicitly: §4a's --legacy-peer-deps prunes auto-installed peers, and
# @hono/node-server's type declarations import from hono (needed if you use server.ts).
# Pin @hono/node-server to the major laikacms peer-depends on (currently ^2) — an
# unpinned install can otherwise pull a newer major than laikacms supports and fail
# with an ERESOLVE peer conflict before anything installs.
npm install laikacms @laikacms/server '@hono/node-server@^2' hono

# pnpm — hono for the same reason: pnpm satisfies @hono/node-server's peer internally, but does
# not expose it at your project root, so a server.ts would not typecheck without it.
# --allow-build: since v10 pnpm refuses to run dependency install scripts unless you allow them,
# and exits non-zero when it skips any — without these flags this command fails outright with
# ERR_PNPM_IGNORED_BUILDS (msgpackr-extract, and esbuild once §4a adds it).
pnpm add --allow-build=esbuild --allow-build=msgpackr-extract \
  laikacms @laikacms/server '@hono/node-server@^2' hono

Version pin: laikacms peer-depends on @hono/node-server@^2.0.10 (see its package.json). Pinning the major here keeps a bare npm install/pnpm add from picking up a future @hono/node-server major before laikacms declares support for it — check laikacms's peerDependencies if you hit an ERESOLVE here.

pnpm records the --allow-build grants in a pnpm-workspace.yaml it writes next to your package.json, so you only pass the flags once. pnpm approve-builds is the interactive equivalent.

PackagePurpose
laikacmsCore: storage repos, document/asset repos, API factories, serializers (subpaths).
@laikacms/serverDecap-compatible API server + Laika backend for the browser-side Decap CMS admin.
@hono/node-serverRuns a Web-standard fetch handler on Node.js.
honoPeer dependency of @hono/node-server@2. §4a's --legacy-peer-deps prunes npm's auto-installed copy; also needed to typecheck a server.ts.

Subpath exports: the snippet below imports from laikacms/storage-fs, laikacms/documents-catalog, laikacms/assets-catalog, laikacms/catalog-convention, and laikacms/storage-serializers-json. All of them come with pnpm add laikacms. See Imports for every import path.

Other formats: swap laikacms/storage-serializers-json for laikacms/storage-serializers-yaml if you prefer YAML files, and change 'json' to 'yaml' in the snippet below.

Supported Decap collection formats: the storage layer above ships serializers for JSON, YAML, markdown-frontmatter, and raw text — but the bundled @laikacms/server Decap admin backend (§4) currently only sends structured content to the documents API for collections with format: json. Decap's default when a collection omits format: is markdown-frontmatter, which this backend does not yet support — saving such an entry fails fast with a clear client-side error rather than a raw file write. Always set format: json explicitly on every collection in admin/config.yml, as the example in §4d does.


2. Create the Node.js server ​

Create server.mjs (or server.ts if you have a TypeScript build step):

js
// server.mjs
import { serve } from '@hono/node-server';
import { laikaApi } from '@laikacms/server/api';
import { CatalogAssetsRepository } from 'laikacms/assets-catalog';
import { ConventionCatalogProvider } from 'laikacms/catalog-convention';
import { CatalogDocumentsRepository } from 'laikacms/documents-catalog';
import { FileSystemStorageRepository } from 'laikacms/storage-fs';
import { jsonSerializer } from 'laikacms/storage-serializers-json';

// Replace this with a real secret in production (e.g. from process.env).
const DEV_TOKEN = 'dev-secret-change-me';

// 1. Build a serializer registry — maps file extensions to serializers.
const serializerRegistry = {
  json: jsonSerializer,
};

// 2. Instantiate the storage repository.
//    Constructor: new FileSystemStorageRepository(
//      rootDirectory,        // path to the content folder on disk
//      serializerRegistry,   // { [extension]: StorageSerializer }
//      defaultFileExtension, // extension used when creating new objects
//      ignoreList?           // glob patterns to exclude (optional)
//    )
const storage = new FileSystemStorageRepository(
  './content', // rootDirectory — created automatically on first write
  serializerRegistry,
  'json', // new objects are stored as <key>.json
);

// 3. Wrap storage in document/asset repos (Catalog layer).
const settings = new ConventionCatalogProvider({ storage });
const documents = new CatalogDocumentsRepository(storage, settings);
const assets = new CatalogAssetsRepository(storage, settings);

// 4. Build the Decap-compatible API.
//    basePath '/api' matches the `api_root` in admin/config.yml below.
//    The handler exposes:
//      GET  /api/health      — status probe (no auth required)
//      *    /api/documents/* — documents JSON:API
//      *    /api/assets/*    — assets JSON:API
//      *    /api/storage/*   — raw storage JSON:API
const api = laikaApi({
  documents,
  storage,
  assets,
  basePath: '/api',
  // For local development: accept a single pre-shared bearer token.
  // Replace this with a real session/JWT validator before deploying to production.
  authenticateAccessToken: async token => {
    if (token !== DEV_TOKEN) throw new Error('Invalid token');
    return { id: 'dev', email: 'dev@localhost' };
  },
  // Authorization: decide what an authenticated principal may do. Here, anyone
  // holding a valid token gets full access. Return false (or throw) to deny.
  authorize: () => true,
  // CORS: required when the Decap admin is served from a different origin than this
  // API (e.g. `npx serve admin/ -l 5000` on :5000 while the API runs on :3000).
  // Without this the browser blocks every API call with a CORS error before the
  // Decap admin can authenticate.  In production, list only your actual admin origin.
  cors: { origins: ['http://localhost:5000'] },
});

// 5. Start listening.
serve({ fetch: api.fetch, port: 3000 }, () => {
  console.log('Laika API listening on http://localhost:3000');
  console.log('Health check: http://localhost:3000/api/health');
});

Which settings provider?ConventionCatalogProvider maps collection names to same-name storage folders and auto-creates its settings object on first use — no seeding required. It is the right choice for this quickstart and for most simple setups. Switch to DecapCatalogProvider (laikacms/catalog-decap) when you need the server and browser Decap configs to stay in sync from one source of truth (it derives collection/folder/media mappings from a Decap config JSON you seed into storage), or when you need multi-folder or nested collection support.

Production auth: the authenticateAccessToken callback above accepts a hard-coded dev token. For production, replace it with a real validator (JWT verification, database session lookup, etc.) or use the bundled laikaOauth2 helper — see Decap Integration.

Other base paths: if you mount behind a reverse proxy at a different prefix, change basePath: '/api' here and update api_root in admin/config.yml to match.


3. Add a start script ​

In package.json:

json
{
  "type": "module",
  "scripts": {
    "start": "node server.mjs"
  }
}

Start the server:

bash
npm start

The API is now available at http://localhost:3000. Verify the health endpoint with:

bash
curl http://localhost:3000/api/health

You should receive a JSON response like {"status":"ok","timestamp":"..."}. This is the same endpoint the Decap laika backend pings to confirm the server is reachable.


4. Set up Decap CMS ​

4a. Install the Decap CMS app ​

@laikacms/server was already installed in §1. Install the Decap CMS browser bundle — the @laikacms/decap-cms fork (its root export is the classic app bootstrap; it also provides the lib/util, lib/auth, ui-default, and core subpaths that the Laika backend imports at bundle time) — its required peer dependencies, and esbuild (used to compile the TypeScript entry file into a browser bundle):

Version floor: the range below keeps the solver off @laikacms/decap-cms@4.0.2, which was published to npm with unresolved catalog: protocol specs in 97 of its 99 dependency entries and has not been deprecated. Installing it fails outright with ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER: "ol@catalog:media" isn't supported by any available resolver. Every release from 4.0.3 up is clean, and a plain unpinned install currently picks a good version on its own — the floor is cheap insurance against a peer range steering the solver back onto 4.0.2, not a workaround you need today. Tracked upstream in laikacms/decap-cms#941; once 4.0.2 is deprecated on npm the floor can be dropped.

bash
npm install '@laikacms/decap-cms@^4.1.0' @emotion/react @emotion/styled
npm install --save-dev esbuild

pnpm add '@laikacms/decap-cms@^4.1.0' @emotion/react @emotion/styled
pnpm add -D esbuild

pnpm: these two commands need no flags — the --allow-build grants from §1 are already recorded in pnpm-workspace.yaml. Without those grants pnpm exits with ERR_PNPM_IGNORED_BUILDS and the install step fails.

The laika backend ships as @laikacms/decap-cms/backends/laika (a subpath of the @laikacms/decap-cms package you just installed, not of @laikacms/server). Import createLaikaBackend from there.

@emotion/react and @emotion/styled are required (non-optional) peer dependencies of @laikacms/decap-cms — the admin shell is styled with Emotion. Without them the esbuild step will fail with "Could not resolve @emotion/…" errors and produce no admin/bundle.js.

Optional peer warnings: @laikacms/decap-cms also declares optional peers for specific widgets — @apollo/client / graphql / graphql-tag (GraphQL-backed widgets), ol (the map widget), uploadcare-widget / uploadcare-widget-tab-effects (the Uploadcare media library), and lucide-react / @radix-ui/react-icons (icon widgets). npm/pnpm will warn that these are unmet — that's expected unless you register the corresponding widget; install only the ones you actually use.

4b. Create the HTML entry point ​

The static file server needs an index.html to load your compiled bundle:

html
<!-- admin/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Admin — Laika</title>
  </head>
  <body>
    <!-- esbuild compiles admin/index.ts → admin/bundle.js (see §5) -->
    <script src="bundle.js" type="module"></script>
  </body>
</html>

4c. Register the backend ​

typescript
// admin/index.ts
import { DecapCmsApp as CMS } from '@laikacms/decap-cms';
import { createLaikaBackend } from '@laikacms/decap-cms/backends/laika';

// No explicit documentsApiBaseUrl / assetsApiBaseUrl needed: the backend
// derives both from base_url + api_root in config.yml (→ http://localhost:3000/api).
const LaikaBackend = createLaikaBackend();

CMS.registerBackend('laika', LaikaBackend);
CMS.init();

4d. Write the Decap config ​

yaml
# admin/config.yml
backend:
  name: laika
  base_url: http://localhost:3000
  api_root: /api
  # dev_token lets the Decap admin authenticate without an OAuth2 flow.
  # Must match DEV_TOKEN in server.mjs. Remove for production.
  dev_token: dev-secret-change-me

media_folder: uploads
public_folder: /uploads

collections:
  - name: posts
    label: Posts
    folder: posts
    create: true
    format: json # required — the laika Decap backend only supports JSON-format collections today
    fields:
      - { name: title, label: Title, widget: string }
      - { name: body,  label: Body,  widget: richtext }

widget: markdown is deprecated (DCMS-483) in favour of widget: richtext; a back-compat alias still resolves markdown fields with a runtime warning. Migration guide: breaking-changes-v4-beta.md in laikacms/decap-cms.

format: json is required. Omitting format: makes Decap default to markdown-frontmatter, which the laika backend cannot yet persist (see the callout in §1). Saving an entry in such a collection now fails with: Laika backend currently only supports JSON-format collections; set `format: json` on collection `<name>`.

language field in stored content:CatalogDocumentsRepository co-locates the document language with its content in storage. When Decap saves an entry that has no i18n configuration, it sends language: "und" (undetermined per BCP 47). As a result, every stored .json file includes a language: "und" key alongside your declared fields:

json
{ "title": "My post", "body": "...", "language": "und" }

Treat language as a Laika-managed field — do not declare it in Decap's fields: list and filter it out when reading content files directly for rendering. If you configure i18n, the field will contain the active locale instead.

The backend constructs its API URL as base_url + api_root → http://localhost:3000/api. All document, asset, storage, and health endpoints are served under that prefix by the laikaApi server started in §2.

The dev_token value is sent as a Bearer token by the Decap admin; the server's authenticateAccessToken callback checks it. Never use a dev token in production — replace it with a real OAuth2 flow or JWT validator (see Decap Integration).

For a production deployment, replace http://localhost:3000 with your public API URL and remove the dev_token line.

See Decap Integration for the full integration guide including OAuth2 setup and available widgets.


5. Run locally ​

In two terminals:

bash
# Terminal 1 — Decap-compatible API (documents + assets + health at /api/*)
npm start

# Terminal 2 — compile the admin bundle, then serve it
npx esbuild admin/index.ts --bundle --outfile=admin/bundle.js --format=esm --platform=browser
npx serve admin/ -l 5000

esbuild bundles admin/index.ts together with @laikacms/decap-cms and @laikacms/decap-cms/backends/laika into a single admin/bundle.js that the browser can load directly. The -l 5000 flag pins serve to port 5000 — without it, serve defaults to port 3000 and falls back to a random ephemeral port when the API already holds 3000, breaking the hardcoded CORS origin. The serve step then hosts admin/index.html (and bundle.js) at http://localhost:5000.

Open http://localhost:5000 (or wherever serve binds) to access the Decap CMS admin UI.

Why cors is required here: the admin at :5000 and the API at :3000 are different origins. Without the cors option the browser's CORS preflight (OPTIONS /api/session) would be answered with a 401 and the Decap admin would show an endless stream of "Authentication failed: Failed to fetch" errors — no login form, no collections. The cors: { origins: ['http://localhost:5000'] } added to server.mjs in §2 enables cross-origin requests from the admin. In production, replace the origin list with your deployed admin URL (or serve the admin and API from the same origin to drop the cors option entirely).

Rebuild after changes: re-run the npx esbuild … command whenever you edit admin/index.ts or admin/config.yml. For a faster inner loop, append --watch to the esbuild command and open a third terminal for npx serve admin/ -l 5000.


6. Production deployment ​

The laikaApi server is a standard Node.js process and can be deployed anywhere that supports Node.js 22 or later.

Key requirement ​

FileSystemStorageRepository reads and writes files at rootDirectory. In production you need a persistent volume attached to that path so content survives restarts and redeploys.

Railway ​

  1. Push your code to a GitHub repository.
  2. Create a new Railway project and connect the repo.
  3. Add a Persistent Volume and mount it at /app/content (or wherever you set rootDirectory).
  4. Set NODE_ENV=production and any other environment variables in the Railway dashboard.
  5. Railway will run npm start automatically.

Fly.io ​

toml
# fly.toml
app = "my-laika-api"
primary_region = "iad"

[build]
dockerfile = "Dockerfile"

[mounts]
source = "content_data"
destination = "/app/content"

[[services]]
internal_port = 3000
protocol = "tcp"

[[services.ports]]
port = 443
handlers = ["tls", "http"]
bash
fly volumes create content_data --size 1
fly deploy

Docker ​

dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# Content is stored in /app/content — mount a volume here in production.
VOLUME ["/app/content"]

EXPOSE 3000
CMD ["node", "server.mjs"]
bash
docker build -t laika-api .
docker run -p 3000:3000 -v $(pwd)/content:/app/content laika-api

Environment variables ​

VariableDescription
PORTPort the server listens on (default: 3000 in the example above).
DEV_TOKENPre-shared bearer token for local dev. Remove entirely for production OAuth2 auth.

laikaApi does not read environment variables directly — pass values from process.env when constructing the repository, the token string, and the serve call.


Next steps ​

Released under the MIT License.