Skip to content

Docker Compose Override

dde configures Docker Compose projects through two complementary mechanisms:

  1. Persistent modifications to the project’s docker-compose.yml during project:init (via DockerComposeModifier)
  2. A temporary runtime override file generated during project:up (via DockerComposeManager::generateOverride())

The split is deliberate: everything that is pure dde infrastructure (networks, SSH-Agent socket) lives only in the runtime overlay, so the committed compose file stays usable outside of dde. Only project-specific values that a developer would reasonably want to read or tweak in source control — Traefik labels, DATABASE_URL, MAILER_DSN — are written back to docker-compose.yml.

Persistent Compose Modifications (project:init)

Section titled “Persistent Compose Modifications (project:init)”

The DockerComposeModifier class modifies the project’s actual docker-compose.yml during project:init. These are one-time changes committed to the repository.

If the compose file still carries the v1-era networks: default: name: dde, external: true block, it is stripped:

# before project:init
networks:
default:
name: dde
external: true

In v2, the per-project dde-services-<project> (main) or dde-services-<project>-<suffix> (worktree) network is injected by the runtime overlay, not declared in the committed file — and it is created unconditionally, even for projects that declare no services: in .dde/config.yml. The shared dde network is never used for project containers. DockerComposeModifier::removeDdeNetworkBoilerplate() handles the cleanup of v1-era network declarations.

Routing labels are added to web services so Traefik can route HTTP/HTTPS traffic:

services:
web:
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.myproject-test-web.rule=Host(`myproject.test`)'
- 'traefik.http.routers.myproject-test-web-tls.rule=Host(`myproject.test`)'
- 'traefik.http.routers.myproject-test-web-tls.tls=true'

If the compose file contains VIRTUAL_HOST and/or VIRTUAL_PORT environment variables (v1 format), they are consumed and converted to Traefik labels. The environment variables are then removed.

If a VIRTUAL_PORT is specified, a load balancer port label is also added:

- 'traefik.http.services.myproject-test-web.loadbalancer.server.port=8080'

Generated by DockerComposeModifier::addTraefikLabels().

Old compose files from v1 declared the SSH-Agent socket volume and the SSH_AUTH_SOCK environment variable directly on every service. DockerComposeModifier::removeSshAgentBoilerplate() strips the service-level volume mount, the SSH_AUTH_SOCK env, and any top-level dde_ssh-agent_socket-dir (or legacy v1-named) volume declaration. In v2 all SSH-Agent wiring is injected by the runtime overlay instead.

When the compose file defines a database service (mariadb or postgres), the modifier automatically adds DATABASE_URL to the main container:

services:
web:
environment:
- DATABASE_URL=mysql://root:root@mariadb:3306/myproject

Similarly, if a mailpit service is present:

services:
web:
environment:
- MAILER_DSN=smtp://mailpit:1025

These are only added if the variables are not already defined in the service environment or in the project’s .env / .env.dev files. Generated by DockerComposeModifier::addServiceEnvironment().

Legacy DDE_UID and DDE_GID build args from v1 are cleaned up by DockerComposeModifier::removeV1BuildArgs().

During project:up, DockerComposeManager::generateOverride() creates a temporary override file that layers runtime configuration on top of the project’s compose file without modifying it.

The override replaces the entrypoint with the dde entrypoint, pins the container to root, and mounts adapter scripts:

services:
web:
user: "0:0"
entrypoint: ['/dde/entrypoint.sh']
command: ['/original/entrypoint.sh', 'original-cmd']
volumes:
- /path/to/resources/entrypoint.sh:/dde/entrypoint.sh:ro
- /path/to/resources/adapters:/dde/adapters:ro

The original entrypoint and CMD from the Docker image are preserved as the command, so exec "$@" in the dde entrypoint chains to the original startup sequence.

If the project has custom adapters in .dde/adapters/, they are also mounted:

- /path/to/project/.dde/adapters:/dde/adapters-project:ro

The override passes the host user context, the SSH agent socket path, and the optional shell preference:

services:
web:
environment:
DDE_UID: "1000"
DDE_GID: "1000"
DDE_SHELL: "zsh"
SSH_AUTH_SOCK: "/tmp/ssh-agent/socket"

SSH_AUTH_SOCK is only injected for services whose image has a shell (see DockerManager::imageHasShell()), and only when an agent socket is actually mounted — in host mode an unresolved host agent omits it (see SSH-Agent Socket). Shell-less images (e.g. scratch or distroless) receive only the labels + networks and are left otherwise untouched.

The overlay declares the per-project network as external and attaches every service to it. The per-service networks: field is emitted with the !override YAML tag so Compose replaces (not merges) any networks the base file declared on that service — a legacy networks: [dde] left over from a v1 layout would otherwise survive the merge and reintroduce the cross-checkout DNS collision the isolation work prevents. A traefik.docker.network label pins Traefik’s lookup network so it can resolve upstream IPs:

networks:
dde-services-myproject:
external: true
services:
web:
labels:
- 'traefik.docker.network=dde-services-myproject'
networks: !override
dde-services-myproject: null

Implementation rule: whenever generated YAML must replace a key whose list-form value Compose would otherwise merge with the base file, emit it via Symfony\Component\Yaml\Tag\TaggedValue with the !override tag — never by string concatenation.

Project containers join exactly one network: the per-project one. Extra networks (an integration with an external Docker network outside the dde stack) are not supported on the base compose path — wire them up post-up via a hook or docker network connect if needed.

dde-services-<project> (main checkout) or dde-services-<project>-<suffix> (worktree) is the per-project network where the versioned service containers (mariadb, postgres, …) are reachable under their canonical names. The worktree variant lets a branch run a different service version than main without alias collisions.

Project containers never join the shared dde network — they only join their per-project network. Two checkouts of the same project (main + worktree) would otherwise both register identical service aliases on dde, and Docker DNS would round-robin between them — randomly routing cross-container calls to the wrong checkout. To keep inbound HTTP routing working, ProjectLifecycleManager::ensureProjectNetwork() attaches Traefik to the per-project network on project:up and project:down detaches it again.

The per-project network is created on every project:up, regardless of whether .dde/config.yml declares any services:. That keeps the network rule one-line — “one per-project network, period” — and removes a special-case fallback in both code and docs.

When a project switches a service version (e.g. mariadb 11.810.11), project:up detaches the previously attached dde-<service>-* container of the same service type before connecting the new one. Without this cleanup the canonical alias (mariadb) would resolve to two containers and Docker DNS would round-robin between them, randomly routing application traffic to the wrong database. Containers of unrelated service types and project app containers are left untouched.

For every shell-bearing service, the override mounts an SSH agent socket at /tmp/ssh-agent/socket. Only the host side of the mount depends on the global ssh.agent.mode (see the SSH agent guide).

In host mode (the default), the resolved host agent socket is bind-mounted read-write using the long volume syntax (a socket path containing : would corrupt the short form). When no host agent can be resolved, both the mount and SSH_AUTH_SOCK are omitted entirely:

services:
web:
volumes:
- type: bind
source: /run/host-services/ssh-auth.sock
target: /tmp/ssh-agent/socket

In managed mode, the shared socket directory volume is mounted read-only and declared at the top level (the volume block is only emitted in this mode — in host mode it would be dead config):

services:
web:
volumes:
- dde_ssh-agent_socket-dir:/tmp/ssh-agent:ro
volumes:
dde_ssh-agent_socket-dir:
external: true

Whenever one of these mounts is emitted, it is paired with SSH_AUTH_SOCK=/tmp/ssh-agent/socket (see above), giving container processes transparent access to an SSH agent without any wiring in the project’s compose file.

If a dev layer was built (see Dev Layer Builder), the override replaces the service image with the dev layer image tag:

services:
web:
image: dde-myproject:dev

All services get a dde.managed=true label for identification:

services:
web:
labels:
- 'dde.managed=true'

When running in a git worktree, additional Traefik router labels are generated for the worktree hostname (e.g. feature.myproject.test).

In a worktree, extra_hosts entries whose hostname is <project>.test or a subdomain thereof are re-emitted with the worktree-hostname variant, tagged !override so Compose replaces (not merges) the base list on the worktree container. Inside containers, dde’s host-side dnsmasq is unreachable — /etc/hosts (populated from extra_hosts) is the only resolver for <project>.test, so without the rewrite a worktree’s container would hold the main checkout’s hostnames and could not reach its own worktree URLs.

Generated by DockerComposeManager::generateOverride() via WorktreeManager::rewriteExtraHosts(). The override is only emitted when at least one base entry references the project host; otherwise the base list passes through unchanged.

The runtime override file is:

  1. Generated before docker compose up by DockerComposeManager::generateOverride()
  2. Applied as the last -f argument so it has the final word on runtime-critical fields (network, worktree hostname, Traefik labels)
  3. Removed after docker compose up completes (in a finally block to ensure cleanup)

This approach keeps the project’s compose file unmodified at runtime.

A project may ship its own override file alongside the base compose file. Docker Compose normally auto-merges it on docker compose up, but that auto-discovery is disabled the moment dde passes explicit -f arguments. DockerComposeManager::findUserOverrideFile() detects the override and ProjectLifecycleManager::up() slots it in between the base file and the dde-generated overlay:

docker compose \
-f docker-compose.yml \
-f docker-compose.override.yml # user override — optional
-f /tmp/dde-override-… # dde runtime overlay — last word
up -d

The override is paired by base filename — compose.yml looks for compose.override.yml (or .yaml), docker-compose.yml looks for docker-compose.override.yml (or .yaml). A project on compose.yml will not silently pick up a stray docker-compose.override.yml.

Typical use cases:

  • Developer-local tweaks (DISPLAY for a Playwright container, host port bindings)
  • Optional debug services that should not ship in the committed base file

The dde overlay always wins for fields it sets explicitly (worktree hostname, per-project network, Traefik routing for known hosts, and — in worktrees only — the extra_hosts rewrite for project hostnames). For everything else — environment, volumes, additional services, and extra_hosts outside a worktree — the user override behaves exactly as it would without dde.