Skip to content

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

  1. Use the ddev-addon-template repository
  2. Click “Use this template” to create your own repository
  3. Customize the install.yaml file - see the example
  4. Add the #ddev-generated comment to all files installed by the add-on
  5. Test with Bats
  6. Create a release when ready
  7. If you expect to support and maintain the add-on, add the ddev-get topic 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 in ddev add-on commands
  • pre_install_actions: Scripts executed before files are copied
  • project_files: Files copied to the project’s .ddev directory
  • global_files: Files copied to the global configuration directory
  • post_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 required
  • dependencies: Other add-ons this add-on depends on
  • yaml_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:

.ddev/
├── install.yaml
└── myservice/
    └── scripts/
        ├── setup.php
        └── configure.php

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:

ddev exec 'rm -rf /mnt/ddev-global-cache/mytool/${HOSTNAME}'
ddev restart

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:

ddev dotenv set .ddev/.env.web.myaddon --api-url=https://example.com

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:

ddev_version_constraint: '>= v1.25.4'

Version Constraints

Specify minimum DDEV version requirements:

ddev_version_constraint: '>= v1.25.2'

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:

  1. Automatically install it using the same formats supported by ddev add-on get
  2. Detect circular dependencies and prevent infinite loops
  3. Install recursively - dependencies of dependencies are also installed

To skip automatic dependency installation, use the --skip-deps flag:

ddev add-on get --skip-deps my-addon

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

  1. Detection Phase: During pre-install or post-install actions, your add-on analyzes the project
  2. Creation Phase: Your add-on creates a .runtime-deps-<addon-name> file listing discovered dependencies
  3. Processing Phase: After installation completes, DDEV automatically processes runtime dependencies
  4. 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:

  1. Pre-install actions execute
  2. Project files are copied
  3. Global files are copied
  4. Post-install actions execute
  5. Runtime dependencies are processed ← This happens last
  6. 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:

  1. Analyzing .upsun/config.yaml during post-install
  2. Detecting services like Redis, Elasticsearch, Memcached
  3. Creating runtime dependencies for corresponding DDEV add-ons
  4. 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:

ddev add-on get --verbose your-addon

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:

project_files:
  - "settings.${DDEV_PROJECT}.php"

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:

  1. 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-generated that are listed in the project_files or global_files will be automatically removed on ddev add-on remove if the #ddev-generated is found in the file.
  2. Enable clean removal - ddev add-on remove deletes 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.

# docker-compose.myservice.yaml
#ddev-generated
services:
  myservice:
    image: myimage:latest

In PHP actions, include it as the first line of generated files:

file_put_contents('docker-compose.myservice.yaml', "#ddev-generated\n" . yaml_emit($config));

In JSON files, add it as a property:

{
    "#ddev-generated": true
}

Description Display

Add descriptions to your actions:

#ddev-description: Installing Redis configuration
<?php
#ddev-description: Processing project settings
?>

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:

bats ./tests/test.bats --filter-tags '!release'

Manual Testing

  1. Create a test DDEV project
  2. Install your add-on locally:
ddev add-on get /path/to/your/addon
  1. Verify services start correctly
  2. Test configuration options
  3. Test removal process

Publishing Your Add-on

Repository Setup

  1. Test thoroughly using the test framework
  2. Create proper releases with semantic versioning
  3. If you expect the add-on to be maintained and useful to others, add the ddev-get topic to your GitHub repository.
  4. Write clear documentation in your readme
  5. Include examples and configuration options

Making it Official

To become an officially supported add-on:

  1. Open an issue in the DDEV repository
  2. Request upgrade to official status
  3. Commit to maintaining the add-on
  4. 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:

ddev utility addon-update-checker

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-checker to 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 just scripts/)
  • Expect changes inside the container to be lost on restart, see Tools That Update Themselves

Examples and References

Getting Help

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.