Skip to content

Configuration Reference

Yerd stores all of its persistent state in a single TOML file: yerd.toml. This page documents where that file lives, every field in the schema, the defaults, how schema versioning and migration work, and how saves stay safe. Everything here is grounded in the yerd-config crate.

You rarely edit this by hand

The daemon (yerdd) owns yerd.toml. Day to day you change it through the CLI or the desktop app, and the daemon rewrites the file atomically. Hand-editing works too - Yerd parses and re-validates the file on every load - but the CLI is the safer path.

Where the config file lives

The file is always named yerd.toml and sits in your per-OS, user-owned config directory:

OSConfig directoryFull path
macOS~/Library/Application Support/io.yerd.Yerd~/Library/Application Support/io.yerd.Yerd/yerd.toml
Linux$XDG_CONFIG_HOME/yerd (default ~/.config/yerd)~/.config/yerd/yerd.toml

These paths come from yerd-platform's directory resolver, which uses the directories crate with the qualifier io / yerd / Yerd. The directory is created on demand the first time the daemon saves; it is not guaranteed to exist before then.

The daemon resolves the path once at startup and falls back to <config dir>/yerd.toml unless an explicit path was passed on the yerdd serve command line. If the file is absent, the daemon starts from the built-in defaults and writes the file on the first change.

Config vs. data vs. runtime

yerd.toml is the only file in the config directory. Certificates live in the data directory, logs in the cache directory, and the IPC socket in the runtime directory. See Architecture and The Daemon for the full layout.

Top-level schema

Every field below maps one-to-one to a field in schema.rs. The on-disk shape always begins with the version line, followed by the scalar keys, then the sub-tables.

KeyTOML typeMeaningDefault
versionintegerOn-disk schema version. Mandatory; written as 18 by this release.n/a (required)
tldstringTLD served by Yerd's resolver."test"
dns_portinteger (u16)Loopback port for the embedded .test DNS responder.1053
symlink_protectionbooleanRefuse to serve assets/scripts reached via a symlink resolving outside a site's document root.true
mcp_enabledbooleanServe Yerd's tools to local AI agents over MCP (yerd mcp).false
portstableHTTP / HTTPS listen ports.80 / 443
phptablePHP defaults and global ini settings.see below
parkedtableParked directory paths.empty
linkedarray of tablesExplicitly linked sites.empty
overridesarray of tablesPer-site overrides for parked sites.empty
servicestablePer-service [services.<id>] tables; every installed engine auto-starts on boot.empty
mailtableBuilt-in mail-capture SMTP server.on / 2525
dumpstableLaravel ▸ Dumps telemetry settings.off / 2304
domainstablePer-site domain sets (primary, aliases, subdomains, wildcards).empty

Unknown keys are rejected

The parser uses deny_unknown_fields at every level. A typo'd or stray key (top-level, or inside [ports], [php], [parked], [mail], [dumps], [dumps.features], [domains], a [domains.linked.<name>] / [domains.parked."<docroot>"] entry, [proxy_rules], a [[proxies]] entry, a [services.<id>] table, a [[linked]] entry, an [[overrides]] entry, or a [[php.extensions.<version>]] entry) is a hard parse error - the daemon will refuse to load the file rather than silently ignore it.

version

The schema version. This key is required - a missing version is a hard error (MissingVersion), and a non-integer or negative value is rejected (NonIntegerVersion). The current schema version is 18, and Yerd always writes version = 18. Older version = 1 through version = 17 files are migrated forward automatically on load. See Schema versioning below.

tld

The top-level domain Yerd's resolver answers for, without a leading dot. The default is test, giving you myapp.test. The value is validated by yerd-core: whitespace is rejected, and a trailing dot is silently stripped ("test." becomes "test"). See DNS & .test Domains.

dns_port

The loopback UDP/TCP port the embedded .test DNS responder binds to. The default is 1053. A fixed (non-ephemeral) port keeps the resolver configuration installed by yerd elevate resolver valid across daemon restarts. A value of 0 means "ephemeral" and is intended for development and tests only - it is not durable across restarts.

Port already in use?

If another process holds dns_port, the daemon fails to bind and tells you to change dns_port in yerd.toml or free the port.

By default (true) the proxy refuses to serve a static asset - or resolve a script - reached through a symlink whose target resolves outside the requested site's own document root, answering with an explicit 403. This is a safety guard: a symlink inside a site could otherwise point the server at arbitrary files elsewhere on the host.

Set it to false to allow those symlinks. The motivating case is a shared parent/child WordPress theme kept in its own directory beside your sites and symlinked into wp-content/themes/: with protection on, its assets 403; with protection off, they are served. The setting is global (all sites) and can be toggled from the desktop app under Settings › Security; the change takes effect immediately, without restarting the daemon.

Off trusts every in-tree symlink

While off, a symlink is followed wherever it resolves, not only within the parked folder. Combined with a public tunnel (yerd-tunnel), that can expose files beyond a site's root. Leave it on unless you specifically need a cross-directory symlink like the shared-theme layout above.

mcp_enabled

Whether yerd mcp serves Yerd's tools to local AI agents over the Model Context Protocol. Defaults to false: exposing Yerd to agents is an explicit opt-in, toggled from the desktop app under Settings › General › AI Agents.

The daemon runs no MCP server of its own - it stores this flag and reports it in its status. Each agent session runs a short-lived yerd mcp process that reads it, so turning it on reaches agent sessions already running (on their next tool call), while turning it off applies to sessions started afterwards. See the AI Agents guide.

Not a security boundary

The flag gates tool discovery, not access. Any process running as your user can already talk to Yerd's daemon through its socket - that is how the yerd CLI works - so turning this off does not isolate Yerd from local software.

[ports]

The HTTP and HTTPS listen ports for the proxy, plus the rootless ports the daemon falls back to when it can't bind the privileged ones.

KeyTOML typeMeaningDefault
httpinteger (u16)HTTP listen port.80
httpsinteger (u16)HTTPS listen port.443
fallback_httpinteger (u16)Rootless HTTP port the daemon drops to when http can't bind without elevation.8080
fallback_httpsinteger (u16)Rootless HTTPS port the daemon drops to when https can't bind without elevation.8443

The default is the IANA well-known pair 80 / 443. Binding these privileged ports may require elevation on macOS and Linux - see Elevation & Privileges. If you would rather avoid elevation, switch to the unprivileged fallback pair 8080 / 8443:

toml
[ports]
http = 8080
https = 8443

fallback_http and fallback_https are what the daemon binds instead of http/https when it starts in degraded mode - unable to acquire the privileged ports without elevation - so the proxy still comes up rather than failing to start. They're editable from the desktop app's Settings > Web ports card as well as by hand.

Validation rules (enforced by Config::validate): neither http nor https may be 0, and they must differ (HttpPortZero, HttpsPortZero, HttpHttpsPortsEqual). Both fallback ports must be >= 1024 - the fallback exists specifically to avoid needing elevation, so a privileged fallback is rejected (FallbackPortPrivileged) - and fallback_http/fallback_https must differ from each other (FallbackPortsEqual).

[php]

PHP defaults applied across sites.

KeyTOML typeMeaningDefault
defaultstringDefault PHP version for new sites (e.g. "8.3")."8.3"
settingstableGlobal PHP ini directives applied to every installed version's FPM pool.empty
version_settingstableSparse per-version overrides of settings, keyed by PHP version.empty
directivestableFree-form per-version ini directives, keyed by PHP version.empty
extensionstableCustom .so extensions to load, keyed by PHP version.empty

default is a MAJOR.MINOR version string validated by yerd-core's PhpVersion; an out-of-range minor or a non-numeric value is rejected. See PHP Versions.

[php.settings] is a string-to-string map of PHP ini directives written into every installed version's FPM pool. An empty map means "use PHP's defaults" and the table is omitted from the file entirely. Only an allowlisted set of directives is accepted, and every value is validated as a security boundary (no control characters, none of the FPM/ini metacharacters [ ] = ; #, length ≤ 256 bytes). The supported directives are:

DirectiveValue shape
memory_limitbyte size (512M); also accepts -1 for unlimited
max_execution_timenon-negative integer
max_input_timenon-negative integer
max_file_uploadsnon-negative integer
upload_max_filesizebyte size (64M)
post_max_sizebyte size (64M)
display_errorsboolean flag (On / Off, rendered as a php_flag)
error_reportinginteger or constant expression (e.g. E_ALL & ~E_DEPRECATED)
toml
[php.settings]
memory_limit = "512M"
max_execution_time = "300"
upload_max_filesize = "64M"

Setting an unsupported directive fails the load

An unknown directive name or a malformed value makes the whole config invalid (InvalidPhpSetting). Stick to the table above.

[php.version_settings."<version>"] (schema v16) holds per-version overrides of the same allowlisted settings, keyed by PHP version string. A version's effective value is its override when present, else the global [php.settings] value, else PHP's built-in default. Omitted entirely when no overrides are set.

[php.directives."<version>"] (schema v18) holds free-form ini directives per version - typically extension settings the allowlist doesn't cover (xdebug.mode, opcache.*, …). Names must start with a letter or _ and use only letters, digits, ., _, -; values follow the same injection rules as [php.settings] (no control characters or [ ] = ; #, ≤ 256 bytes). Directives Yerd manages through typed paths are reserved: the eight allowlisted settings, extension / zend_extension, and openssl.cafile / curl.cainfo.

toml
[php.version_settings."8.3"]
memory_limit = "1G"

[php.directives."8.3"]
"xdebug.mode" = "debug"

These two tables load leniently

Unlike [php.settings], a hand-edited invalid or reserved entry in version_settings / directives never fails the load - it is silently dropped while valid siblings survive, so a bad edit can't stop the daemon. Setting values through the CLI/GUI still validates strictly. A malformed version key (e.g. "eight") is still a hard error.

Manage these with yerd set php --only <version> and yerd php ini or the desktop app's Per-version configuration card.

[php.extensions] maps a PHP version string to an array of custom extensions to load into both that version's FPM pool and its CLI. It is written as an array-of-tables per version and omitted entirely when empty. Because a native .so is ABI-bound to a PHP minor, an entry only applies to the version it is keyed under.

FieldTOML typeMeaning
namestringRemoval/display handle (defaults to the .so basename when added).
pathstringAbsolute path to the .so. Validated as a security boundary: must be absolute, end in .so, and contain no control characters, NUL, ", or $ (spaces are allowed - the rendered ini value is double-quoted, and $ is rejected because PHP would interpolate ${VAR} inside it).
zendboolLoad as a zend_extension rather than a plain extension.
toml
[[php.extensions."8.5"]]
name = "scrypt"
path = "/opt/homebrew/lib/php/pecl/20250925/scrypt.so"
zend = false

Manage this with yerd php ext or the Extensions section of the desktop app's Per-version configuration card rather than editing by hand - the CLI/daemon load-probe each .so before saving. Names must be unique within a version; a duplicate or an invalid path makes the whole config invalid.

[parked]

Directories you have "parked" - every immediate subdirectory becomes a site served under <dirname>.<tld>. See Sites.

KeyTOML typeMeaningDefault
pathsarray of stringsParked directory paths.[]

Paths are stored verbatim as UTF-8 strings and are not canonicalised by the config layer - "/srv/foo" and "/srv/foo/" are distinct entries. They are kept in sorted order with no duplicates. An empty-string path is rejected (ParkedPathEmpty).

toml
[parked]
paths = ["/Users/you/Sites", "/Users/you/work"]

[[linked]]

Explicitly registered sites, each as its own array-of-tables entry. Order is preserved on round-trip.

KeyTOML typeMeaning
namestringSite name (the subdomain under your TLD).
document_rootstringPath to the site's project directory.
web_subpathstringServed web root, relative to document_root. Optional.
phpstringPHP version for this site (e.g. "8.3").
securebooleanWhether HTTPS is enabled for this site.
kindstring"linked" or "parked".

name, document_root, php, secure, and kind are required per entry. name, php, and kind are validated by yerd-core; for example an invalid site name like "FOO.BAR" is rejected. Linked site names must be unique - a duplicate produces DuplicateLinkedSite.

web_subpath is the directory actually served, relative to document_root (e.g. "public" for Laravel; empty/absent means "serve the document root itself"). It is optional and omitted from the file when empty, so a site served from its project root has no web_subpath line. It must be a plain relative path - an absolute path or one containing .. is rejected (WebRootEscapes) so a hand-edited value can never escape the project. Yerd normally sets this for you via framework detection; see Web root.

toml
[[linked]]
name = "api"
document_root = "/Users/you/projects/api"
web_subpath = "public"
php = "8.3"
secure = true
kind = "linked"

[[overrides]]

Per-site overrides for parked sites, each its own array-of-tables entry. A parked site is otherwise derived purely from a directory listing, so it has nowhere to persist a custom PHP version or HTTPS flag. Rather than promoting it to a linked site (which would change its kind), the daemon records the override here and re-applies it during the directory scan, leaving the site parked.

KeyTOML typeMeaning
pathstringThe parked site's document-root path. Required.
phpstringPinned PHP version. Omit to inherit the global default.
securebooleanPinned HTTPS flag. Omit to inherit (off).
web_rootstringPinned web root, relative to path. Omit to auto-detect.
front_controllerbooleanPinned front-controller mode. Omit to auto-derive from detection.

php, secure, web_root, and front_controller are all optional - omitting a key means "inherit" (or, for web_root/front_controller, "auto-derive on every scan"). An entry may pin one, several, or (uselessly) none. The serialiser skips omitted keys, so a partial override stays tidy on disk. Like web_subpath on a linked site, web_root must be a plain relative path inside the project (WebRootEscapes otherwise). Setting web_root is what yerd root <parked-site> <path> does; setting front_controller is what yerd front-controller <parked-site> on|off does.

front_controller = true funnels every request through the site-root index.php (the right behaviour for a single-front-controller framework such as Laravel or Symfony); false executes a named .php under the served root directly (classic multi-page PHP). When omitted, the mode is auto-derived: a framework served from a subdirectory (non-empty web_root/web_subpath) defaults to front-controller mode, while WordPress (any layout) and plain root-served sites default to direct execution. The same key is accepted inside a [[linked]] entry.

Direct execution exposes every .php in the served root

With direct execution (the default for a plain root-served site), any real .php file under the served root is URL-executable - including a stray phpinfo.php or a leftover admin tool. If the site is exposed beyond loopback via a tunnel, those files are remotely reachable. Set front_controller = true (or point web_root at a clean public directory) to funnel everything through index.php instead.

toml
# Pin PHP, HTTPS, and the served web root for one parked site...
[[overrides]]
path = "/Users/you/Sites/blog"
php = "8.4"
secure = true
web_root = "public"

# ...and only HTTPS for another (PHP and web root inherit / auto-detect).
[[overrides]]
path = "/Users/you/Sites/wiki"
secure = false

path must match byte-for-byte

The path key is the parked site's document-root string, stored byte-exact and never canonicalised - it must match exactly the path the daemon's directory scan produces. Do not canonicalise, trim, or add a trailing slash by hand, or the override won't be applied. An empty path is rejected (OverridePathEmpty).

[services.<id>]

Installed services, one table per engine, keyed by its id (mysql, mariadb, postgres, redis, or meilisearch). An unknown service id fails validation (UnknownService). See Services & Databases.

KeyTOML typeMeaningDefault
versionstringInstalled version this engine is pinned to.unset
portinteger (u16)Loopback port the engine listens on.unset
enabledbooleanRecord of the last start/stop intent (status only).true

version and port are omitted from the wire when unset; enabled always carries a value.

TIP

enabled no longer gates boot auto-start - the daemon auto-starts every installed engine regardless of this flag. A stop lasts only the current session; uninstall to keep an engine off. See Services & Databases.

toml
[services.mysql]
version = "8.4"
port = 3306
enabled = true

[services.redis]
version = "8"
port = 6379
enabled = true

You normally manage these through the yerd service commands rather than by hand.

[mail]

The built-in mail-capture SMTP server - a Herd-style sink that accepts mail on a loopback port and stores it for inspection in the desktop app. Capture is on by default.

KeyTOML typeMeaningDefault
enabledbooleanWhether the daemon starts the capture server on boot.true
portinteger (u16)Loopback port the capture server binds on 127.0.0.1.2525

When enabled the daemon binds port on 127.0.0.1; a busy port is non-fatal - the daemon logs and runs with capture not listening. Validation rejects port = 0 (MailPortZero).

Because the section's default (enabled, port 2525) is the common case, the serialiser omits [mail] entirely when it matches the default - so a default file has no [mail] table at all. The table is written only once a value differs from the default.

toml
[mail]
enabled = true
port = 2525

[dumps]

Telemetry settings for the Laravel ▸ Dumps feature. The dump server buffers per-request telemetry frames from the yerd-php-ext extension; this section is the durable source of truth (the daemon writes a runtime mirror the extension reads each request). Disabled by default.

KeyTOML typeMeaningDefault
enabledbooleanWhether dump interception is on (the "antenna").false
portinteger (u16)Loopback port the dump server listens on / the extension connects to.2304
persistbooleanWhen false, the buffer is cleared on each new request (latest-request view); true accumulates across requests.false
featurestablePer-feature capture toggles (see below).empty

Validation rejects port = 0 (DumpsPortZero).

[dumps.features] is a map of feature name → bool. The keys are dumps, queries, jobs, views, requests, logs, and cache. An absent key means "on", so the table only needs entries for features you have turned off. An empty map (every feature on) is omitted from the file, and so is the whole [dumps] table when it matches the default (disabled, port 2304, no overrides).

toml
[dumps]
enabled = true
port = 2304
persist = false

[dumps.features]
queries = false   # absent keys default to on; only the off ones need listing

[tunnel]

Persisted state for sharing sites through Cloudflare Tunnel. Two maps, both empty by default - the whole [tunnel] table is omitted from the file until you create a named tunnel or expose a site. Quick-tunnel state is never persisted (it lives only in the running daemon).

Sub-tableShapeMeaning
[tunnel.named]map name → uuidThe named tunnels created on your Cloudflare account.
[tunnel.sites]map site → hostnamePer-site public hostnames exposed through the named tunnel.

Validation rejects empty keys/values (TunnelEntryEmpty), a [tunnel.sites] hostname that isn't a plausible DNS name (TunnelHostnameInvalid), and any key or UUID containing path- or YAML-unsafe characters (TunnelKeyInvalid). The account certificate and per-tunnel credentials are not stored here - they live in a daemon-owned 0700 directory, never in the config file.

toml
[tunnel.named]
my-tunnel = "6ff42ae2-765d-4adf-8112-31c55c1551ef"

[tunnel.sites]
app = "app.example.com"

[groups]

User-defined site groups for the desktop app's Sites view. Purely an organisational overlay - groups do not affect routing. Both fields are empty by default, so the whole [groups] table is omitted from the file until you create a group.

KeyTOML typeMeaning
orderarray of stringsGroup display names, in display order.
memberstable (site → group)Per-site group membership, keyed by site name.

Membership is keyed by site name, not document-root, so a group applies to parked and linked sites alike without touching either site's own record. A site absent from members is "Unallocated" - the GUI's synthetic bucket for ungrouped sites, which is never itself persisted here.

Validation rules (enforced by Config::validate): every name in order must be non-empty (GroupNameEmpty) and unique, ASCII-case-insensitively (GroupDuplicate); the name Unallocated is reserved in any casing and rejected (GroupNameReserved); and every members value must reference a group present in order, also folding case (GroupMemberDangling). Whether a keyed site still exists is not checked - parked sites are discovered from disk on each scan and have no config record to check against.

toml
[groups]
order = ["Blog", "Shop"]

[groups.members]
api = "Blog"

[domains]

Per-site domain customization: the primary (canonical) domain plus any additional aliases, subdomains, and wildcards a site answers for. Empty by default - the whole [domains] table is omitted until you customise a site with yerd domain. An uncustomised site answers only its default apex <name>.<tld>; subdomains do not resolve implicitly.

The table is split by site class, mirroring [[overrides]]:

KeyTOML typeMeaning
[domains.linked]table (name → delta)Deltas for linked sites, keyed by site name.
[domains.parked]table (docroot → delta)Deltas for parked sites, keyed by byte-exact document-root.

Keying linked by name and parked by document-root matches [[overrides]], so routing survives a directory rename and a parked site keeps its domains without a config record of its own.

Each entry is a delta over the default apex, with three optional fields:

FieldTOML typeMeaning
addedarray of stringsExtra domains the site answers for (exact or single-label wildcard).
suppressedarray of stringsDefault domains to drop (only ever the apex).
primarystringThe canonical domain (must be exact, never a wildcard).

Values are stored as sub-parts - the part left of the TLD, exactly as the router matches - not full FQDNs. Under TLD test, corp.test is stored as corp, *.blog.test as *.blog, and the apex blog.test as blog. A leftmost * is a single-label wildcard (*.blog matches api.blog.test, never x.api.blog.test).

Config::validate enforces only structural rules (no duplicate added; added and suppressed disjoint; primary not a wildcard). TLD membership, cross-site uniqueness, and "keep at least one exact domain" are enforced by the daemon, which alone can see parked sites on disk.

toml
[domains.linked.blog]
added = ["corp", "*.blog"]
suppressed = ["blog"]
primary = "corp"

[domains.parked."/Users/me/Sites/shop"]
added = ["shop-staging"]

[[proxies]]

Whole-host reverse proxies - a <name>.<tld> host forwarded wholesale to a running service, with no PHP or document root. Empty by default (the array is omitted from the file) until you add one with yerd proxy. Order is preserved on round-trip.

FieldTOML typeMeaning
namestringThe proxy's DNS label; it answers on <name>.<tld>.
targetstringThe upstream URL, http://host:port or https://host:port.
secureboolWhether the proxy is served over HTTPS (toggled by yerd secure).
toml
[[proxies]]
name = "reverb"
target = "http://127.0.0.1:8080"
secure = true

[proxy_rules]

Per-site path-prefix proxy rules - a path on an existing site forwarded to a service while every other path is served by PHP. Empty by default (the whole table is omitted) until you add a rule. Split by site class exactly like [domains]: linked rules key by site name, parked rules by byte-exact document-root, so routing survives a directory rename.

KeyTOML typeMeaning
[proxy_rules.linked]table (name → array of rules)Rules for linked sites, keyed by name.
[proxy_rules.parked]table (docroot → array)Rules for parked sites, keyed by docroot.

Each rule is a { prefix, target } table. Removing a site's last rule drops its key entirely, so the file round-trips byte-identically.

toml
[[proxy_rules.linked.myapp]]
prefix = "/app"
target = "http://127.0.0.1:8080"

Config::validate enforces the structural rules it can see (proxy names unique among proxies and against linked sites; a site's rule prefixes unique; no target pointing at a .<tld> host). Collisions with parked sites and the loopback-on-own-port loop guard are enforced by the daemon, which alone knows the actively bound ports and sees parked sites on disk.

Schema versioning and migration

Every config file must carry a top-level version = N key - it is the single trigger for forward migration. The current schema version is 18.

When the daemon loads a file, it routes on the version it finds:

text
found  > CURRENT (18)   →  error (UnsupportedVersion) - a newer Yerd wrote this file
found == CURRENT (18)   →  parse directly
found  < CURRENT (18)   →  walk forward migration steps, then parse

A file written by a newer Yerd than you are running is refused rather than misread. Older files are migrated forward in place, one version at a time, before the normal wire-deserialisation and validation run:

  • v1 → v2 is a bare version bump: v2 only added the optional web_subpath ([[linked]]) and web_root ([[overrides]]) keys, which default when absent, so a v1 file needs no structural rewrite.
  • v2 → v3 is the first structural migration: it rewrites the old [services] shape (a flat enabled = ["redis", ...] array of identifiers) into per-service [services.<id>] tables, carrying each previously-enabled id forward as an enabled = true instance.
  • v3 → v4 is a bare version bump: v4 only added the optional [mail] section, which defaults when absent, so a v3 file needs no structural rewrite. The bump exists so an older binary rejects a file using [mail] cleanly as UnsupportedVersion rather than failing on the unknown table.
  • v4 → v5 is likewise a bare version bump: v5 only added the optional [dumps] table, which defaults when absent. Same rationale - the bump lets an older binary refuse a [dumps]-bearing file cleanly instead of tripping deny_unknown_fields.
  • v5 → v6 is a bare version bump: v6 only added the top-level update_channel scalar (defaults to "stable" when absent).
  • v6 → v7 is a bare version bump: v7 only added the [ports] fallback_http / fallback_https keys (defaulting to 8080 / 8443).
  • v7 → v8 is a bare version bump: v8 only added the optional [tunnel] table, which defaults to empty when absent. Same rationale - the bump lets an older binary refuse a [tunnel]-bearing file cleanly rather than tripping deny_unknown_fields.
  • v8 → v9 is a bare version bump: v9 only added the optional [groups] table, which defaults to empty when absent. Same rationale - the bump lets an older binary refuse a [groups]-bearing file cleanly rather than tripping deny_unknown_fields.
  • v9 → v10 is a bare version bump: v10 added the optional [php.extensions] registry and the wp_auto_login / wp_auto_login_user keys (inside [[linked]] and [[overrides]], for one-click WordPress admin login), all of which default when absent. Same rationale - the bump lets an older binary refuse a file using them cleanly rather than tripping deny_unknown_fields.
  • v10 → v11 is a bare version bump: v11 only added the optional [domains] table (per-site domain sets), which defaults to empty when absent. Same rationale - the bump lets an older binary refuse a [domains]-bearing file cleanly rather than tripping deny_unknown_fields. No existing keys change, so a v10 file needs no structural rewrite.
  • v11 → v12 is a bare version bump: v12 only added the top-level symlink_protection scalar (defaults to true when absent).
  • v12 → v13 is a bare version bump: v13 only added the optional per-site front_controller key (inside [[linked]] and [[overrides]]), which defaults to auto when absent.
  • v13 → v14 is a bare version bump: v14 only added the optional [[proxies]] array and [proxy_rules] table (reverse proxies and per-site path rules), both of which default to empty when absent. Same rationale - the bump lets an older binary refuse a proxy-bearing file cleanly rather than tripping deny_unknown_fields.
  • v14 → v15 is the multi-instance services rework: v15 added the optional per-instance site field and the "{type}:{site}" wire ids (both additive), and made the enabled flag actually gate boot autostart. The migration marks every existing single-instance engine enabled = true so previously-installed engines keep starting with Yerd across the upgrade.
  • v15 → v16 is a bare version bump: v16 only added the optional [php.version_settings] table (per-version overrides of the global PHP settings), which defaults to empty when absent.
  • v16 → v17 is a bare version bump: v17 only added the top-level mcp_enabled scalar (defaults to false when absent).
  • v17 → v18 is a bare version bump: v18 only added the optional [php.directives] table (free-form per-version ini directives), which defaults to empty when absent.

The on-disk schema version is deliberately decoupled from the IPC protocol version; the two evolve independently.

Downgrades are refused, not misread

Because later versions changed shapes the parser checks strictly (keys inside [[linked]] / [[overrides]] in v2, and the whole [services] shape in v3), an older daemon reading a newer file would fail. The version routing turns that into a clean UnsupportedVersion error instead - there is no automatic backward migration, but it fails loudly rather than corrupting state. See Config Schema History for the exact manual edits to hand-downgrade a file version by version.

Forward-compatible by design

The parser tolerates older shapes: a v1 file written before web_subpath/web_root existed migrates to v2 and parses fine (the new fields default). New optional fields are added additively, so upgrades don't break your existing config.

Atomic saves

Saves are atomic. The daemon serialises the config, writes it to a temporary file in the same directory, then renames it over yerd.toml. Because the temp file lives on the same filesystem as the destination, the rename is atomic on Unix - a reader never sees a half-written file, and a crash mid-save leaves the previous config intact. On failure the temp file is cleaned up automatically, so no orphan files are left behind.

On Unix the file is created with mode 0600 (owner read/write only): the daemon is the only intended writer. Intermediate parent directories are created as needed.

Durability trade-off

Yerd does not fsync the file or its parent directory after a save. For a developer-only config file the portability cost outweighs the durability gain, so a loss under sudden power loss is accepted by design.

A complete annotated example

This is a valid yerd.toml covering the core fields (see the sections above for the newer optional tables - update_channel, [tunnel], [groups], [php.extensions], [domains], [[proxies]], [proxy_rules], wp_auto_login - omitted here for brevity):

toml
# Schema version - mandatory, always written as 18 by this release.
version = 18

# TLD served by the resolver; sites resolve as <name>.test
tld = "test"

# Loopback port for the embedded .test DNS responder (default 1053).
dns_port = 1053

# Proxy listen ports. Defaults are 80 / 443 (may need elevation).
# Swap for the rootless 8080 / 8443 pair to avoid privileged binds.
[ports]
http = 80
https = 443

[php]
# Default PHP version applied to new sites.
default = "8.3"

# Global ini directives written into every installed version's FPM pool.
# Allowlisted directives only; values are validated as a security boundary.
[php.settings]
memory_limit = "512M"
upload_max_filesize = "64M"
post_max_size = "64M"

# Parked directories: each immediate subdirectory becomes a site.
# Paths are stored verbatim and are NOT canonicalised.
[parked]
paths = ["/Users/you/Sites"]

# Explicitly linked sites (order preserved). web_subpath is optional (the
# served web root relative to document_root; omitted when the root is served).
[[linked]]
name = "api"
document_root = "/Users/you/projects/api"
web_subpath = "public"
php = "8.3"
secure = true
kind = "linked"

# Per-site overrides for PARKED sites, keyed by exact document-root path.
# Omit php / secure / web_root to inherit / auto-detect. `path` must match the
# scan byte-for-byte.
[[overrides]]
path = "/Users/you/Sites/blog"
php = "8.4"
secure = true
web_root = "public"

# Installed services, one table per engine.
# Known ids: mysql, mariadb, postgres, redis, meilisearch. Usually managed via `yerd service`.
[services.redis]
version = "8"
port = 6379
enabled = true

# Built-in mail-capture SMTP server. ON by default - this table is written only
# when a value differs from the default (enabled, port 2525); a default config
# omits [mail] entirely. Shown here for completeness.
[mail]
enabled = true
port = 2525

# Laravel ▸ Dumps telemetry. OFF by default - omitted from a default file. When
# present, absent [dumps.features] keys default to ON, so only disabled features
# need listing.
[dumps]
enabled = true
port = 2304
persist = false

[dumps.features]
queries = false

A Forjed project. Released under the MIT License.