Skip to content

cwp.yml

shipped 1.0.0

The 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:

valuewhat it means
managedthe default. A partner: adopted, two-way, guarded by a restore point and a confirmation.
protecteda partner whose upward writes are refused without --force.
sourceread-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_meta and term_meta for what the feature writes on other groups.
  • asserts for a value another artifact has to hold.
  • settle for what the target needs after the data has landed.
  • refuse for 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.yml does 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:

noneNone of them. The default. cwp media pull and pull.uploads bring the library to your machine without committing any of it.
referencedThe files your pulled content points at: a featured image, a picture in a page. Enough to rebuild the site’s pages from the tree.
libraryEvery 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 &gt;) 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.