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 and mounts adapter scripts:

services:
web:
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()). 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

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 the shared SSH-Agent socket directory and declares the backing volume at the top level:

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

Paired with SSH_AUTH_SOCK=/tmp/ssh-agent/socket (see above), this gives container processes transparent access to the host SSH keys 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.