Skip to content

Configuration

Configuration is read from several files that merge into one document. You rarely need all of them: a single target in the global file is enough to start.

FileWhat belongs here
~/.ddev/ddev-sync/config.yamlYour targets — hosts, users, paths. Personal, never committed. One file for every project you install the add-on into.
<project>/.ddev/ddev-sync.yamlProject settings the whole team shares. Committed.
<project>/.ddev/ddev-sync/local.yamlYour overrides for this project, and the place for secrets. It sits inside the directory the add-on’s own .gitignore covers, so it stays out of the repository without your doing anything.

The global file is read from your own home directory on your own machine. The add-on mounts ~/.ddev/ddev-sync into its container read-only, which is what makes the file reachable from a sync — and also means a sync can never write to it. The directory itself is created on the first start of a project that has the add-on; the config.yaml inside it is yours to create.

The local file used to live at <project>/.ddev/ddev-sync.local.yaml, one level further up. That path still works — it is still read, and nothing will move your file for you — but it sits outside the directory the add-on’s .gitignore covers, so a project still using it has its secrets one git add away from the repository. Move the file to .ddev/ddev-sync/local.yaml when you get the chance. ddev sync init says so, once, if it writes credentials to the old path.

Later files win over earlier ones: the project file overrides the global one, the local file overrides both. Above all three sit the layers ddev-sync brings itself: the built-in defaults, the profile for your project type, the profile’s version variant, and any features that detected something. Your files always win over those.

Every line of a dry run names the layer it came from, which is the quickest way to find out why something is in your plan:

5 env local patch_keys: 9 key(s) -> .env.local, .env [~/.ddev/ddev-sync/config.yaml]

A target describes one remote you pull from. ddev sync init writes the first one for you: it asks for a name, where the remote is and which stage it is, writes that one target, and then runs doctor against it. It only ever writes <project>/.ddev/ddev-sync.yaml — the global file is mounted read-only into the add-on’s container, so no sync can write there — and it says so afterwards, because that file is committed: move the block to the global file to keep the host and user out of the repository. It reads the answers from a terminal and has no flags to supply them with, so a scripted setup writes the file directly.

Written by hand, a target looks like this.

version: 1
targets:
production:
host: web01.example
user: deploy
port: 22
path: /srv/app/
stage: production
KeyMeaning
hostThe remote host.
userThe SSH user.
ssh_aliasA host taken from your ~/.ssh/config, instead of host and user. Not available through the add-on — see ssh_alias and the add-on.
portDefaults to 22.
pathThe project’s directory on the remote, where its code and files live.
stageproduction, staging, testing or dev. A run that skips sanitising warns you about the stage it pulled from.
layoutplain (default) or deployer, when the remote uses Deployer’s releases/current and shared directories.
deployerTake host, user, port, path and layout from the project’s own Deployer configuration instead of writing them down. See Taking the connection from Deployer.
extendsInherit another target’s connection details and change only what differs.
providerserver (default) or docker, when production runs in Docker and the stack publishes a sync endpoint. See Syncing from production in Docker.
sync_portWhere the Docker host publishes that endpoint. Only with provider: docker, and required by it.
jump_host, jump_host_user, jump_host_portFor a remote only reachable through a bastion. ddev-sync does not trust the bastion’s key on first use the way it does the remote’s, so tell ssh about the bastion once: see Host key verification failed.

ssh_alias takes a host from your ~/.ssh/config instead of spelling out host, user and port. It does not work when ddev-sync runs through the DDEV add-on, which is how it normally runs: the sync happens in a small container of its own, and your ~/.ssh/config is not mounted into it.

That is deliberate rather than an omission. A real ~/.ssh/config names identity files, Includes other files and sets control paths — all of them paths on your machine that do not exist inside the container. Mounting the file alone would make ssh_alias appear to work and then fail on whichever of those the entry happens to depend on, which is a worse answer than not offering it.

Name host, user and port on the target instead. Keys themselves are not affected: the sync authenticates through the ssh agent DDEV forwards, so ddev auth ssh is all a key needs.

Several targets can share connection details:

targets:
production:
host: web01.example
user: deploy
path: /srv/app/
stage: production
staging:
extends: production
path: /srv/staging/
stage: staging

A target can name a sync endpoint instead of a directory on a host: a small container the production stack publishes on the host’s own loopback address, which ddev-sync reaches through the host with an ordinary SSH login. Developers then need no Docker rights on the production host at all.

targets:
production:
provider: docker
host: prod.example # the Docker host, as you connect to it today
user: deploy
sync_port: 2222 # where the stack publishes the endpoint
path: /data/ # the path inside the endpoint, not on the host
stage: production

host and user are the first hop here, and path is read inside the container. Placing the endpoint, distributing the keys and the three settings that have failure modes worth knowing are all in Syncing from production in Docker; WordPress is not supported under this provider in this version, and the reason is there too.

A project that is deployed with Deployer already states where it deploys: the host, the user and the directory. A target can ask for those instead of repeating them, so they cannot drift apart.

targets:
production:
deployer:
host: web01.example # Deployer's host alias; omit it when there is only one
stage: production

host here is Deployer’s host alias, the name its host() call was given. That is often a hostname, but not necessarily: a project whose hosts are called production and staging has aliases that are not hostnames. When Deployer knows exactly one host you can leave host out, and the block is just deployer: {}.

Where the values come from. ddev-sync runs dep config --format=json in the project’s web container, using vendor/bin/dep or a dep on the container’s PATH, and reads the host, user, port and deploy path from the answer. The host to connect to is Deployer’s hostname, falling back to the alias when it has none. A missing remote_user or deploy_path fails the run, because there is nothing to connect with or to; a port of null simply means 22, as it does for a target you write by hand. The layout is worked out from the same output: if Deployer’s current_path is its deploy_path plus current, the remote uses Deployer’s releases/current layout, and otherwise it is a plain one. It asks once per sync, and that one answer serves every target.

It is strict, and there is no fallback. The block is the only source for host, user, port and path. Setting any of them on the target beside the block (the host inside the block is the alias, a different thing) is a configuration error, not an override, and so is layout, which is derived rather than chosen, and ssh_alias, which would be a second way to say where to connect. If resolving fails, the run fails with the reason; ddev-sync never quietly substitutes a value from somewhere else. A stale fallback would be right on the machine where it was written and wrong on the next one, in a tool that replaces your local database.

Two things stay yours:

  • stage is still required. It is not a connection detail, and a host alias is not a stage name.
  • jump_host, jump_host_user and jump_host_port may be combined with the block. Deployer has no canonical field for a bastion, so there is nothing for the block to supply and nothing to conflict with.

The project has to be running. The dep command runs inside the web container, and Deployer has to be installed there: either vendor/bin/dep (the usual place, from composer require --dev deployer/deployer) or a dep on the container’s PATH. A stopped project cannot resolve its target. The error for that can be misleading: a stopped container makes docker exec exit with status 1, which looks exactly like a recipe that failed. The message carries the underlying error output, so read it before concluding that your deploy.php is broken. ddev sync targets is different: it never runs dep and contacts nothing remote, because it shows the block as you wrote it rather than what it resolves to. Invoked through ddev sync on a stopped project it will still start the project, as DDEV does for any service command, but the listing itself only reads your configuration.

A dry run of such a target is not entirely inert. It contacts no remote and writes no file, and it shows the real endpoint in its header, which is what you want it for. But dep config evaluates the project’s own deploy.php, so the PHP in that file (deploy.php, or deploy.yaml) runs inside the web container, even in a dry run.

Deployer carries no database credentials that ddev-sync can read. In the project this was checked against, they were interactive prompts rather than values. Credentials still come from the source you configure under Database credentials on the remote, and this block does not change that.

With extends, the block is one of three ways of saying where to connect, alongside host and ssh_alias. Whichever the stronger target sets wins and the others are dropped, so a target can extend a literal one and switch to Deployer, or the reverse. Only a combination with the block is an error, whether within a single target or across files that define the same target name; host together with ssh_alias remains allowed, with the alias winning. The one place that is not symmetric: when the block wins, the base target’s user, port, path and layout are dropped with its host, and a literal set by the target that extends it is rejected, because it contradicts the block.

To dump the remote database, ddev-sync needs its credentials. Where it reads them from is a setting, not a guess:

database:
remote_credentials: env # env (default) | explicit | wp_cli

env reads them from the remote’s own environment file. It looks for .env.local and then .env in the project’s data directory on the target. The keys default to DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_HOST and DATABASE_PORT, and a project whose variables are named differently maps them:

database:
remote_credentials: env
env_file: .env.production # when the remote's file is named otherwise
env_map:
name: DB_DATABASE
user: DB_USERNAME
password: DB_PASSWORD
host: DB_HOST
port: DB_PORT

database.env_file is the file on the target, which is not always the one your own project uses: env.file names the local project’s file, and setting that alone still steers this lookup too, so a project that was already using it keeps working. Only a project whose local and remote files have different names needs the separate key. ddev sync init asks for this only when its own credentials check came back red and this is the source you configured — it lists the names it looked for and what is actually in that directory on the target — and it writes your answer here.

wp_cli asks the remote’s WP-CLI for them, which is also how WordPress’s table prefix is read — a site with a prefix other than wp_ needs this source, or an explicit database.wp_table_prefix.

explicit takes them from your configuration. These are production credentials, so they belong in the local file, .ddev/ddev-sync/local.yaml, not in the project file that is committed. ddev sync init writes them there for you when it has to ask for them, and the add-on’s shipped .gitignore already covers that path.

database:
remote_credentials: explicit
credentials:
name: acme_prod
user: acme
password: s3cret
host: 127.0.0.1
port: "3306"

Drupal projects generally need explicit, because Drupal keeps its credentials in settings.php rather than in a file this tool can parse.

After the import, the local application has to point at the local database. For Laravel and Symfony that means patching .env, and the profile does it automatically.

TYPO3 is different, and deliberately so. A composer-installed TYPO3 ships no .env — it reads config/system/additional.php, which DDEV writes itself — so the TYPO3 profile does not touch an environment file at all. Earlier versions tried, and a sync against a stock TYPO3 aborted after importing the production database and before sanitising it, which is the worst possible place to stop.

If your TYPO3 project does use a .env (with helhum/dotenv-connector, for instance), switch it back on in one line:

env:
strategy: patch_keys

You inherit the profile’s keys and file candidates; only the strategy changes. A .env is never required.

Mappings merge key by key, so overriding a single setting works as you would expect. Lists do not: by default a list in your file is appended to the profile’s. Replace one instead of extending it when that is what you mean:

sanitize:
rules:
mode: replace
values:
- name: anonymize-users
sql: "UPDATE users SET email = CONCAT('user', id, '@example.invalid')"

This matters most for WordPress, whose shipped URL-rewriting rule carries a placeholder production URL that only your project can fill in. Appending a corrected rule leaves the placeholder one running alongside it.