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
| Requirement | Version |
|---|---|
| Node.js | ≥ 22 (22 LTS or 24 LTS) |
| npm or pnpm | any 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:
# 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' honoVersion pin:
laikacmspeer-depends on@hono/node-server@^2.0.10(see itspackage.json). Pinning the major here keeps a barenpm install/pnpm addfrom picking up a future@hono/node-servermajor beforelaikacmsdeclares support for it — checklaikacms'speerDependenciesif you hit an ERESOLVE here.
pnpm records the
--allow-buildgrants in apnpm-workspace.yamlit writes next to yourpackage.json, so you only pass the flags once.pnpm approve-buildsis the interactive equivalent.
| Package | Purpose |
|---|---|
laikacms | Core: storage repos, document/asset repos, API factories, serializers (subpaths). |
@laikacms/server | Decap-compatible API server + Laika backend for the browser-side Decap CMS admin. |
@hono/node-server | Runs a Web-standard fetch handler on Node.js. |
hono | Peer 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, andlaikacms/storage-serializers-json. All of them come withpnpm add laikacms. See Imports for every import path.
Other formats: swap
laikacms/storage-serializers-jsonforlaikacms/storage-serializers-yamlif 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/serverDecap admin backend (§4) currently only sends structuredcontentto the documents API for collections withformat: json. Decap's default when a collection omitsformat: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 setformat: jsonexplicitly on every collection inadmin/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):
// 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?
ConventionCatalogProvidermaps 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 toDecapCatalogProvider(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
authenticateAccessTokencallback above accepts a hard-coded dev token. For production, replace it with a real validator (JWT verification, database session lookup, etc.) or use the bundledlaikaOauth2helper — see Decap Integration.
Other base paths: if you mount behind a reverse proxy at a different prefix, change
basePath: '/api'here and updateapi_rootinadmin/config.ymlto match.
3. Add a start script
In package.json:
{
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}Start the server:
npm startThe API is now available at http://localhost:3000. Verify the health endpoint with:
curl http://localhost:3000/api/healthYou 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 unresolvedcatalog:protocol specs in 97 of its 99 dependency entries and has not been deprecated. Installing it fails outright withERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER: "ol@catalog:media" isn't supported by any available resolver. Every release from4.0.3up 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 onto4.0.2, not a workaround you need today. Tracked upstream in laikacms/decap-cms#941; once4.0.2is deprecated on npm the floor can be dropped.
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 esbuildpnpm: these two commands need no flags — the
--allow-buildgrants from §1 are already recorded inpnpm-workspace.yaml. Without those grantspnpmexits withERR_PNPM_IGNORED_BUILDSand the install step fails.
The
laikabackend ships as@laikacms/decap-cms/backends/laika(a subpath of the@laikacms/decap-cmspackage you just installed, not of@laikacms/server). ImportcreateLaikaBackendfrom there.
@emotion/reactand@emotion/styledare 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 noadmin/bundle.js.
Optional peer warnings:
@laikacms/decap-cmsalso 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), andlucide-react/@radix-ui/react-icons(icon widgets).npm/pnpmwill 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:
<!-- 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
// 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
# 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: markdownis deprecated (DCMS-483) in favour ofwidget: richtext; a back-compat alias still resolvesmarkdownfields with a runtime warning. Migration guide: breaking-changes-v4-beta.md inlaikacms/decap-cms.
format: jsonis required. Omittingformat: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>`.
languagefield in stored content:CatalogDocumentsRepositoryco-locates the document language with its content in storage. When Decap saves an entry that has no i18n configuration, it sendslanguage: "und"(undetermined per BCP 47). As a result, every stored.jsonfile includes alanguage: "und"key alongside your declared fields:json{ "title": "My post", "body": "...", "language": "und" }Treat
languageas a Laika-managed field — do not declare it in Decap'sfields: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:
# 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 5000esbuild 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
corsis required here: the admin at:5000and the API at:3000are different origins. Without thecorsoption 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. Thecors: { origins: ['http://localhost:5000'] }added toserver.mjsin §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 thecorsoption entirely).
Rebuild after changes: re-run the
npx esbuild …command whenever you editadmin/index.tsoradmin/config.yml. For a faster inner loop, append--watchto the esbuild command and open a third terminal fornpx 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
- Push your code to a GitHub repository.
- Create a new Railway project and connect the repo.
- Add a Persistent Volume and mount it at
/app/content(or wherever you setrootDirectory). - Set
NODE_ENV=productionand any other environment variables in the Railway dashboard. - Railway will run
npm startautomatically.
Fly.io
# 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"]fly volumes create content_data --size 1
fly deployDocker
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"]docker build -t laika-api .
docker run -p 3000:3000 -v $(pwd)/content:/app/content laika-apiEnvironment variables
| Variable | Description |
|---|---|
PORT | Port the server listens on (default: 3000 in the example above). |
DEV_TOKEN | Pre-shared bearer token for local dev. Remove entirely for production OAuth2 auth. |
laikaApidoes not read environment variables directly — pass values fromprocess.envwhen constructing the repository, the token string, and theservecall.
Next steps
- Architecture — understand the layered design
- Decap Integration — OAuth2, widgets, media library
- API Reference — full JSON:API endpoint reference
- Repositories — swap to R2, S3, or other backends