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.
| development | staging | production | |
|---|---|---|---|
Missing server.base_url | Allowed | Allowed | Error |
Missing server.security.signing_key | Allowed, with a warning; a key is derived from the database URL | Allowed, with a warning | Error |
serve --dev profiler, debug endpoints and admin asset reload | Work | Work | Off, even with --dev |
Authoring commands (engine-dmo dev field add and the rest) | Run | Run only with dev.authoring: true | Refused |
engine-dmo mcp over standard input and output, docs serve | Run | Run | Refused |
engine-dmo seed apply | Runs | Runs | Refused over existing content; on an empty database only with --i-know-what-i-am-doing, and seed users are never created |
engine-dmo seed reset | Runs | Runs | Refused |
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:
| Setting | Rule in production |
|---|---|
server.base_url | Required: the public origin of the default site, used for absolute URLs, sitemaps and hreflang |
server.security.signing_key | Required, at least 32 characters |
server.real_ip_header with an empty server.trusted_proxies | Error. 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: false | Error: a local staff account must carry a second factor |
search.log_queries: false | Error: the zero-result report depends on the query log |
schema.on_drift: warn | Error: use refuse (the default) or apply |
storage.s3.public_base_url | Must be https |
A headless site’s delivery.frontend.origin, preview_url and frontend webhook URLs | Must be https |
engine-dmo check adds the mail rules, which a booting engine does not repeat:
| Setting | Rule in production |
|---|---|
mail.driver | Must be postmark, ses, resend or smtp; the default log driver only writes messages to the log |
mail.from | Required |
mail.postmark.webhook_token | Required with the postmark driver |
mail.resend.api_key, mail.resend.webhook_secret | Required 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:
| Form | Meaning |
|---|---|
${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:
| Path | Holds |
|---|---|
database.url | The PostgreSQL connection string, with its password |
server.security.signing_key | The install’s master key |
media.signing_key | The key media derivative addresses are signed with |
auth.staff.oidc.client_secret | The OpenID Connect client secret |
storage.s3.secret_access_key | The object store’s secret key |
mail.postmark.server_token, mail.postmark.webhook_token | Postmark |
mail.ses.secret_access_key | Amazon SES |
mail.resend.api_key, mail.resend.webhook_secret | Resend |
mail.smtp.password | The SMTP relay’s password |
search.meilisearch.api_key, search.meilisearch.password | Meilisearch |
search.elasticsearch.api_key, search.elasticsearch.password | Elasticsearch |
search.opensearch.api_key, search.opensearch.password | OpenSearch |
maps.key | The map tile provider’s key |
telemetry.errors.dsn | The error reporter’s DSN |
Every value under telemetry.otlp.headers | Headers 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_key | The 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 | Key | What it protects | Rotating it |
|---|---|---|
server.security.signing_key | CSRF 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_key | The addresses of image derivatives | Every derivative address changes, so cached pages and CDN copies point at addresses that are generated again on first request |
| Database, provider and API credentials | Access to that service | Rotate 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.
Related
- engine.yaml reference: every key, its type and default.
- Environment variables: the variables the engine itself reads and sets.
- Container images: passing the environment to a container.
Deploying
How a Meridian site runs in production, the parts of a deployment (engine, PostgreSQL, storage, optional search and mail), the deploy sequence, and which page covers each task.
Container images
The two published engine images (pure Go and -vips), their tags, what each contains, how to run one with your project mounted or baked into an image of your own, and the runtime constraints (non-root, no shell, read-only root).