Creating DDEV Add-ons¶
DDEV add-ons provide a powerful way to extend development environments. You can create add-ons using traditional Bash actions or the new PHP-based actions for complex configuration processing.
Quick Start¶
- Use the
ddev-addon-templaterepository - Click “Use this template” to create your own repository
- Customize the
install.yamlfile - see the example - Add the
#ddev-generatedcomment to all files installed by the add-on - Test with Bats
- Create a release when ready
- If you expect to support and maintain the add-on, add the
ddev-gettopic to the add-on’s GitHub repository. This helps developers find the add-on and encourages reporting issues and feedback.
See this screencast for a walkthrough.
Add-on Structure¶
Every add-on requires an install.yaml file with these sections:
name: my-addon
pre_install_actions: []
project_files: []
global_files: []
post_install_actions: []
removal_actions: []
Core Sections¶
name: The add-on name used inddev add-oncommandspre_install_actions: Scripts executed before files are copiedproject_files: Files copied to the project’s.ddevdirectoryglobal_files: Files copied to the global configuration directorypost_install_actions: Scripts executed after files are copied-
removal_actions: Scripts executed when removing the add-on. Use these to clean up files that don’t carry#ddev-generated(e.g. files placed outside.ddev/, or files the user may have modified).removal_actions: - | #ddev-description: Remove generated settings file if unmodified if [ -f "${DDEV_APPROOT}/${DDEV_DOCROOT}/sites/default/settings.ddev.myservice.php" ]; then if grep -q '#ddev-generated' "${DDEV_APPROOT}/${DDEV_DOCROOT}/sites/default/settings.ddev.myservice.php"; then rm -f "${DDEV_APPROOT}/${DDEV_DOCROOT}/sites/default/settings.ddev.myservice.php" else echo "Skipping removal: settings.ddev.myservice.php has been modified." fi fi
Advanced Sections¶
ddev_version_constraint: Minimum DDEV version requireddependencies: Other add-ons this add-on depends onyaml_read_files: YAML files to read for template processing
Action Types: Bash vs PHP¶
Traditional Bash Actions¶
Bash actions run directly on the host system and are suitable for:
- File permissions and system commands
- Environment setup and package installation
- Direct command execution
- Simple file operations
name: bash-example
post_install_actions:
- |
#ddev-description: Configure project settings
echo "Setting up project: $DDEV_PROJECT"
chmod +x .ddev/commands/web/mycommand
PHP-based Actions ✨ NEW¶
PHP actions provide powerful capabilities for:
- Complex data processing and YAML manipulation
- Conditional logic based on project configuration
- Cross-platform compatibility
- File content generation and template processing
Why Use PHP Actions?¶
- Better YAML processing with the built-in php-yaml extension
- Cross-platform compatibility (no shell scripting differences)
- Rich string manipulation and data processing capabilities
- Access to DDEV project configuration through environment variables
- Familiar syntax for developers working with PHP projects
Basic PHP Action¶
name: php-example
post_install_actions:
- |
<?php
#ddev-description: Process project configuration
// Access DDEV environment variables
$projectName = $_ENV['DDEV_PROJECT'];
$projectType = $_ENV['DDEV_PROJECT_TYPE'];
$docroot = $_ENV['DDEV_DOCROOT'];
echo "Setting up $projectType project: $projectName\n";
// Generate YAML configuration
$config = [
'services' => [
'myservice' => [
'image' => 'nginx:latest',
'environment' => [
'PROJECT_TYPE' => $projectType
]
]
]
];
file_put_contents('docker-compose.myservice.yaml',
"#ddev-generated\n" . yaml_emit($config));
?>
Available Environment Variables¶
PHP actions have access to all standard DDEV environment variables:
<?php
// Project Information
$_ENV['DDEV_PROJECT'] // Project name
$_ENV['DDEV_PROJECT_TYPE'] // 'drupal', 'wordpress', 'laravel', etc.
$_ENV['DDEV_APPROOT'] // '/var/www/html' (project root)
$_ENV['DDEV_DOCROOT'] // 'web', 'public', or configured docroot
$_ENV['DDEV_TLD'] // 'ddev.site' or configured TLD
// Technology Stack
$_ENV['DDEV_PHP_VERSION'] // '8.1', '8.2', '8.3', etc.
$_ENV['DDEV_WEBSERVER_TYPE'] // 'nginx-fpm', 'apache-fpm'
$_ENV['DDEV_DATABASE'] // 'mysql:8.0', 'postgres:16', etc.
$_ENV['DDEV_DATABASE_FAMILY'] // 'mysql', 'postgres'
// System Information
$_ENV['DDEV_VERSION'] // Current DDEV version
$_ENV['DDEV_MUTAGEN_ENABLED'] // 'true' or 'false'
?>
PHP Action Execution Environment¶
PHP actions run inside a temporary container, unlike Bash actions which run on the host.
- Working directory:
/var/www/html/.ddev(your project’s .ddev directory) - Project access: Full read/write access to project repository at
/var/www/html/ - Error handling: Automatic strict error handling (equivalent to Bash
set -eu) - Extensions: php-yaml extension for robust YAML processing
Advanced PHP Example: Conditional Configuration¶
name: conditional-config
pre_install_actions:
- |
<?php
#ddev-description: Generate environment-specific configuration
$projectType = $_ENV['DDEV_PROJECT_TYPE'];
$services = [];
// Different services based on project type
switch($projectType) {
case 'drupal':
$services['redis'] = [
'image' => 'redis:7-alpine',
'ports' => ['6379:6379']
];
break;
case 'wordpress':
$services['memcached'] = [
'image' => 'memcached:alpine',
'ports' => ['11211:11211']
];
break;
default:
$services['cache'] = [
'image' => 'nginx:alpine'
];
}
$composeContent = ['services' => $services];
file_put_contents('docker-compose.conditional.yaml',
"#ddev-generated\n" . yaml_emit($composeContent));
echo "Generated configuration for $projectType project\n";
?>
Separate PHP Script Files (Best Practice)¶
For complex logic, create separate PHP script files using your add-on’s namespace:
File structure:
Clean install.yaml:
name: myservice
project_files:
- myservice/scripts/setup.php
- myservice/scripts/configure.php
post_install_actions:
- |
<?php
#ddev-description: Configure project
require 'myservice/scripts/setup.php';
- |
<?php
#ddev-description: Apply optimizations
require 'myservice/scripts/configure.php';
myservice/scripts/setup.php:
<?php
#ddev-generated
$projectType = $_ENV['DDEV_PROJECT_TYPE'];
$docroot = $_ENV['DDEV_DOCROOT'];
// Exit early if not applicable
if ($projectType !== 'drupal') {
echo "Not a Drupal project, skipping\n";
exit(0);
}
// Perform Drupal-specific setup
$settingsFile = "/var/www/html/{$docroot}/sites/default/settings.ddev.php";
$settings = "<?php\n// DDEV-generated settings\n";
file_put_contents($settingsFile, $settings);
echo "Drupal settings configured\n";
Real-world example: ddev-redis structure¶
.ddev/
├── install.yaml
├── docker-compose.redis.yaml
└── redis/
└── scripts/
├── setup-drupal-settings.php
├── setup-redis-optimized-config.php
└── settings.ddev.redis.php
Mixed Bash and PHP Actions¶
You can combine both approaches in a single add-on:
name: mixed-actions
pre_install_actions:
- |
#ddev-description: Set file permissions
chmod +x .ddev/commands/web/mycommand
- |
<?php
#ddev-description: Process configuration
$projectName = $_ENV['DDEV_PROJECT'];
echo "Processing config for: $projectName\n";
?>
Advanced Features¶
Customizing ddev describe Output¶
Add-ons can customize what appears in ddev describe using x-ddev.describe-* extensions in your docker-compose file (requires DDEV v1.24.10+):
services:
myservice:
image: myimage:latest
x-ddev:
describe-url-port: "https://${DDEV_HOSTNAME}:8080"
describe-info: "API key: ${MYSERVICE_API_KEY}"
See Customizing ddev describe output for full details, and the x-ddev Extension for all supported keys.
Minor Docker Image Customization¶
For small tweaks to an existing image, use dockerfile_inline instead of maintaining a separate Dockerfile:
services:
myservice:
image: myimage:latest
build:
dockerfile_inline: |
FROM myimage:latest
RUN apt-get update && apt-get install -y mypackage
Tools That Update Themselves¶
If your add-on installs a tool in the web image and the tool updates itself, the update is lost at the next ddev restart, and users fall back to the slow ddev restart --no-cache. Use one of these approaches instead. Either way, keep the tool’s configuration and credentials apart from the program, as described in Persisting Changes Across Restarts.
Keep the Tool in ddev-global-cache¶
Install the tool at build time as the web container’s user, not as root. The script below can’t move files owned by root, so it would leave the tool in the home directory and its updates wouldn’t be kept. Switch users around the install, as in Installing into the home directory:
# .ddev/web-build/Dockerfile.mytool
#ddev-generated
USER $username
RUN curl -fsSL https://example.com/mytool/install.sh | bash
USER root
Then add a web-entrypoint.d/mytool.sh script to project_files. It’s the script from Persisting Home Subdirectories in ddev-global-cache with one change: cp -an adds files from a newer image but never replaces the stored ones, so the tool’s own updates are kept. This example is for a tool installed in ~/.mytool:
# .ddev/web-entrypoint.d/mytool.sh
#ddev-generated
# Keep mytool in ddev-global-cache so its own updates survive restarts.
# cp -n adds files from a newer image but keeps the stored ones.
(
for dir in .mytool; do
target="/mnt/ddev-global-cache/mytool/${HOSTNAME}/${dir}"
mkdir -p "${target}" "$(dirname ~/"${dir}")" &&
if [ -d ~/"${dir}" ] && [ ! -L ~/"${dir}" ]; then
cp -an ~/"${dir}"/. "${target}"/ && rm -rf ~/"${dir}"
fi &&
ln -sfn "${target}" ~/"${dir}" ||
echo "mytool.sh: unable to keep ~/${dir}" >&2
done
)
The first start copies the tool from the image, and after that the tool’s updater writes to the volume. Starting needs no network, and ddev delete removes the copy. Keep ${HOSTNAME} directly under your add-on’s directory, because ddev delete doesn’t look deeper. Only keep directories that belong to your tool: if two add-ons link a shared directory like ~/.local/bin, the last one wins. ddev-nvm also keeps ~/.nvm in ddev-global-cache/nvm_dir/${HOSTNAME}, but it copies from the image only on the first start, so a newer image doesn’t add anything.
To go back to the version in the image, users can run:
Pin the Version¶
Set the version in an ARG and release a new add-on version for each tool update. After ddev add-on get, a plain ddev restart rebuilds only from that step on. Turn off the tool’s own updater, because its updates would be lost.
Environment Variables¶
Users configure an add-on through the env files named after it, so .ddev/.env.myaddon reaches the myaddon service and, with DDEV v1.25.4+, the add-on’s own install actions as well.
To add variables to a service you don’t own, such as web, use a labeled file named after your add-on:
That reaches the web container, and two add-ons doing this leave each other’s file alone, which is not the case when both write .ddev/.env.web.
Ask for a token or any other secret in a .local file, like .ddev/.env.myaddon.local, which DDEV keeps out of Git.
Require v1.25.4 for labeled and .local files
Older DDEV versions don’t raise an error on .ddev/.env.<service>.<label> or .local files, they simply never read them, so the add-on installs cleanly and misbehaves later. If yours ships or reads one, say so in install.yaml:
Version Constraints¶
Specify minimum DDEV version requirements:
Dependencies¶
Declare add-on dependencies that will be automatically installed:
dependencies:
- ddev/ddev-redis # GitHub repository
- https://example.com/addon.tar.gz # Direct tarball URL
Dependencies are automatically installed when the add-on is installed. If a dependency is missing, DDEV will:
- Automatically install it using the same formats supported by
ddev add-on get - Detect circular dependencies and prevent infinite loops
- Install recursively - dependencies of dependencies are also installed
To skip automatic dependency installation, use the --skip-deps flag:
This does not install dependencies. Ensure required dependencies are present if your add-on relies on them.
Runtime Dependencies (Advanced)¶
Advanced Feature
Runtime dependencies are an advanced, rarely-used feature for sophisticated add-ons that need to dynamically discover dependencies during installation. Most add-ons should use static dependencies declared in install.yaml instead.
Runtime dependencies allow add-ons to dynamically discover and install dependencies during the installation process, rather than declaring them statically. This enables complex scenarios like:
- Service detection - Analyzing project configuration to determine needed services
- Conditional dependencies - Installing different add-ons based on project analysis
- Dynamic configuration processing - Dependencies determined by parsing external files
How Runtime Dependencies Work¶
- Detection Phase: During pre-install or post-install actions, your add-on analyzes the project
- Creation Phase: Your add-on creates a
.runtime-deps-<addon-name>file listing discovered dependencies - Processing Phase: After installation completes, DDEV automatically processes runtime dependencies
- Installation Phase: DDEV installs any missing dependencies and cleans up the runtime dependencies file
Creating Runtime Dependencies¶
Create a .runtime-deps-<addon-name> file in the project’s .ddev directory with one dependency per line:
Example: Dynamic service detection in post-install action¶
name: my-dynamic-addon
post_install_actions:
- |
<?php
#ddev-description: Detect required services dynamically
$services = [];
// Analyze project configuration
if (file_exists('/var/www/html/.platform.yaml')) {
$config = yaml_parse_file('/var/www/html/.platform.yaml');
// Check for Redis usage
if (isset($config['services']['cache']['type']) &&
strpos($config['services']['cache']['type'], 'redis') !== false) {
$services[] = 'ddev/ddev-redis';
}
// Check for Elasticsearch usage
if (isset($config['services']['search']['type']) &&
strpos($config['services']['search']['type'], 'elasticsearch') !== false) {
$services[] = 'ddev/ddev-elasticsearch';
}
}
// Create runtime dependencies file if services were found
if (!empty($services)) {
$runtimeDepsFile = '.runtime-deps-my-dynamic-addon';
file_put_contents($runtimeDepsFile, implode("\n", $services) . "\n");
echo "Created runtime dependencies for " . count($services) . " service(s)\n";
}
?>
Runtime Dependencies File Format¶
The .runtime-deps-<addon-name> file uses the same dependency formats as static dependencies:
# One dependency per line
ddev/ddev-redis
ddev/ddev-elasticsearch
https://example.com/addon.tar.gz
# Comments and empty lines are ignored
Processing Timing¶
Runtime dependencies are processed after all installation phases complete:
- Pre-install actions execute
- Project files are copied
- Global files are copied
- Post-install actions execute
- Runtime dependencies are processed ← This happens last
- Cleanup occurs
This timing ensures that:
- Add-ons can analyze the fully installed project state
- Post-install actions can create runtime dependencies based on project configuration
- Dependencies have access to all project files when they install
Real-world Example: Upsun Integration¶
The ddev-upsun add-on demonstrates runtime dependencies by:
- Analyzing
.upsun/config.yamlduring post-install - Detecting services like Redis, Elasticsearch, Memcached
- Creating runtime dependencies for corresponding DDEV add-ons
- Automatically installing the required service add-ons
<?php
// Simplified example from ddev-upsun
$detectedServices = analyzeUpsunConfig('/var/www/html/.upsun/config.yaml');
$dependencies = [];
foreach ($detectedServices as $service) {
switch ($service['type']) {
case 'redis':
$dependencies[] = 'ddev/ddev-redis';
break;
case 'opensearch':
$dependencies[] = 'ddev/ddev-opensearch';
break;
}
}
if (!empty($dependencies)) {
file_put_contents('.runtime-deps-upsun', implode("\n", $dependencies));
}
?>
When NOT to Use Runtime Dependencies¶
Use static dependencies instead if:
- Dependencies are always required
- Dependencies don’t change based on project analysis
- You want simpler, more predictable behavior
Use runtime dependencies only if:
- Dependencies must be determined by analyzing project files
- Different projects need different dependencies
- You’re integrating with external platform configurations
Debugging Runtime Dependencies¶
Add verbose logging to debug runtime dependency processing:
This will show:
- When runtime dependencies files are created
- What dependencies are discovered
- Installation progress for each dependency
Limitations¶
- Runtime dependencies cannot create circular dependency loops
- The
.runtime-deps-*file is automatically deleted after processing - Runtime dependencies are not processed when using
--skip-deps - Cannot be used to conditionally install the add-on itself
Template Replacements (Advanced, Very Unusual)¶
Use environment variables in filenames and content:
YAML File Processing¶
(Using YAML file processing is very unusual.)
yaml_read_files reads external YAML files and makes their contents available as Go template variables in Bash actions. This is a Go template feature and cannot be used inside PHP actions.
yaml_read_files:
platformapp: ".platform.app.yaml"
services: ".platform/services.yaml"
post_install_actions:
- |
#ddev-description: Configure PHP version from platform config
cat <<EOF >${DDEV_APPROOT}/.ddev/config.platformsh.yaml
php_version: {{ trimPrefix "php:" .platformapp.type }}
EOF
Error Handling¶
Use proper exit codes and error messages:
<?php
#ddev-description: Validate requirements
if (empty($_ENV['DDEV_PROJECT'])) {
echo "Error: DDEV environment not available\n";
exit(1);
}
// Continue with setup...
echo "Requirements validated\n";
?>
Special Directives¶
The #ddev-generated Comment¶
Add #ddev-generated as a comment in any file your add-on creates or copies into a project. DDEV uses this marker to:
- Track managed files - on a subsequent
ddev add-on get, DDEV replaces the file automatically if it contains#ddev-generated, even if the user has modified it. Files with#ddev-generatedthat are listed in theproject_filesorglobal_fileswill be automatically removed onddev add-on removeif the#ddev-generatedis found in the file. - Enable clean removal -
ddev add-on removedeletes all files that still contain#ddev-generated
Files that do not contain this comment are left in place during removal (assumed to be user-modified). The removal_actions section is the place to clean up any such files.
In PHP actions, include it as the first line of generated files:
In JSON files, add it as a property:
Description Display¶
Add descriptions to your actions:
Warning Exit Codes¶
Treat specific exit codes as warnings instead of errors:
post_install_actions:
- |
#ddev-warning-exit-code: 2
#ddev-description: Optional configuration
some-command-that-might-fail
Interactive Actions¶
Add #ddev-interactive to a Bash action that needs direct terminal input or live output. DDEV runs these actions interactively only when stdin is a terminal and JSON output is disabled. In non-interactive environments, such as CI, DDEV keeps the standard buffered action behavior.
post_install_actions:
- |
#ddev-description: Configure API credentials
#ddev-interactive
if [ "$(ddev dotenv get .ddev/.env.myaddon.local --api-token 2>/dev/null)" = "" ]; then
read -p "Enter your API token: " TOKEN
ddev dotenv set .ddev/.env.myaddon.local --api-token="${TOKEN}"
fi
Testing Your Add-on¶
Bats Testing Framework¶
The add-on template includes a tests/test.bats file for testing:
#!/usr/bin/env bats
setup() {
set -eu -o pipefail
# Override this variable for your add-on:
export GITHUB_REPO=owner/repo
# ...
}
health_checks() {
# Do something useful here that verifies the add-on
# You can check for specific information in headers:
run curl -sfI https://${PROJNAME}.ddev.site
assert_output --partial "HTTP/2 200"
assert_output --partial "test_header"
# Or check if some command gives expected output:
DDEV_DEBUG=true run ddev launch
assert_success
assert_output --partial "FULLURL https://${PROJNAME}.ddev.site"
}
@test "install from directory" {
set -eu -o pipefail
echo "# ddev add-on get ${DIR} with project ${PROJNAME} in $(pwd)" >&3
run ddev add-on get "${DIR}"
assert_success
run ddev restart -y
assert_success
health_checks
}
# bats test_tags=release
@test "install from release" {
set -eu -o pipefail
echo "# ddev add-on get ${GITHUB_REPO} with project ${PROJNAME} in $(pwd)" >&3
run ddev add-on get "${GITHUB_REPO}"
assert_success
run ddev restart -y
assert_success
health_checks
}
Run tests in the add-on root directory:
Manual Testing¶
- Create a test DDEV project
- Install your add-on locally:
- Verify services start correctly
- Test configuration options
- Test removal process
Publishing Your Add-on¶
Repository Setup¶
- Test thoroughly using the test framework
- Create proper releases with semantic versioning
- If you expect the add-on to be maintained and useful to others, add the
ddev-gettopic to your GitHub repository. - Write clear documentation in your readme
- Include examples and configuration options
Making it Official¶
To become an officially supported add-on:
- Open an issue in the DDEV repository
- Request upgrade to official status
- Commit to maintaining the add-on
- Subscribe to repository activity and be responsive
Keeping Your Add-on Up to Date¶
The ddev-addon-template evolves over time - workflows, test scripts, tooling, and other components get updated. Run the ddev utility addon-update-checker command regularly from your add-on directory (or a workspace containing multiple add-ons) to check whether your add-on is up to date with the template and current best practises:
Best Practices¶
- Follow semantic versioning for releases
- Maintain backward compatibility when possible
- Test with different DDEV versions
- Update dependencies regularly - use
ddev utility addon-update-checkerto keep scripts and tooling in sync with the template - Respond to user issues promptly
- Keep documentation up to date
- Use namespaced directories (e.g.,
myservice/scripts/not justscripts/) - Expect changes inside the container to be lost on restart, see Tools That Update Themselves
Examples and References¶
- Add-on Template: ddev-addon-template
- Official Add-ons: Browse examples at addons.ddev.com
- Redis Add-on: ddev-redis - Example of add-on with
removal_actionsand namespaced scripts directory
Getting Help¶
- Maintenance Guide: DDEV Add-on Maintenance Guide
- DDEV Discord: Join DDEV Discord for development support
- GitHub Issues: Use DDEV Issues for problems and questions
- Add-on Trainings: DDEV Add-ons: Creating, maintaining, testing (YouTube) and Advanced Add-On Techniques
Creating DDEV add-ons is a powerful way to contribute to the DDEV ecosystem. Whether you use traditional Bash actions or the new PHP-based actions, you can create sophisticated extensions that help developers worldwide.