Meridian handbook
Getting started

Your first project

Create a Meridian project with init or init --from, migrate and apply it to a database, seed it, serve it and sign in to the admin as the first staff user.

A Meridian project is a directory: configuration under config/, templates under templates/, seed content under seed/ and static files under public/. This page creates one, puts it in a database and signs you in to the admin.

Each step needs the one before it:

flowchart LR
  init["init<br/>writes the files"] --> check["check --no-db<br/>no database"]
  check --> migrate["migrate up<br/>engine tables"]
  migrate --> apply["schema apply<br/>your types and sites"]
  apply --> seed["seed apply<br/>the home page"]
  seed --> dev["dev up<br/>serves the site"]
  migrate --> user["user create<br/>admin sign-in"]

You need the binary and a PostgreSQL database (Install).

Create the project

engine-dmo init my-site
created a project in /home/you/my-site from the starter (...)

guidance for coding agents: AGENTS.md, .claude/skills/meridian-project/SKILL.md, .claude/skills/meridian/SKILL.md

editor support: config schemas in .meridian/schema, mapped for zed and vscode

next:
  export DATABASE_URL=postgres://...
  engine-dmo migrate up --project /home/you/my-site
  engine-dmo schema apply --project /home/you/my-site
  engine-dmo seed apply --project /home/you/my-site
  engine-dmo serve --dev --project /home/you/my-site

The starter is the smallest project that passes engine-dmo check and serves a page: one locale, one site, the core page type routed at /{slug}, a home page in the seed, and four templates (the layout base.html, page/entry.html, and the error pages errors/404.html and errors/500.html).

init takes the directory as its argument (the current directory when omitted) and refuses a directory that already holds config/engine.yaml.

FlagDefaultEffect
--from <dir>noneCopy this project directory instead of writing the starter
--editors <list>zed,vscodeMap the configuration files to their JSON Schemas in these editors’ project settings (.zed/settings.json, .vscode/settings.json), so the editor completes and validates keys. Pass --editors "" for none
--agentstrueWrite guidance for coding agents: AGENTS.md and the meridian-project skill (both generated by engine-dmo guide from this project), and the meridian skill (the handbook’s For agents page). Each is written only when the file is not already there. --agents=false writes none

What the starter contains

.gitignore
AGENTS.md
README.md
engine.yaml
locales.yaml
grants.yaml
site.yaml
routes.yaml
seed.yaml
home.yaml
base.html
entry.html
404.html
500.html

config/engine.yaml reads everything that differs between machines from the environment:

engine:
  version: "1.x"
  environment: ${ENVIRONMENT:-development}

server:
  listen: ${LISTEN_ADDR:-127.0.0.1:8080}
  base_url: ${BASE_URL:-http://localhost:8080}

database:
  url: ${DATABASE_URL}

logging:
  format: ${LOG_FORMAT:-text}

storage:
  driver: filesystem
  filesystem:
    root: ./storage

config/locales.yaml declares one locale, en. Set its timezone and currency to your own before you add content; the starter uses Etc/UTC and EUR:

locales:
  available:
    - code: en
      id: loc_3990wq8rss
      label: English
      source: true
      default: true
      timezone: Etc/UTC
      currency: EUR

config/sites/main/site.yaml declares the one site, answering on localhost and localhost:8080, and routes.yaml routes pages and names the home page:

routes:
  - type: page
    id: rte_8397jasp17
    uri: "/{slug}"

home: { type: page, slug: home }

The id: values are written by init and are different in every project; never copy them between projects. Stable ids explains why.

config/access/grants.yaml gives the administrators group the built-in owner role, so the first staff user you create can do everything (Users, roles and permissions):

grants:
  - { group: administrators, role: owner }

seed/seed.yaml names the seed and lists locales: [en]. That list limits only the analytics a seed can generate; document values load in every locale a seed file gives, so a new locale needs its values in the documents, not in this list.

templates/_shared/page/entry.html renders a page:

{% extends "base.html" %}
{% block body %}
<h1>{{ entry.title }}</h1>
{% endblock %}

The core page type has only two fields, title and slug. To give pages a body or a summary, extend the type in config/schema/ (Types); engine-dmo describe type page lists the fields a type has.

Replace the starter layout

templates/_shared/base.html is a placeholder that writes its own <title> and canonical link, so check warns that it has no head(). Replace its head with head(), which writes the title, description, canonical link, hreflang links, robots and social tags from the page and the site’s SEO settings:

<!doctype html>
<html lang="{{ locale.code }}" dir="{{ locale.direction }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {{ head() }}
</head>
<body>
  <main id="main">{% block body %}{% endblock %}</main>
</body>
</html>

SEO and structured data covers what head() prints.

Put it in a database

Point the engine at the database

cd my-site
export DATABASE_URL='postgres://meridian:meridian@localhost:5432/meridian?sslmode=disable'

Every command reads the project in the current directory, or the one --project names.

Check the project

engine-dmo check --no-db

check validates every configuration file, the schema and every template, and prints each problem with its file, line and column. It exits 0 when the project is valid and 3 when it is not. --no-db skips the checks that need a database and does not need secrets to be set.

The unchanged starter passes and prints one warning, that base.html has no head() (Replace the starter layout). The summary line still says 0 errors, 0 warnings: template warnings are printed above it but not counted, and --strict does not fail on them. Read every line check prints, not only the summary.

Run the migrations

engine-dmo migrate up

Migrations create the engine’s own tables: core’s and those of every module the binary compiles. They only ever move forward.

Apply the schema

engine-dmo schema apply

This reconciles the database with your configuration: locales, sites, types, fields, routes, roles and grants. engine-dmo schema plan shows the same changes without applying them.

Seed it

engine-dmo seed apply

The starter’s seed creates and publishes the home page. Seeding goes through the same validation and revision history as an editor’s save, and refuses a database that already holds documents or media; engine-dmo seed reset --confirm empties content, media and analytics first (never in production).

Serve it

engine-dmo dev up

http://localhost:8080 now serves the home page. The page home: { type: page, slug: home } names in routes.yaml answers /, not /home, which is not found. dev up serves the site in development mode and watches config/: each time you save a file it writes any missing ids, checks the project, plans the schema change and applies it when it is not destructive. Stop it with Ctrl+C. The development loop covers it and engine-dmo serve --dev.

Sign in to the admin

The admin is at /studio on any of the project’s sites. Staff users are created from the command line:

engine-dmo user create --email you@example.org --name "Your Name"
first user: added to the administrators group
created you@example.org (...) in groups administrators
enrolment link, valid 24 hours, single use:
  http://localhost:8080/studio/enrol/...

The first user created goes into administrators, which the starter’s grants.yaml gives the owner role. Later users name their groups with --group (repeatable). The link is built from server.base_url.

Open the link, choose a password of at least 12 characters, scan the code into an authenticator app and enter a code from it. The admin then shows your recovery codes once. Staff accounts require a second factor by default.

user create needs the migrations applied. It does not send mail: it prints the link.

Development shortcuts

Two settings make signing in quicker on your own machine. Neither is allowed in production.

A file in seed/users/ creates a staff user when the seed is applied, with a password and an authenticator secret, so you can sign in straight away after every reseed:

# seed/users/admin.yaml. Development only: seed apply skips users in production.
email: admin@example.test
name: Dev Admin
groups: [administrators]
password: correct horse battery staple
totp_secret: JBSWY3DPEHPK3PXP

totp_secret is a base32 secret you add to your authenticator app. The value above is a well-known example; use your own. Leave it out and the user cannot sign in while a second factor is required.

What to do next

  • Add a type, a field or a route with the engine-dmo dev verbs while dev up runs: The development loop.
  • Learn what each file is for: Project layout.
  • Model your content: Content modelling.
  • Write templates: Templates.
  • Point your coding agent at AGENTS.md and the two skills init wrote; the engine-dmo dev verbs regenerate them whenever they change the configuration.