From Lando to DDEV: Replacing Custom Shell Scripts with Drainpipe

A Drupal project migration from Lando to DDEV replaced complex shell scripts with Drainpipe tasks, creating a more standardized, documented, and maintainable developer workflow.

Long-running Drupal projects accumulate local development debt. For example, say you start using Lando for local development. You likely have a .lando.yml file and a couple of helper scripts. Over time, those scripts grow. They might source a shared utilities library, parse their own flags, write to log files, or prompt for input.

By the time a new developer joins the project, getting set up locally means reading several files to figure out which command to run first, then debugging the one that fails because of an environment assumption that was never documented.

At Lullabot, we recently migrated a large Drupal project from Lando to DDEV and replaced its custom shell scripts with Drainpipe. This article covers what drove that decision, how we approached the migration, and what we learned.

The problem with inherited Lando setups

Lando works. That is not the issue. However, as with any long-term setup, it can become a bit complex to manage after several years of organic growth.

A typical pattern: the .lando.yml defines multiple services (PHP, MySQL, maybe a Node service for a frontend build), each with custom build scripts and post-start hooks. The actual logic lives in shell scripts that are sourced from a shared utilities file that parses the Lando configuration into variables. To understand why a step fails, you trace through three layers of shell sourcing.

There is no central registry of commands. Running lando with no arguments shows the tooling entries from .lando.yml, but the descriptions are usually one-liners that do not explain prerequisites or what the command actually does. To understand lando drupal-sync-db, you read sync.sh. To understand why sync.sh behaves differently on a fresh install than on an existing one, you find the conditional buried on line 47.

Every new developer spends time reconstructing knowledge that the team has collectively forgotten. This is not unique to Lando, since any local dev setup tends to drift toward opacity, but it is worth mentioning before discussing the alternative.

Why standardize on DDEV

The Drupal ecosystem has largely converged on DDEV. The 2026 CraftQuest Community Survey found DDEV holds 72% market share for Drupal local development environments. That near-standardization has practical consequences: better documentation, more community-maintained extensions, and less time explaining your setup to people who have never seen it before.

Lullabot formalized this recommendation in a 2021 Architecture Decision Record, and the commitment goes further than a policy document. Lullabot’s VP of Technology, Andrew Berry, sits on the DDEV Foundation board, and Lullabot makes monthly financial contributions to the project (and you can too!). We recommend DDEV in part because we believe in funding the infrastructure we depend on.

DDEV also has stronger cross-platform consistency than Lando. Its project-type presets handle sensible defaults for a new Drupal project. DDEV configures PHP version handling, Drush commands (Drush is the command-line tool for Drupal), and file permissions without custom logic. The base configuration for a Drupal 11 project is minimal:

name: myproject

type: drupal11

docroot: docroot

php_version: '8.4'

database:

  type: mysql

  version: '8.0'

composer_version: '2'

xdebug_enabled: false

web_environment:

  - 'DRUSH_OPTIONS_URI=https://myproject.ddev.site'

That is the entire DDEV configuration. For most Drupal projects, it is enough to start.

Replacing shell scripts with Drainpipe

DDEV solves the container environment. It does not solve the workflow, such as the commands developers run to download a database, import it, apply updates, and validate code. That is where Drainpipe comes in.

Drainpipe is a Composer package from Lullabot that provides a Task-based workflow for Drupal projects. Task is a task runner similar to Make, but defined in YAML. Drainpipe ships task definitions for common operations such as drupal:updatedrupal:import-dbdrupal:composer:development, and others, which projects can include and extend. A project-level Taskfile.yml includes those bundles and adds project-specific tasks on top:

version: '3'
dotenv: ['.env', '.env.defaults']
includes:
  deploy: ./vendor/lullabot/drainpipe/tasks/deploy.yml
  drupal: ./vendor/lullabot/drainpipe/tasks/drupal.yml
  snapshot: ./vendor/lullabot/drainpipe/tasks/snapshot.yml
  test:
    taskfile: ./vendor/lullabot/drainpipe-dev/tasks/test.yml
    optional: true
tasks:
  sync:
    desc: "Sync a database from production and import it"
    cmds:
      # Replace this with a command to fetch your database.
      - ./vendor/bin/drush site:install -y
      - echo "đŸ§¹ Sanitising database"
      - ./vendor/bin/drush sql:sanitize --yes
  # Drush aliases will be passed through e.g. task update site=@staging
  update:
    desc: "Runs the Drupal update process"
    cmds:
      - task: drupal:update
  validate:
    desc: "Run PHP linting and Drupal coding standards checks on custom code"
    cmds:
      - ./vendor/bin/parallel-lint -e php,module,inc,install docroot/modules/custom
      - ./vendor/bin/phpcs --standard=Drupal docroot/modules/custom

Every task has a desc field. Tasks with more complexity can add a summary block for extended documentation. That makes the workflow discoverable without reading source files:

ddev task --list
ddev task --summary sync
ddev task --summary setup:post-import

A developer who has never touched the project can run ddev task --list and see what exists. The documentation lives in the same file as the implementation.

Documenting site-specific quirks

Most Drupal projects that have been running in production for a few years have site-specific quirks that must be addressed during local setup. A particular post-update hook might crash unless a table exists first. Config import might need to run twice because of a dependency ordering issue. A specific module might need to be uninstalled before the configuration can be imported cleanly.

In shell scripts, these are comments, if they are documented at all. In a Taskfile, they become named tasks:

drupal:update:
    deps: [setup:pre-update]
    desc: Run Drupal update tasks after deploying new code
    cmds:
      - ./vendor/bin/drush {{.site}} --yes cache:rebuild
      - ./vendor/bin/drush {{.site}} --yes updatedb --no-cache-clear
      - ./vendor/bin/drush {{.site}} --yes config:import || true
      - ./vendor/bin/drush {{.site}} --yes config:import
      - ./vendor/bin/drush {{.site}} --yes deploy:hook
setup:pre-update:
  desc: "Ensure DB prerequisites are met before running drupal:update"
  summary: |
    Creates the xmlsitemap table if it does not exist. The remote database
    backup may not include it, which causes the xmlsitemap post-update hook
    to crash with "table doesn't exist". Also marks the problematic
    xmlsitemap_engines hook as already run to avoid a null-haystack error.
  cmds:
    - |
      ./vendor/bin/drush sql:cli <<'SQL'
      CREATE TABLE IF NOT EXISTS xmlsitemap ( ... );
      SQL
    - ./vendor/bin/drush php-eval "..."

The knowledge that previously lived in someone’s head, or in a comment inside a 300-line script, is now a named, described, searchable task. The next person who hits the same issue can read why the workaround exists.

How to approach the migration

Before introducing new tools, verify the existing Lando setup actually works. If Lando has not been used in a few months, there may be broken dependencies or outdated scripts. Fix those first so you have a clean baseline: the ability to download the database, import it, run a config import, and log in.

It is also worth checking production logs for errors that are failing silently. The migration to DDEV itself can surface latent issues that were never triggered in your existing setup. For example, on our recent project, a missing xmlsitemap database table had gone unnoticed under Lando, but caused drupal:update to crash once we ran it against a fresh database import during the switch to DDEV.

Once the baseline is stable, get DDEV running alongside Lando rather than replacing it immediately. The goal at this stage is simply for DDEV to start and for the site to load. You are not yet migrating anything. If your Lando setup is complex, a rough DDEV equivalent of your existing setup script is a reasonable intermediate step. It does not have to be clean, because you will not keep it.

With DDEV stable, the remaining work is translating shell scripts into Taskfile tasks. The translation itself is usually straightforward. The more interesting part is deciding which site-specific behaviors are worth documenting explicitly and which were workarounds for problems that no longer exist.

Tradeoffs to consider

Lando’s multi-service setup is more flexible without additional configuration. If your project runs multiple services (a separate Node container for a frontend build, a Redis container, a custom image), Lando’s service definitions are intuitive and well-documented. DDEV supports additional services via Docker Compose overrides, but the pattern requires more configuration.

Drainpipe is opinionated. It uses Task, and the task definitions it ships are written for a particular style of Drupal project. Teams with existing workflows built around make or npm scripts will need to decide how much of that to preserve and how much to replace.

Neither of these is a reason not to migrate. They are reasons to go in with accurate expectations about where the work actually is.

The daily workflow after migration

Once DDEV is working, the daily workflow might look like this:

ddev start
ddev auth ssh
ddev task sync

To update an existing local without re-importing the database:

ddev task update

To lint and run code standards checks:

ddev task validate

The value is not that any individual command is better than the one it replaced. It is that the commands are consistent, documented, and work the same way for every developer on the team. When something breaks, the failure is in a named task with a description, not in a chain of sourced shell files that nobody fully remembers.

 

Get in touch with us

Tell us about your project. We'd love to hear from you!