Skip to content

Environments and the two-layer config

shipped 1.0.0

Two 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

  1. 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_sync marker and refuses a conflicting write, and git needs no equivalent.
  2. 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 against dev and against prod gets different answers, and no flag on the command changes which.
  3. For code, the local site is the tree. The docroot lives inside the repository, so cwp push and cwp deploy have 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 a dev at 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.