Customizing Docker Images¶
It’s common to have a requirement for the web or db images which isn’t bundled with them by default. There are two ways to extend these Docker images:
webimage_extra_packagesanddbimage_extra_packagesin.ddev/config.yaml.- An add-on Dockerfile in your project’s
.ddev/web-buildor.ddev/db-build.
Adding Extra Debian Packages with webimage_extra_packages and dbimage_extra_packages¶
You can add extra Debian packages with lines like this in .ddev/config.yaml:
webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
dbimage_extra_packages: [netcat, telnet, sudo]
Then the additional packages will be built into the containers during ddev start.
Adding PHP Extensions¶
PHP Extensions supported by deb.sury.org¶
If a PHP extension is supported by the upstream package management from deb.sury.org, you’ll be able to add it with minimal effort. Test to see if it’s available using ddev exec '(sudo apt-get update || true) && sudo apt-get install php${DDEV_PHP_VERSION}-<extension>', for example, ddev exec '(sudo apt-get update || true) && sudo apt-get install php${DDEV_PHP_VERSION}-imap'. If that works, then the extension is supported, and you can add webimage_extra_packages: ["php${DDEV_PHP_VERSION}-<extension>"] to your .ddev/config.yaml file.
PECL PHP Extensions not supported by deb.sury.org¶
Few people need pecl extensions
Most people don’t need to install PHP extensions that aren’t supported by deb.sury.org, so you only need to go down this path if you have very particular needs.
If a PHP extension is not supported by the upstream package management from deb.sury.org, you’ll install it via pecl using a .ddev/web-build/Dockerfile. You can search for the extension on pecl.php.net to find the package name. (This technique can also be used to get newer versions of PHP extensions than are available in the deb.sury.org distribution.)
For example, a .ddev/web-build/Dockerfile.mcrypt might look like this:
ENV extension=mcrypt
SHELL ["/bin/bash", "-c"]
# Install the needed development packages
RUN (apt-get update || true) && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confnew" --no-install-recommends --no-install-suggests build-essential php-pear php${DDEV_PHP_VERSION}-dev
# mcrypt happens to require libmcrypt-dev
RUN apt-get install -y libmcrypt-dev
RUN pecl install ${extension}
RUN echo "extension=${extension}.so" > /etc/php/${DDEV_PHP_VERSION}/mods-available/${extension}.ini && chmod 666 /etc/php/${DDEV_PHP_VERSION}/mods-available/${extension}.ini
RUN phpenmod ${extension}
A .ddev/web-build/Dockerfile.xlswriter to add xlswriter might be:
ENV extension=xlswriter
SHELL ["/bin/bash", "-c"]
# Install the needed development packages
RUN (apt-get update || true) && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confnew" --no-install-recommends --no-install-suggests build-essential php-pear php${DDEV_PHP_VERSION}-dev
# xlswriter requires libz-dev
RUN sudo apt-get install -y libz-dev
RUN echo | pecl install ${extension}
RUN echo "extension=${extension}.so" > /etc/php/${DDEV_PHP_VERSION}/mods-available/${extension}.ini && chmod 666 /etc/php/${DDEV_PHP_VERSION}/mods-available/${extension}.ini
RUN phpenmod ${extension}
A .ddev/web-build/Dockerfile.xdebug (overriding the deb.sury.org version) might look like this:
# This example installs xdebug from pecl instead of the standard deb.sury.org package
ENV extension=xdebug
SHELL ["/bin/bash", "-c"]
RUN phpdismod xdebug
# Install the needed development packages
RUN (apt-get update || true) && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confnew" --no-install-recommends --no-install-suggests build-essential php-pear php${DDEV_PHP_VERSION}-dev
# Remove the standard Xdebug provided by deb.sury.org
RUN apt-get remove php${DDEV_PHP_VERSION}-xdebug || true
RUN pecl install ${extension}
# Use the standard xdebug.ini from source
ADD https://raw.githubusercontent.com/ddev/ddev/main/containers/ddev-webserver/ddev-php-files/etc/php/8.2/mods-available/xdebug.ini /etc/php/${DDEV_PHP_VERSION}/mods-available
RUN chmod 666 /etc/php/${DDEV_PHP_VERSION}/mods-available/xdebug.ini
# ddev xdebug handles enabling module so we don't enable here
#RUN phpenmod ${extension}
Adding Locales¶
The web image ships by default with a small number of locales, which work for most usages, including
en_CA, en_US, en_GB, es_ES, es_MX, pt_BR, pt_PT, de_DE, de_AT, fr_CA, fr_FR, ja_JP, and ru_RU.
If you need other locales, you can install all of them by adding locales-all to your webimage_extra_packages. For example, in .ddev/config.yaml:
Adding Extra Dockerfiles for webimage and dbimage¶
For more complex requirements, add your own Dockerfile content to .ddev/web-build (for webimage) or .ddev/db-build (for dbimage). DDEV merges those files with its own build steps into one generated Dockerfile per image, at .ddev/.webimageBuild/Dockerfile and .ddev/.dbimageBuild/Dockerfile, which you never edit directly.
DDEV writes example files for you to copy and rename:
.ddev/web-build/Dockerfile.exampleand.ddev/db-build/Dockerfile.examplein the project, when you runddev config$HOME/.ddev/web-build/pre.Dockerfile.exampleand$HOME/.ddev/db-build/pre.Dockerfile.examplefor global Dockerfiles
The Dockerfile builds an image, it doesn’t run in your project
While the Dockerfile is executing, your code is not mounted and the container is not running, the image is being built. So for example, an npm install in /var/www/html will not do anything to your project because the code is not there at image building time.
Insertion Order¶
The file name decides where its content lands in the generated Dockerfile, and global files in $HOME/.ddev/*-build are always inserted before the project’s files in .ddev/*-build:
| # | Content | Notes |
|---|---|---|
| 1 | prepend.Dockerfile, prepend.Dockerfile.*(global, then project) |
Above DDEV’s FROM line, for multi-stage builds. Only $BASE_IMAGE is declared this early, see Build Time Environment Variables |
| 2 | DDEV’s FROM $BASE_IMAGE, build arguments, and user creation |
The in-image user mirrors your host user, using $username, $uid, and $gid |
| 3 | pre.Dockerfile, pre.Dockerfile.*(global, then project) |
For directives that have to come early, like proxy settings, SSL termination, CA certificates, or EOL PHP versions |
| 4 | DDEV’s own build steps | PHP version, webimage_extra_packages or dbimage_extra_packages, Composer update, database clients |
| 5 | Dockerfile, Dockerfile.*(global, then project) |
The usual choice for most customizations |
| 6 | DDEV’s finishing steps | Permission fixes on the web image, which have to run after everything else |
Within each group, files are inserted in alphabetical order, so Dockerfile comes first, then Dockerfile.* alphabetically.
To see the result, read the generated Dockerfile, or force a rebuild with ddev restart --no-cache or ddev utility rebuild, which shows the whole build output for debugging.
Copying Files into the Image¶
The .ddev/*-build directory is the Docker “context”, so if a file named file.txt exists in .ddev/web-build, you can use COPY file.txt / in the Dockerfile.
Examples¶
An example web image .ddev/web-build/Dockerfile might be:
Another example would be installing phpcs globally (see Stack Overflow answer):
ENV COMPOSER_HOME=/usr/local/composer
# We try to avoid relying on Composer to download global, so in `phpcs` case we can use the PHAR.
RUN curl -L https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -o /usr/local/bin/phpcs && chmod +x /usr/local/bin/phpcs
RUN curl -L https://squizlabs.github.io/PHP_CodeSniffer/phpcbf.phar -o /usr/local/bin/phpcbf && chmod +x /usr/local/bin/phpcbf
# If however we need to download a package, we use `cgr` for that.
RUN composer global require consolidation/cgr
RUN $COMPOSER_HOME/vendor/bin/cgr drupal/coder:^8.3.1
RUN $COMPOSER_HOME/vendor/bin/cgr dealerdirect/phpcodesniffer-composer-installer
# Register Drupal’s code sniffer rules.
RUN phpcs --config-set installed_paths $COMPOSER_HOME/global/drupal/coder/vendor/drupal/coder/coder_sniffer --verbose
# Make Codesniffer config file writable for ordinary users in container.
RUN chmod 666 /usr/local/bin/CodeSniffer.conf
# Make `COMPOSER_HOME` writable if regular users need to use it.
RUN chmod -R ugo+rw $COMPOSER_HOME
# Now turn it off, because ordinary users will want to be using the default.
ENV COMPOSER_HOME=""
Multi-Stage Builds¶
Multi-stage builds help keep a Dockerfile optimized without making it hard to read and maintain. They need a prepend. file, because the extra FROM statement has to be on top of the generated Dockerfile. An example web image could have a .ddev/web-build/prepend.Dockerfile:
# If we want to use any of the build time environment variables injected by ddev
# on the prepend.Dockerfile* variants we need to manually declare them to make
# them available using the ARG instruction.
# Only $BASE_IMAGE is already added as it must be global to be used on FROM
# statements.
FROM $BASE_IMAGE AS build-stage-go
# While we are not using $uid and $gid in the code below, this serves as an example
# of how any of the other DDEV's build variables must be defined.
ARG uid
ARG gid
# install go
RUN set -eux; \
GO_VERSION=$(curl -fsSL "https://go.dev/dl/?mode=json" | jq -r ".[0].version"); \
AARCH=$(dpkg --print-architecture); \
wget -q https://go.dev/dl/${GO_VERSION}.linux-${AARCH}.tar.gz -O go.tar.gz; \
tar -C /usr/local -xzf go.tar.gz; \
rm go.tar.gz;
And then a Dockerfile:
# Copy entire go directory from the build stage defined above.
COPY --from=build-stage-go /usr/local/go /usr/local
Global Dockerfiles¶
The same files work in $HOME/.ddev/web-build/ and $HOME/.ddev/db-build/, where they apply to every project on the machine, which is handy for things like corporate certificates or private package registries. Global files are inserted before the project’s own files, and the global directory is a Docker “context” too, where a project file of the same name wins.
Your global directory may live elsewhere
See Global Files, it isn’t always $HOME/.ddev.
For example, to install a custom CA certificate in every project’s web image:
cat > $HOME/.ddev/web-build/pre.Dockerfile.vpn << 'EOF'
COPY my-ca.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
EOF
# The COPY source has to be in the same directory, it's the Docker "context"
cp /path/to/my-ca.crt $HOME/.ddev/web-build/my-ca.crt
ddev restart
A pre. file is used here so the certificate is in place before DDEV’s own build steps run.
Build Time Environment Variables¶
The following environment variables are available for the web Dockerfile to use at build time:
$BASE_IMAGE: the base image, likeddev/ddev-webserver:v1.24.0(global scope)$username: the username inferred from your host-side username$uid: the user ID inferred from your host-side user ID$gid: the group ID inferred from your host-side group ID$DDEV_PHP_VERSION: the PHP version declared in your project configuration$TARGETARCH: The build target architecture, likearm64oramd64$TARGETOS: The build target operating system (alwayslinux)$TARGETPLATFORM:linux/amd64orlinux/arm64depending on the machine it’s been executed on
Only $BASE_IMAGE is automatically available in prepend.Dockerfile* variants
If you need to use any of the other variables you will need to manually add them to your prepend.Dockerfile* files using ARG instructions.
For example, a Dockerfile might want to build an extension for the configured PHP version like this using $DDEV_PHP_VERSION to specify the proper version:
ENV extension=xhprof
ENV extension_repo=https://github.com/longxinH/xhprof
ENV extension_version=v2.3.8
RUN (apt-get update || true) && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confnew" --no-install-recommends --no-install-suggests autoconf build-essential libc-dev php-pear php${DDEV_PHP_VERSION}-dev pkg-config zlib1g-dev
RUN mkdir -p /tmp/php-${extension} && cd /tmp/php-${extension} && git clone ${extension_repo} .
WORKDIR /tmp/php-${extension}/extension
RUN git checkout ${extension_version}
RUN phpize
RUN ./configure
RUN make install
RUN echo "extension=${extension}.so" > /etc/php/${DDEV_PHP_VERSION}/mods-available/${extension}.ini
An example of using $TARGETARCH would be:
RUN curl --fail -JL -s -o /usr/local/bin/mkcert "https://dl.filippo.io/mkcert/latest?for=linux/${TARGETARCH}"
DDEV Labels on Images¶
A DDEV image can carry two labels that look similar and mean different things. docker inspect on a locally built web image will show both, usually with different values, which is expected:
| Label | Set by | Means |
|---|---|---|
com.ddev.image-tag |
baked in when the image is built | The tag this image was published as. Inherited through FROM, so a derived image keeps the tag of the DDEV image it descends from, even after it is given its own name and tag. |
com.ddev.webtag |
the running DDEV CLI | Which DDEV built or started this resource. Applied to containers, networks, and the build sections DDEV generates, so it always reflects the DDEV you are running rather than the image’s origin. |
So a project’s own web image, built from .ddev/web-build, inherits com.ddev.image-tag from the ddev-webserver base it was built on, and gets com.ddev.webtag from whichever DDEV built it. When you pin a webimage or dbimage, only com.ddev.image-tag tells you which generation of DDEV images it came from, which is what DDEV checks to warn you that a pinned image has gone stale.
Seeding a Custom Starter Database in dbimage¶
If you publish a derived dbimage (for example a CI-built image with your production dataset baked in, so teammates or preview environments get a ready-to-use database with no import step), bake it in as /mysqlbase/custom/base_db.zst using a .ddev/db-build/Dockerfile:
The database container uses this seed the first time its data volume is created (a brand-new project, or after ddev delete and ddev start), instead of the stock DDEV starter database. A project-level seed snapshot wins over it — so a teammate can still override your baked-in seed for their own project just by dropping a seed snapshot into .ddev/db_snapshots, without rebuilding the image.
A seed can also be baked in uncompressed, as base_db.mbstream (MariaDB) or base_db.xbstream (MySQL, or MariaDB 5.5/10.0) — the raw mariabackup/xtrabackup stream with no compression stage. This is an unusual, deliberate tradeoff (see Uncompressed Snapshots): it trades a much larger image layer for zero first-boot decompression cost, and only makes sense if you’ve already decided that tradeoff is worth it.
When ddev start seeds a fresh database volume from a baked-in seed, it says so and warns that a large one may take a while:
Initializing new database volume from /mysqlbase/custom/base_db.zst baked into dbimage ddev/ddev-dbserver-mariadb-11.8:v1.25.0...
With a large database this may take a long time.
Note
Only supported for mysql and mariadb database types (excluding the very old, EOL mysql:5.5 and mariadb:5.5). PostgreSQL projects use the stock upstream postgres image and its own startup process, which this seeding mechanism doesn’t hook into, so a baked-in seed is silently ignored.
Adding EOL Versions of PHP¶
If your project requires multiple versions of PHP—such as using PHP 8.3 but also needing an older, unsupported, unmaintained version like PHP 7.4 for specific scripts—and you don’t want to fully switch to PHP 7.4 with ddev config --php-version=7.4, you can install it using the pre.Dockerfile.* technique from the previous section.
Create a .ddev/web-build/pre.Dockerfile.php7.4 file with the following content:
After restarting the project, you can use PHP 7.4 with the command ddev exec php7.4 -v.
Installing into the home directory¶
The in-container home directory is rebuilt when you run ddev restart, so if you have something that installs into the home directory (like ~/.cache) you’ll want to switch users in the Dockerfile. In this example, npx playwright install installs a number of things into ~/.cache, so we’ll switch to the proper user before executing it, and switch back to the root user after installation to avoid surprises with any other Dockerfile that may follow.
USER $username
# This is an example of creating a file in the home directory
RUN touch ~/${username}-was-here
# `npx playwright` installs lots of things in ~/.cache
RUN npx playwright install
RUN npx playwright install-deps
USER root
Debugging the Dockerfile Build¶
It can be complicated to figure out what’s going on when building a Dockerfile, and even more complicated when you’re seeing it go by as part of ddev start.
- Use
ddev sshfirst of all to pioneer the steps you want to take. You can do all the things you need to do there and see if it works. If you’re doing something that affects PHP, you may need tosudo killall -USR2 php-fpmfor it to take effect. - Put the steps you pioneered into
.ddev/web-build/Dockerfileas above. - If you can’t figure out what’s failing or why, running
ddev utility rebuildwill show the full output of the build process. You can also runexport DDEV_VERBOSE=true && ddev startto see what’s happening during theddev startDockerfile build.