cwp.yml
shipped 1.0.0The project layer, committed, maintained by cwp init and
cwp config set rather than by hand.
Environments and the two-layer config is the
argument; this is the list.
version: 1
name: example
docroot: public
php: "8.3" # also written to .ddev/config.yaml
plugin_slug: example-site # site plugin dir; default <name>-site
default_environment: local # where a command with no environment goes
environments:
local: # the site on this machine
provider: ddev # served by DDEV; no url, .ddev/config.yaml has it
dev:
cloudron_app: example.com # the Cloudron app location
url: https://example.com
cloudron_package: managed # Cloudron's layout: managed | developer
mode: managed # managed | protected | source — see below
prod:
cloudron_app: www.example.com
url: https://www.example.com
cloudron_package: managed
mode: protected # blocks upward writes without --force
vps: # a plain host over ssh
provider: ssh
ssh: deploy@example.com # what you would type after `ssh`
path: /var/www/example # the docroot on that host
url: https://example.com
mode: protected
tracked: # the paths push and deploy send upward
- wp-content/plugins/example-site
pull:
uploads: proxy # sync | skip | proxy
scrub: true
keep_admin_email: you@example.com # this user survives scrubbing
keep_roles: # these roles survive scrubbing
- administrator
- editor
keep_logins: # these people do too, by name, and travel
- naima # in content/authors.yml
truncate_tables: # tables the scrub empties beside the ones
- my_plugin_log # the page builder declares as its logs,
# without the table prefix. A name that does
# not exist is reported as absent, never an error.
truncate_post_types: # post types whose posts the scrub deletes
- snn_404_logs
- snn_redirect_logs
- snn_activity_log
- snn_mail_logs
- snn_search_logs
- snn-agent-history
truncate_taxonomies: # taxonomies whose terms the scrub deletes
- snn_ip_address # the IPs live here, not in postmeta
- snn_user_agent
builder:
id: bricks # none | bricks
options:
child_theme: example-child
export_dir: bricks
wp_user: 1 # optional — who ability calls run as
content: # optional; absent means pages do not move
dir: content/
post_types: [page, post]
meta:
include: ["_bricks_*", "_thumbnail_id", "_wp_page_template"]
exclude: ["_edit_lock", "_edit_last"]
attachments: ["_thumbnail_id"] # values that are attachment ids
widgets: false # false | true | [sidebar-1, footer-1]
# true carries every registered area
settings: # optional; groups of options, allowlisted
general:
keys: [blogname, blogdescription]
features: # optional; families switched on, <owner>/<family>
example-child/seo: on # on | settings-only | off
access: # optional; absent means availability is unmanaged
local: open # open | maintenance | coming_soon
dev: coming_soon
prod: open
commit: manual # manual | auto — whether cwp commits its
# own writes for you
media:
tracked: none # none | referenced | library —
# which of the site's images the tree holds
max_files: 200 # the capture stops here and says so
max_megabytes: 50 # small on purpose — see below
scope: # written by `cwp adopt`, not by you
post_types: [page, post]
taxonomies: [category, post_tag]
menus: [primary]
widget_areas: [sidebar-1]
roles: [administrator, editor]
setting_keys: [blogname]
plugins: [example-plugin]
themes: [bricks]
locales: [de_DE]
derived_at: "2026-08-23T10:00:00.000Z"
derived_from: prod
provenance: true # leave a record of each push on the site
hooks_dir: cwp/hooks
The keys worth an explanation
The sections below run in the order the keys matter, not the order the file writes them. First what cwp may do to an environment, then what the tree carries, then how a crossing behaves, then what cwp derives for itself. The block above holds every key the schema accepts. The sections below cover the ones that need a paragraph.
mode
Says what this project may do to an environment. Three values, from most permission to least:
| value | what it means |
|---|---|
managed | the default. A partner: adopted, two-way, guarded by a restore point and a confirmation. |
protected | a partner whose upward writes are refused without --force. |
source | read-only. Never a target for push, deploy, adopt or access. |
source has no flag that opens it. That separates it from protected.
protected refuses until you pass --force: the site is one you own. cwp
refuses a source before the guard ladder runs at all, so there is nothing
for a flag to pass.
Know the trade before you rely on it. Adoption does not apply to a source.
A capture from one carries the items and not the identities, so cwp cannot
tell you next week that a renamed page is the same page.
An environment has a mode holds the whole
argument.
stage
Where an environment stands in its life: build, launch, live or
handover. Optional, per environment, and local is always build. The
guard ladder asks whether the project may write; the stage asks whether the
target is ready for it. In launch and live three checks stop a push
instead of warning after it: the sandbox captcha key, the mail guard on the
environment, the scrub’s placeholder accounts on it. No flag passes that.
The line names the fix, or stage: build while the site is not live.
An environment without stage: has the guard ladder and no stage check;
cwp status says so once. cwp launch is the
transition that sets live after its checks pass.
default_environment
Where a command goes when you name no environment. cwp push and
cwp push local are then the same command. cwp push prod names a different
target.
cwp init writes local, so a fresh project’s unnamed commands mean the site
you are standing in. If you push upward all day, point it at that environment
once and stop typing it.
It has to name an environment the file declares. cwp refuses a value that names nothing when it reads the file. A project may declare no default: an unnamed command there refuses and tells you the key.
cwp does not guess it. A project with one environment and no default
still refuses a bare cwp push. An omitted argument has one meaning on this
surface.
provider
Defaults to cloudron, so a file that never mentions it keeps working. The
four shapes need different facts, and the schema accepts only the set that
belongs to the shape. cloudron_package means nothing on a plain host. There
path is the docroot, and cwp has no way to derive it.
ftps and sftp mean shared hosting: an account that moves files and runs
nothing. They take host, user, path, url, and optionally port and
host_key:
environments:
live:
provider: sftp
host: www280.example.net
user: sitep
path: /public_html/site
url: https://example.com
host_key: "SHA256:…"
There is no password key, and cwp refuses one you add. Git tracks this
file. cwp reads the password from ~/.netrc for that host, or from
CWP_<ENVIRONMENT>_PASSWORD. Choose your host
says what such a host can and cannot do.
ddev is the site on this machine. It carries no url and no host.
.ddev/config.yaml already says the name, and cwp asks DDEV rather than
keeping a second copy that can disagree.
provider says how cwp reaches a site. The key says which site it is. One
key is a name rather than a label you choose: an environment called local is
the site you are standing in. The name decides that, not the provider, so a
local site that something other than DDEV serves one day is still your local
site. Everything that compares two sides reads it: cwp doctor against local does not compare that site with itself, and does not
survey it twice.
Every project declares it (ADR-028). The tree needs a counterpart on this
machine to rebuild into, and that counterpart has a name: cwp push local
turns a clean checkout into a running site. cwp refuses a cwp.yml without
the entry and names the two lines to add; cwp init writes them into a file
that lacks them. A repository that pushes to a remote and installs nothing
here still carries the entry. cwp does not touch the container until a
command names it.
cloudron_package
Picks between Cloudron’s two known layouts. cwp derives the remote
wp-content path from it rather than storing it: managed →
/app/data/wp-content, developer → /app/data/public/wp-content. cwp doctor
verifies the derivation against the real remote path.
The prefix says whose word managed is here. It is Cloudron’s name for an
application layout. mode: managed one line above is cwp’s name for
a relationship. The two have nothing to do with each other. An ssh
environment has no cloudron_package at all.
content
May also be a map of named groups. A custom post type wants that: its own
directory and usually its own meta rules. See
cwp content.
A group is its post_types. dir only says where it keeps its files, and
several groups may name the same one. The tree then stays
content/<type>/<slug>.yml while each type gets meta rules of its own. dir
defaults to the group’s name.
A post type no group declares travels in neither direction.
cwp coverage says which types are outside
management.
author
Set on an environment, naming one login. Every item pushed there lands under that account, whatever the tree says wrote it:
environments:
staging:
provider: ssh
ssh: deploy@staging.example.com
path: /var/www/staging
url: https://staging.example.com
author: deploy
The key suits a target that has no editorial staff: a staging site, a demo. Nothing is lost: the tree keeps the real authors, and a push to an environment without the key puts them back.
Upward only. There is no downward form. A tree that collapsed its authors on the way in could never say who wrote anything. Without the key the item’s own login travels, and the target has to resolve it.
A pull from an environment with the key set carries no authorship at all. It
keeps the author each item already has in the tree and leaves
content/authors.yml alone. Otherwise the tree would learn from the site that
one account wrote everything.
Taking the key away again is a change to that environment. The next run
reports it as one. The site holds the collapsed login and the tree holds
somebody else, so those items read as changed there and go back with
--overwrite-conflicts. An item the tree carries no author for stays where the
collapse put it: the tree has no opinion to write.
pull.keep_logins
Names people who keep their real data through the scrub and travel in
content/authors.yml, whatever role they hold.
Authorship already carries a person. Whoever wrote a post cwp manages is in that file and survives the scrub: an anonymised author is an author nothing can resolve on the other side. This key is for the editor who has published nothing yet.
Neither touches the customer database. Authorship, keep_roles and this list
bound what travels. The tree says what that means
for a repository.
content.<group>.taxonomies
Names the taxonomies whose terms travel as files. You declare them; cwp never discovers them. A project’s taxonomies can include ones that store visitor IPs as terms, and discovery would commit personal data.
content.<group>.menus
Does the same for navigation menus. true carries every menu the site has, a
list carries the named slugs:
content:
post_types: [page]
menus: [primary, footer]
Menus land in <dir>/menus/<slug>.json, one file per menu, and travel with
--all in both directions. When cwp reads the file it refuses three
configurations, each one managing one thing twice. The three: nav_menu under
taxonomies: beside menus:, nav_menu_item under post_types:, and
menus: on more than one group. cwp doctor catches the fourth: carrying
menus while the scrub truncates nav_menu or nav_menu_item.
content.<group>.meta.attachments
Names the meta keys whose value is an attachment id. Those travel as
media:<upload path>, the same form an image inside a builder payload already
uses. The featured image then resolves to the target’s own copy instead of
pointing at whatever holds that number there. _thumbnail_id is the default
and usually the whole list.
You declare them; nothing can infer them. A bare string holding an integer
looks the same whatever the key means. Remapping a post id into an attachment
id would be worse than leaving it alone. meta.include has to
carry a key listed here too, or there is no value to rewrite.
settings
Names the WordPress options this project carries as committed files, group by
group. It is an allowlist, and there is no “everything” spelling. A dump of
wp_options commits secrets, caches and every plugin’s version marker, and
produces a diff nobody reads.
settings:
core:
preset: wordpress-settings
frontpage:
preset: wordpress-pages
shop:
keys: ["woocommerce_*"]
exclude: ["woocommerce_debug_*"]
stats:
keys: [stats_settings]
hold: [stats_settings.geoip_license_key]
local-only:
keys: [blog_public, admin_email]
environments: [local]
settings.<group>.hold
Paths inside an option whose value is the target’s own, as
<option>.<key>[.<key>]. The pull leaves the path out of the file and records
it under held:. The push writes the option with the target’s value at it,
and invents nothing for a path the target does not hold. The option before the
first dot has to be one the group claims; cwp refuses a path into an array.
It is not exclude:, which takes a whole option out of the group in both
directions.
settings.<group>.classify
What a path inside an option is, <option>.<key>[.<key>] to one word:
setting, environment, secret, log, derived, attachment, post,
term or user. setting is the default and travels. The four after it stay
the target’s own, exactly as a held path does. The file records the path
under withheld: with its class. attachment, post and term name an id
that means a different thing on every environment. Such an id travels as the
identity the tree already knows: an attachment as { kind: attachment, path },
a post as { kind: post, type, slug, uid }, a term as
{ kind: term, taxonomy, slug }. The push resolves it on the target. An id
the site cannot describe stays the number it is. An identity the target
cannot resolve keeps the target’s own value at that path, never a zero. The
push names it. user is a person and stays on the target until its first
case decides.
* in a path matches every key of an object and every index of a list at
that level: ex_role_pages.*.* reaches the ids inside a map of lists. An
entry without a dot classifies the whole option. ex_default_image: attachment makes a scalar option travel as an identity. ex_redirect_url: environment leaves one on the target and records it under withheld: by
name, where exclude: would have dropped it without a word.
settings:
smtp:
keys: [custom_smtp_settings]
classify:
custom_smtp_settings.smtp_password: secret
custom_smtp_settings.smtp_host: environment
custom_smtp_settings.smtp_username: environment
hold: is classify: with environment and no word. A class does not outrank
the deny list. setting on a path whose last key the list refuses fails when
cwp reads the file.
settings.<group>.before
content puts the group before the content of a full push. A full push
sends content before settings: wordpress-pages holds post ids the
content delivers. A group a plugin registers post types, taxonomies or fields
from needs the opposite. The posts of those types then land on a target that
knows the type, and a wrong schema fails before the content lies there.
settings:
ex-schema:
keys: [ex_custom_post_types, ex_taxonomies, ex_custom_fields]
before: content
The push shows the two halves as settings (schema) before the content and
settings after it. cwp push --only settings sends both. A family’s
before: content writes this field on the group it expands into.
settings.<group>.preset
Names a list cwp keeps current, so the twenty-three core option keys need no
copy in every project. wordpress-settings is the options behind the Settings
screens. wordpress-pages is the ones holding a post ID; it brings
as: post-reference with it. A keys: list beside a preset extends it.
settings.<group>.environments
Scopes a group, exactly as it scopes an inventory item. Absent means
everywhere. A setting is not always one value for a project: blog_public is
0 on staging and 1 in production. cwp skips a group scoped elsewhere in both
directions rather than blanking it. Two groups on different environments may
name the same key, so one can override the other.
* is the only wildcard. as: post-reference is for options holding a post ID:
the file stores the page’s identity and the push resolves it on the target.
cwp coverage with --suggest writes this block out
for review, with the counts that justify each line.
cwp refuses three declarations when it reads the file. Each would carry
something twice, or carry something that must never reach a commit. The
three: a literal key on the deny list (credentials, caches, active_plugins);
one key two groups claim; a key the page builder’s own tree already carries.
A key a glob happens to match on one site is different: the pull drops it
and names it in the output. Nobody can judge a glob before there is a site to
expand it against.
features
Switches a family on: <owner>/<family> to on, settings-only or off.
A family is one feature of a plugin or theme with every place it keeps
state, declared once in a family file and read from there. on carries every
member. settings-only carries the options and nothing that is content or
log. off and absence are the same thing.
features:
example-child/seo: on
example-child/redirects: on
example-child/logs: settings-only
When cwp reads the file, a family expands into the groups the rest of this
page describes. A settings group named <owner>/<family>, whose file lands
under settings/<owner>/<family>.yml. A content group of the same name. Meta
the feature writes on other groups’ posts and terms. The post types,
taxonomies and tables the scrub deletes. The file stays as you wrote it; the
expansion lives in the parsed config.
The owner is a slug inventory.yml lists. cwp ships a family file per owner
it has read against a live install: snn-brx-child-theme with seventeen
families, and akismet, brickslabs-bricks-navigator, smtp-mailer and
wp-statistics with one or two each. A project adds its own under
cwp/families/<name>.yaml; a project file for an owner cwp knows replaces
cwp’s whole. cwp coverage lists the families available to a project. cwp
ships no family for a plugin nobody has run against a live install. A file
names the owner and the lowest version it holds at, and one family per
feature:
owner:
theme: example-child
min_version: "0.290"
families:
seo:
toggle: ex_seo_enabled
options:
keys: ["ex_seo_*"]
classify:
ex_seo_default_image: attachment
post_meta: ["_ex_seo_*"]
term_meta: ["_ex_seo_*"]
asserts:
- { option: builder_settings, path: general.disableSeo, value: true }
settle: [flush-rewrites]
redirects:
content:
post_type: ex_redirect
dir: redirects
meta: [redirect_from, redirect_to]
refuse:
post_meta: [redirect_clicks]
post_types: [ex_redirect_log]
settle:
- { transient: ex_all_redirects }
Seven kinds of member, each optional:
toggle.options, as a settings group:keys,exclude,hold,classify.content: a post type or several,taxonomies,dir,meta,attachments,term_meta.post_metaandterm_metafor what the feature writes on other groups.assertsfor a value another artifact has to hold.settlefor what the target needs after the data has landed.refusefor what never travels.
cwp checks an assert and never writes it: a push refuses when the target does
not hold the value, and a pull says so. A settle step is an effect of the
push, run after the artifacts and before the ref. Five forms: flush-rewrites,
{ transient: <name> }, { ability: <name> }, { wp: [<argv>] }, or
{ meta_default: { post_type, key, value } }. The last writes the value on
every post of the type that has none.
cwp refuses six things when it reads the file, each with the fix:
- an id that is not
<owner>/<family> - an owner no family file knows
- an owner
inventory.ymldoes not list - a version below the file’s floor
- a family the file does not have
- a key or post type a family and a hand-written group both claim
widgets
Names the widget areas this project carries. true carries every area holding
something, a list carries the named ids. false, the default, carries none.
You declare them; cwp does not discover them. A live site’s only widget area
held WordPress’s five default block widgets, untouched, and carrying every
non-empty area would have committed those into every project.
cwp coverage says which areas hold anything.
builder.id
Names the page builder rather than switching one on. none is plain
WordPress and a supported configuration. cwp refuses the older
bricks: { enabled } block and names the replacement in the fix hint.
builder.options.wp_user
Is who cwp bricks,
cwp ability and the builder checks in cwp doctor
run as. Every ability tests the current user and WP-CLI has none, so cwp needs
one. Leave this unset and it uses the first administrator. That is right on a
one-admin site and arbitrary on any other. It takes an id, a login or an
email. A site with no administrator and no wp_user is an error with a fix
hint, not a silent fallback.
commit
Decides whether cwp commits its own writes. manual is the default. git pull
does not commit for you, it refuses, and a cwp that auto-commits stops behaving
like the verb it borrowed. The warning about a dirty tree is the moment you
learn your tree has diverged. Committing for you would hide that and make it
permanent.
media.tracked
Which of the site’s images the tree holds. Three answers, one per thing people want:
none | None of them. The default. cwp media pull and pull.uploads bring the library to your machine without committing any of it. |
referenced | The files your pulled content points at: a featured image, a picture in a page. Enough to rebuild the site’s pages from the tree. |
library | Every attachment the site holds, linked or not. |
none is the default and the default is the safety. git cannot
delta-compress binaries: it stores every version whole and forever, and nobody
can repair a history afterwards. Turning it on decides that your imagery
belongs in the repository. That buys a working local site with no production
database on it (Working without a database).
referenced carries what the pages need, and nothing else. An image sitting
in the library that no page links to does not travel. The tree exists to
rebuild the site, and an unreferenced file is not part of any page.
library is for the small site whose pictures are the site. Twenty or
thirty images, most of them small, where “nothing links to it today” is not the
same as “nobody wants it”. cwp init mentions git-LFS when it sees this value
and configures nothing. Whether LFS is right depends on your host, and a clone
without the extension gets pointer files instead of pictures. GitLab has it on
by default.
max_files and max_megabytes are the brake for all three. Without LFS they are
the only one.
media.max_files
Stops the capture after this many files, default 200. It is a count of files this run brought down, not a total the tree may hold.
media.max_megabytes
Stops it after this many megabytes, default 50.
Both are small on purpose. cwp cannot check either ahead of time: no provider reports a remote file’s size, so cwp counts the budget as files land. A capture that reaches either names the files it left rather than failing or silently carrying less than you think:
! 3 file(s) left on the site — 5 files exceeds the 2-file budget:
2026/08/hero.png, 2026/08/banner-1200x600-1.png, 2026/08/logo.png. Raise
`media.max_files` or `media.max_megabytes`, or leave them there: git stores
every version of a binary whole and forever
Five names at most, and the rest as a count: and 4 more. You cannot work out
afterwards which files stayed behind: the capture takes them in the order the
site returns them.
Leaving them there is a real answer, and the line says so. A tree that carries the pages and not every picture is a working tree. A repository that carries four hundred megabytes of photographs is one nobody can clone.
allow_unfiltered_write
Decides who wins when the host filters what cwp writes. Off by default.
On a host defining DISALLOW_UNFILTERED_HTML, Cloudron among them, no user
holds unfiltered_html, so WordPress runs its content filter on every write.
It escapes (> becomes >) and it strips (a <script>, a disallowed
attribute). By default cwp restores the escaped text after the write and
refuses a write the filter would strip, naming what would go.
true stands that filter down for the length of each content write instead:
allow_unfiltered_write: true
The filter then escapes nothing and strips nothing, including markup the host
meant to remove. That is a legitimate choice for a project whose tree gets a
review. It is also somebody else’s security decision switched off, so cwp
keeps it visible rather than convenient. The window is one write. cwp undoes
both halves before the meta writes that follow. Every affected write says so
in the run. cwp doctor reports it as a finding for
as long as the key holds true.
The page builder’s own tree stays the same either way: cwp restores what Bricks escapes after its write.
provenance
Decides whether an upward write leaves a record on the environment it wrote
to. The record says when, which command, how much, from which commit and
whether the tree was dirty. It also installs an mu-plugin,
cwp-provenance.php, that shows the record as a dashboard widget to anyone
with manage_options. It is on by default: the person asking the question
sits in wp-admin with no terminal and no checkout.
The option keeps the last twenty pushes, newest first. The widget renders the most recent in full and the ones before it as dates and commands. That answers the second question that person has, whether this was the deploy from Tuesday or the one that stopped halfway, without crowding out the first. Twenty small records is a few kilobytes and never grows.
false writes neither and is the setting for a project that does not want cwp
installing a file into its site. It removes nothing that is already there: delete
cwp-provenance.php from mu-plugins yourself, and cwp will not put it back
while the key is false.
perimeter and team
perimeter: declares who comes through which door of each environment, and
after which check. The key is the same identity the rest of the file uses:
local for the DDEV site, otherwise the environment’s name. A string is a
level: open, basic, challenge, identity, team. A block names the
doors one by one: site, login, admin, api, xmlrpc, origin. team:
lists who works on the site, for the identity and team checks:
@example.com for a domain, otherwise an address. cwp leaves an environment
with no entry unmanaged and never writes it.
cwp perimeter is the only writer, and its page
has every door and value.
access
The old spelling of the site door, kept for one minor cycle. open,
maintenance (HTTP 503) or coming_soon (HTTP 200) per environment. cwp
reads it as perimeter.<env>.site and refuses a file that says both for one
environment. cwp access applies it and runs
cwp perimeter underneath.
scope
cwp adopt writes it and everything else reads it;
you do not maintain it. It is what “everything under management” means for
this project, derived from what the site registers rather than guessed.
Absent means never derived, a different state from empty. cwp reports
an environment that registers something the scope does not name, and never
adopts it quietly. cwp adopt <env> --rescope proposes the change as a diff.
The reason beside a line
Any key may carry a comment line above it in one grammar, fields separated
by |, every field optional:
# because: Bricks checks is_file(), B-147 | until: cwp:B-147 | since: 2026-08-28 | by: hand
uploads: sync
because is prose. since is a date. by is hand or the command that
wrote the line. until says when the reason is spent, in one of five forms:
cwp:B-<n>, cwp>=<version>, plugin:<slug>>=<version>, stage:<stage>,
or a date. cwp config set writes the line with
--because and --until; cwp status and cwp doctor name every reason
that no longer holds. A comment of your own above the key stays where it is.
Keys cwp maintains for itself
cwp config set refuses version, environments, projects and
capabilities, and names the command to use instead. It refuses content.…
too: the group keys are yours, and a dotted-path setter has no way to tell a
group name from a typo.