Environments and the two-layer config
shipped 1.0.0Two files, both YAML, both maintained by cwp init and
cwp config set rather than by hand.
The project: cwp.yml, committed
version: 1
name: example
docroot: public
php: "8.3"
plugin_slug: example-site # the site plugin's directory
environments:
local:
provider: ddev # the site on this machine; DDEV owns its URL
dev:
cloudron_app: dev.example.com
url: https://dev.example.com
cloudron_package: managed # Cloudron's layout: managed | developer
mode: managed # managed | protected | source
prod:
cloudron_app: example.com
url: https://example.com
cloudron_package: managed
mode: protected # refuses upward writes without --force
vps:
provider: ssh # a plain host
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
keep_roles: [administrator, editor]
builder:
id: bricks # none | bricks
options:
child_theme: example-child
export_dir: bricks
wp_user: 1 # optional — who ability calls run as
access: # per environment, and it never travels
local: open # open | maintenance | coming_soon
dev: coming_soon
prod: open
commit: manual # manual | auto
media:
tracked: false # do attachment files enter the tree?
hooks_dir: cwp/hooks
Not every key is here. This block is the shape, not the list.
cwp.yml is the list, and content:, settings:,
widgets: and the derived scope: live there with what each one decides.
access: is keyed by environment and sits outside environments:. It maps
an environment name to that environment’s open or closed state. local is in
it like any other name, and it is the one a developer closes most often.
Of every key in the file, access: alone must not travel. A crossing makes
two environments agree. cwp access <env> converges
one environment to what this file declares about it, the opposite operation.
For that reason the design system’s transfer strips the builder’s own
maintenance flag rather than carrying it. Two writers of one state is how a
deploy reopens a staging site nobody meant to reopen.
local is always in the map (ADR-028). It is the site on this machine,
reached through DDEV, and it carries no url: .ddev/config.yaml owns the
address. The entry lets you name the site (cwp push local), and
default_environment: points at it when you rebuild the tree here. Omitting
the environment on
cwp bricks,
cwp content,
cwp plugins,
cwp ability, cwp mcp and
cwp open means local rather than dev.
Every environment is a spoke
The map above lists four sites. The tree, your committed
working copy, is not one of them. The shape is this: the tree at the centre,
each site a spoke, the environment argument naming which spoke rather than
which direction. local is the nearest spoke and the only one whose address
cwp asks DDEV for instead of the file.
If you know git, spend the analogy: the tree is the working copy, and local,
dev and prod are all remotes. cwp pull dev is git pull dev. The
resemblance is deliberate down to the refusals: it declines rather than
clobbering and snapshots the tree before it overwrites anything. cwp has no
“local versus remote” axis. It has tree versus site. The diagram on
the safety page draws it with the guards on.
The three places the analogy stops
- A git remote holds the same objects your working copy holds. A cwp site
does not. It holds a rendered form: rows in a database, a serialised
element tree in postmeta. A round trip is a transform rather than a copy.
For that reason cwp writes a
_cwp_syncmarker and refuses a conflicting write, and git needs no equivalent. - Git’s remotes are interchangeable. cwp’s are not. One of them carries
protected. The mode is a property of the spoke, not of the direction: the same command againstdevand againstprodgets different answers, and no flag on the command changes which. - For code, the local site is the tree. The docroot lives inside the
repository, so
cwp pushandcwp deployhave no local target to write to. They assume an environment where the other commands assume the local site. This asymmetry is the one worth knowing about. It is the reason a project has adevat all.
The manual uses these words precisely; the glossary has one entry each for tree, site, spoke and mode.
The machine: ~/.config/cwp/config.yml, never committed
version: 1
cloudron_host: my.example.com
defaults:
uploads: proxy
scrub: true
backup_before_push: true
snapshot_before_pull: true
snapshot_before_bricks_write: true
projects:
example: ~/Code/example/example.com
cwp init writes the projects registry and
cwp projects reads it.
Which layer wins, and how to find out
pull.uploads is project config and defaults.uploads is machine config. They
spell the same idea deliberately: one is the default the other starts from, and a
flag beats both.
The layer is never a guess. cwp knows which file each key lives in. It
refuses an unknown key and prints the near misses rather than writing the key
to whichever file happened to be open. cwp config get <key> --explain prints
the chain and marks the winner:
✓ pull.uploads — proxy
pull.uploads resolves to proxy from the project layer:
flag —
→ project proxy
machine —
default proxy
Two things the config deliberately does not hold
Defaults you did not set. cwp config set writes back your file with one key
changed, never a serialised copy of the parsed config. Otherwise every schema
default would freeze into the file the first time you set anything, and a
default cwp later corrects would never reach you. This has happened: a set of
table names that had never existed survived that way in real projects.
ssh settings. ssh: goes to the host verbatim, so ports, identity files
and jump hosts live in ~/.ssh/config. There they cannot disagree with the rest
of your machine.