dev-layer-builder.md
June 10, 2026 · View on GitHub
The ImageManager builds a development layer on top of project Docker images. This layer adds the dde user with the correct UID/GID, enabling file permission compatibility between the host and container.
Why a Dev Layer?
Docker images typically run as root or a predefined user. For development, files created inside the container need to be owned by your host user. The dev layer solves this by:
- Switching to
USER rootso the user can be created and the container starts as root at runtime (see Non-root base images) - Adding a
ddeuser and group with your host UID/GID - Installing
su-exec+shadow(Alpine) for privilege dropping and UID/GID remapping - Labeling the image with
dde.configured=trueanddde.project={name}
Label Check
Before building, ImageManager::hasLabel() checks if the image already has dde.configured=true. If it does, the build is skipped.
The isLayerCached() method checks if the dev image already exists locally:
public function isLayerCached(string $projectName): bool
{
return $this->dockerManager->imageExists($this->getDevImageTag($projectName));
}
Image Naming
Dev layer images follow the pattern:
dde-{projectName}:dev
For example: dde-myproject:dev
Dockerfile Generation
The generateDockerfile() method creates a Dockerfile based on the detected distribution:
Alpine-based Images
FROM php:8.4-fpm-alpine
LABEL dde.configured="true"
LABEL dde.project="myproject"
USER root
RUN apk add --no-cache su-exec shadow \
&& addgroup -g 1000 dde || true \
&& adduser -u 1000 -G dde -D -h /home/dde dde || true
Debian-based Images
FROM php:8.4-fpm
LABEL dde.configured="true"
LABEL dde.project="myproject"
USER root
RUN groupadd -g 1000 dde 2>/dev/null || true; \
useradd -u 1000 -g 1000 -m -d /home/dde -s /bin/sh dde 2>/dev/null || true
The Debian branch uses the C-binary shadow tools (useradd/groupadd), which ship in the base image — so no apt-get is needed. This is deliberate: Debian 13 (trixie) reimplemented adduser/addgroup as Perl scripts that abort under taint mode (Insecure directory in $ENV{PATH}) when PATH contains world-writable directories, as the whatwedo base images do. The shadow binaries are immune to that check.
Distribution detection is done by detectDistro(), which runs cat /etc/os-release inside an ephemeral container and checks for "alpine" in the output.
Non-root base images
Some base images default to a non-root USER — for example, registry.whatwedo.ch/whatwedo/docker-base-images v3 ships USER app. Without intervention this breaks the dev layer twice:
- At build time the
RUNwould execute as the unprivileged user and fail to create theddeuser (every command is|| true, so the build silently produces an image without addeuser —dde exec --user ddethen fails). - At runtime the dev layer is the container image, so the container would start as the non-root user. The entrypoint's user-creation/remap block is gated on
id -u = 0and would be skipped, and the service adapters could not rewrite the root-owned nginx/php-fpm config to run asdde.
Adding USER root (and not resetting it) fixes both: the build runs as root, and the resulting image starts the container as root so the entrypoint and service adapters work. This restores the implicit contract dde relied on with the older root-by-default base images.
Build Process
buildDevLayer() executes the full build:
- Generate the dev image tag (
dde-{projectName}:dev) - Detect the base image's distribution (Alpine vs Debian)
- Generate the Dockerfile with host UID/GID from
UserContext - Create a temporary directory, write the Dockerfile
- Run
docker buildviaDockerManager::buildImage() - Clean up the temporary directory
Cache Invalidation
public function invalidateLayer(string $projectName): void
Removes the dev layer image, forcing a rebuild on the next project:up. This is useful when:
- The base image has changed
- The host UID/GID has changed
- The
--buildflag is passed toproject:up
Integration with Project Lifecycle
The ProjectLifecycleManager::up() method calls ImageManager::ensureDevLayers() which:
- Returns
nullearly if the project name is empty - Checks if the layer is already cached (returns
nullif so) - Finds the first service with an
imagekey whose base image has a usable shell (DockerManager::imageHasShell()); services withoutimage:and services pointing at shell-less / distroless / scratch images are skipped, because the dev-layer Dockerfile runsuseradd/adduserand cannot succeed without/bin/sh - Builds the dev layer for that service
- Returns an array with the service name and image tag, or
nullwhen no shell-bearingimage:service exists
This mirrors the shell-check DockerComposeManager::generateOverride() already applies before injecting the entrypoint, SSH-Agent socket or env_file.
The result is included in the return value of ProjectLifecycleManager::up() for informational purposes. To actually use the dev layer at runtime, the project config should set the container image explicitly (e.g. containers.web.image: dde-myproject:dev), which the runtime override will pick up via DockerComposeManager::generateOverride().