Meridian handbook
Deploying

Configuration and secrets

How one project runs in development, staging and production through engine.environment and environment variables, how ${VAR} interpolation and the secret rule work, and every rule production enforces.

The same project directory runs everywhere. What differs between your laptop, staging and production is the environment the engine starts in: engine.environment and the variables your config/engine.yaml interpolates. Configuration files are committed to git; secrets never are.

A production-ready engine.yaml

This is the shape most deployments start from. Every value that differs per environment is a variable, and every secret is a bare ${VAR}:

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

server:
  listen: ${LISTEN_ADDR:-127.0.0.1:8080}
  base_url: ${BASE_URL:-http://localhost:8080}
  trusted_proxies: ["172.30.0.0/24"]   # the range your reverse proxy connects from
  security:
    signing_key: ${SIGNING_KEY}

database:
  url: ${DATABASE_URL}

logging:
  format: ${LOG_FORMAT:-json}

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

mail:
  driver: smtp
  from: "Visit Example <hello@example.org>"
  smtp:
    host: ${SMTP_HOST}
    port: 587
    username: ${SMTP_USERNAME}
    password: ${SMTP_PASSWORD}
    tls: starttls

Check it the way production will read it, without a database:

ENVIRONMENT=production engine-dmo check --no-db

check --no-db accepts an unset variable in a secret path, so CI and an image build can check the project without the deployment’s secrets. Every other unset variable is still an error.

Environments

engine.environment is development (the default), staging or production. Any other value is an error.

developmentstagingproduction
Missing server.base_urlAllowedAllowedError
Missing server.security.signing_keyAllowed, with a warning; a key is derived from the database URLAllowed, with a warningError
serve --dev profiler, debug endpoints and admin asset reloadWorkWorkOff, even with --dev
Authoring commands (engine-dmo dev field add and the rest)RunRun only with dev.authoring: trueRefused
engine-dmo mcp over standard input and output, docs serveRunRunRefused
engine-dmo seed applyRunsRunsRefused over existing content; on an empty database only with --i-know-what-i-am-doing, and seed users are never created
engine-dmo seed resetRunsRunsRefused

Never run serve --dev in production: it reloads templates from disk on every request and starts the extensions’ development servers. Set ENVIRONMENT=production on every production process, including the one-off migrate up and schema apply steps, so they read the configuration the server will.

What production refuses

In production the engine refuses at boot (and engine-dmo check reports) each of these:

SettingRule in production
server.base_urlRequired: the public origin of the default site, used for absolute URLs, sitemaps and hreflang
server.security.signing_keyRequired, at least 32 characters
server.real_ip_header with an empty server.trusted_proxiesError. The default real_ip_header is X-Forwarded-For, so list your proxy’s addresses in trusted_proxies, or set real_ip_header: "" when nothing sits in front of the engine
auth.staff.local.require_mfa: falseError: a local staff account must carry a second factor
search.log_queries: falseError: the zero-result report depends on the query log
schema.on_drift: warnError: use refuse (the default) or apply
storage.s3.public_base_urlMust be https
A headless site’s delivery.frontend.origin, preview_url and frontend webhook URLsMust be https

engine-dmo check adds the mail rules, which a booting engine does not repeat:

SettingRule in production
mail.driverMust be postmark, ses, resend or smtp; the default log driver only writes messages to the log
mail.fromRequired
mail.postmark.webhook_tokenRequired with the postmark driver
mail.resend.api_key, mail.resend.webhook_secretRequired with the resend driver; the webhook secret is the whsec_ value the Resend dashboard shows

Production also warns (it does not refuse) about auth.staff.local.breach_check: off, template.strict: true, dev.authoring (it has no effect), admin.theme.contrast_check: warn, a plain http S3 endpoint and a plain http OTLP endpoint on a public host.

Run check with the production environment

Because the mail rules live in engine-dmo check and not in the boot, an engine started in production with the log driver serves pages and writes every message to its log. Run ENVIRONMENT=production engine-dmo check --no-db in CI so that never reaches a deploy.

Environment variables in configuration

The engine reads no fixed environment variables for its configuration. It reads the variables your engine.yaml (and the other configuration files) name:

FormMeaning
${VAR}The variable’s value. An unset variable is an error at load (environment variable VAR is not set). A variable set to the empty string gives the empty string
${VAR:-default}The variable’s value, or default when the variable is unset or empty, as in the shell and Compose

Interpolation happens in values only, never in keys, and only in string-typed settings. A boolean or a number cannot come from a variable: require_mfa: ${REQUIRE_MFA} does not load. Where a setting must change per environment and is not a string, keep it the same everywhere or use a separate configuration directory with --config.

Several interpolations may be combined in one string, such as base_url: https://${PUBLIC_HOST}, except in secret paths (below). For a connection string assembled from parts, assemble it in the environment (Compose and most orchestrators can) and pass the whole string as DATABASE_URL.

The engine does not read .env files. Your shell, process manager, container runtime or orchestrator sets the variables. Environment variables lists the names the starter project and the examples use.

The secret rule

A secret path must be exactly one bare interpolation, ${VAR}, with no default and nothing around it. A literal value, or ${VAR:-something}, is an error that names the file, line and column:

config/engine.yaml:12:18: server.security.signing_key is a secret and must be an environment interpolation, not a literal
      = write signing_key: ${SIGNING_KEY} and set the variable in the environment

The secret paths in engine.yaml:

PathHolds
database.urlThe PostgreSQL connection string, with its password
server.security.signing_keyThe install’s master key
media.signing_keyThe key media derivative addresses are signed with
auth.staff.oidc.client_secretThe OpenID Connect client secret
storage.s3.secret_access_keyThe object store’s secret key
mail.postmark.server_token, mail.postmark.webhook_tokenPostmark
mail.ses.secret_access_keyAmazon SES
mail.resend.api_key, mail.resend.webhook_secretResend
mail.smtp.passwordThe SMTP relay’s password
search.meilisearch.api_key, search.meilisearch.passwordMeilisearch
search.elasticsearch.api_key, search.elasticsearch.passwordElasticsearch
search.opensearch.api_key, search.opensearch.passwordOpenSearch
maps.keyThe map tile provider’s key
telemetry.errors.dsnThe error reporter’s DSN
Every value under telemetry.otlp.headersHeaders sent to the telemetry collector
ai.azure_openai.api_key, ai.openai.api_key, ai.mistral.api_key, ai.openai_compatible.api_key, ai.anthropic.api_keyThe AI provider’s key

Other files hold secrets under the same rule: a headless site’s frontend webhook secret in site.yaml (at least 16 characters), and stripe.secret_key and stripe.webhook_secret in config/paidlistings.yaml (engine-dmo only). A plugin’s env in plugins.yaml gets nothing of the engine’s own environment; a variable whose name looks like a credential (a key, token, secret or password) must be written as an interpolation there.

Secrets in development

A secret path takes no default, so adding signing_key: ${SIGNING_KEY} (or any other secret) for production makes SIGNING_KEY required on every machine that loads the configuration, your laptop and CI included. check --no-db, describe and the dev verbs accept it unset; config validate, seed verify, new, render, serve and the rest refuse with environment variable SIGNING_KEY is not set. Set it in development to the empty string (the engine then derives a development key from the database URL, as when the key is absent) or to 32 or more random characters. A value of 1 to 31 characters is refused. Commands and the environment lists which commands need what.

A secret whose variable is set to the empty string is treated as not configured. That is how a project runs a Meilisearch server with no key: write api_key: ${MEILISEARCH_API_KEY} and set MEILISEARCH_API_KEY= (empty).

Where secrets show

Secrets are redacted wherever configuration is printed: engine-dmo config render prints [redacted] for every secret path, engine-dmo doctor reports the database as host and name only, and a recorded error that happens to quote a configured secret is scrubbed before it is stored.

engine-dmo config render          # the fully resolved engine.yaml, defaults included, secrets redacted
engine-dmo config render --json   # the same as JSON
engine-dmo config validate        # engine.yaml alone, no database

Generating and rotating keys

Generate each key with a cryptographic random source:

openssl rand -base64 48
KeyWhat it protectsRotating it
server.security.signing_keyCSRF tokens, the encryption of staff second-factor secrets at rest, hashed rate-limit keys, and the keys derived from it (including the media key when media.signing_key is unset)Every staff user’s authenticator stops verifying and must be reset with engine-dmo user reset-mfa; open forms fail their CSRF check once. Plan it as an event, not a routine
media.signing_keyThe addresses of image derivativesEvery derivative address changes, so cached pages and CDN copies point at addresses that are generated again on first request
Database, provider and API credentialsAccess to that serviceRotate at the provider, set the new value in the environment, restart the engine

Every replica of an install must have the same signing_key; replicas with different keys reject each other’s forms and sessions.