Skip to content

Syncing from production in Docker

When production runs in Docker, the files and the database a sync needs are inside containers rather than on the host. The obvious way to reach them is docker exec over SSH — and it is the one worth refusing: access to the Docker socket is root on the host, and the docker group is not a lesser form of root but root with an extra step. Under that arrangement every developer who may sync may also take over the production host.

Instead, the stack publishes a small sync endpoint: a container that speaks SSH and carries nothing but what a sync needs on the far side. The developer connects to the Docker host as the ordinary user they already are, and ssh opens the last hop from there. No Docker rights, no socket, no root. The privilege is spent once, at deploy time, by whoever already holds it.

One endpoint per stack, in the stack’s own compose file. That boundary is the authorisation: whoever can reach project A’s endpoint cannot reach project B’s, because the stack already draws that line.

services:
ddev-sync:
image: noixdev/ddev-sync-endpoint:0.1.0-rc.2
restart: unless-stopped
# Only reachable through an SSH forward from the host itself. The left
# port is what your target's `sync_port` names.
ports:
- "127.0.0.1:2222:22"
# The stack's own network, so the endpoint resolves `db` — or whatever
# your database service is called — by name.
networks:
- default
environment:
# The public keys allowed in, one per line.
DDEV_SYNC_AUTHORIZED_KEYS: "${DDEV_SYNC_AUTHORIZED_KEYS}"
# The uid and gid that own the files mounted below.
DDEV_SYNC_UID: "33"
DDEV_SYNC_GID: "33"
volumes:
# What the sync pulls, read-only.
- app_storage:/data/storage:ro
- ./.env:/data/.env:ro
# The host key, which has to outlive the container.
- ddev_sync_hostkeys:/etc/ssh/keys
volumes:
ddev_sync_hostkeys:

The add-on ships this file as endpoint/docker-compose.ddev-sync-endpoint.yaml, with its own version already filled in; copying it from the release you installed is the way to keep the tag in step.

Four things are yours to decide, and each has a failure mode worth knowing before you meet it.

The endpoint reads authorised keys from DDEV_SYNC_AUTHORIZED_KEYS and, when present, from a file mounted at /etc/ddev-sync/authorized_keys — the union of both, not a choice between them. Keep them wherever your deployment keeps secrets; mount a file instead if that fits better; use both if one list is the team’s and the other is a CI key.

With neither, the container exits at startup and says so. There is no mode in which it accepts anyone: an image whose authentication could be switched off would reach production by accident and be invisible in docker ps. A line that is not readable as a public key — a key pasted with Windows line endings, or truncated by a mangled multi-line secret — is refused the same way, because the alternative is an endpoint that listens, is reachable, and cannot be entered.

To revoke someone, remove their key and redeploy the endpoint. Nothing else holds their access.

DDEV_SYNC_UID and DDEV_SYNC_GID are the ids the SSH session runs as, and they have to match the owner of the files you mounted — the application’s, usually 33 on a Debian-based image and often 82 on an Alpine-based one. Get this wrong and the endpoint starts fine, lets the developer in, and the sync fails when it reaches the files.

It is not compose’s user:. An sshd can only serve a uid that has an entry in the container’s user database, and only root inside the container can write one, so a user: the image was not built for makes the container exit immediately with No user exists for uid …. These two variables are written into the account at startup instead, which works for any uid.

ddev sync doctor checks it, so you can find out before a developer does:

OK sync endpoint prod.example
OK deploy path /data/ (in the endpoint)
OK path readable

ddev_sync_hostkeys:/etc/ssh/keys is not optional. ddev-sync trusts a host key the first time it sees it and refuses a changed one — that is what protects a channel that carries production’s database and its credentials. Without the volume, the endpoint generates a new key on every redeploy, and the first redeploy breaks the sync for every developer at once, with a message that reads like an attack.

If it does happen, the sync says what to look at:

the sync endpoint's host key is not the one recorded for it. Under
provider: docker a redeploy that dropped the endpoint's host-key volume
looks exactly like this; check that the volume mounted at /etc/ssh/keys
survived, then remove the stale known_hosts entry.

Each developer then removes the stale entry once. It is recorded under a name of its own rather than under the host’s address, so that two stacks on one Docker host do not collide — and under the add-on the known-hosts file lives in the sidecar, so the command runs there:

Terminal window
ddev exec -s sync ssh-keygen -R ddev-sync-endpoint-prod.example-2222

The name is ddev-sync-endpoint-<host>-<sync_port>, with the values from the target. Your first sync has this beside the other two ways a host key can refuse a connection.

The last hop is an SSH forward opened on the Docker host, so the host’s sshd has to allow forwarding. Many hardened images ship AllowTcpForwarding no, and the error it produces explains nothing on its own, so ddev-sync names it:

the host in between refused to forward the connection. A jump host needs
'AllowTcpForwarding yes' in its sshd_config; many hardened images ship 'no'.
targets:
production:
provider: docker # server (the default) | 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
stage: production

host and user keep their usual meaning — the machine you connect to — which under this provider is the first hop rather than the last. The container behind it is infrastructure, not a destination, and it has one account fixed by the image.

Three things differ from a target on a server:

  • path is read inside the endpoint, so it names the mount points you gave it, not a directory on the host. This is the one place where intuition from a server target is wrong, which is why doctor spells it out as (in the endpoint).
  • layout: deployer is refused. A Deployer layout resolves releases/current on the far side, and the endpoint has no such structure.
  • A sync_port without provider: docker is refused, because it can only mean the provider was meant to be set and silently ignoring it would sync the wrong thing from the wrong place.

WordPress is not supported under this provider in this version. Its credentials and its table prefix both come from WP-CLI, which needs PHP and a WordPress installation; the endpoint has neither and must not grow them. The refusal says so rather than failing later and deeper. A WordPress project syncs from a Docker host the way it did before, with provider: server.

Nowhere you have to configure: the endpoint carries none of its own. The engine reads them from production’s .env — the file you mounted — and the database host named in that file resolves because the endpoint sits inside the stack’s network. That is why the networks: line matters, and why remote_credentials: explicit is rarely needed here.

Worth being explicit about, because the design removes one privilege and does not pretend to remove another.

A key in the endpoint’s authorized_keys is a shell in a container on production’s network, with production’s database credentials in a mounted file and a database client installed. So “may sync” means “may read and write the production database”. The read-only mounts stop the files from being written and nothing more; ddev-sync’s own code only ever pulls, but the limit is the key list, not the container.

Three further properties follow from the same design:

  • The container runs as root inside itself, because that is what lets an sshd serve a uid chosen at deploy time. It has no Docker socket, no added capabilities, read-only payload mounts and a port published on the host’s loopback address only. Drop capabilities in your own compose file if your environment expects it.
  • There is one account for every developer, so the only attribution of a sync is the key fingerprint in the endpoint’s sshd log. Revocation is per key, which is the part that matters.
  • Rate limiting is off on purpose. OpenSSH would otherwise penalise a source address after failed authentication — and since every developer arrives through the same Docker host, they share one bucket, so one stale key retrying in a loop would lock out the whole team for minutes. Keys and a loopback-only port are what protect the endpoint.

The endpoint carries sshd, rsync and a MariaDB client, and deliberately nothing else: no Docker client, so it has nothing to say to the daemon; no rclone, because object storage is pulled by the developer’s side; no PHP; and not even an ssh client, so it cannot hop onwards. It also reports its own health, so a stack whose sshd died stops reading as Up.

A bastion in front of the Docker host is a second hop. ssh -J takes a chain, so the shape does not preclude it, and it is not built until someone needs it. Swarm, Kubernetes and a Docker host without SSH are further providers rather than variations of this one.