Configuring .drupalaibp/
A config is a *.json file inside a .drupalaibp/ directory in the place
you launch the installer from. When one is present, the installer reads it and
pre-configures the whole build — project type, coding agents, agent skills,
provisioning (Composer packages, modules, recipes, DDEV add-ons), custom
questions, and hook scripts fired at named build events.
# in a directory containing .drupalaibp/config.json
bash <(curl -fsSL https://project.pages.drupalcode.org/one_line_installer/drupalaibp)
Upgrading from 1.x? The single root
.drupalaibp.jsonis no longer read. Move it into the directory:mkdir .drupalaibp && git mv .drupalaibp.json .drupalaibp/config.json.
The structural choices (profile, agents, skills, deny rules, provisioning) come
from the config and aren't prompted. The config's questions are still asked
interactively, each with its default pre-filled, so you can review or adjust
them. Add --yolo to take every default silently and run fully unattended.
There are two complete, runnable examples in
docs/examples/
— a Drupal site with provisioning, and a non-Drupal Node/Storybook project.
Where a config lives, and picking one
Every *.json directly under .drupalaibp/ is a named config — the file's
basename (without .json) is its name. The simplest project has a single
.drupalaibp/config.json.
- One config in
.drupalaibp/is used automatically. - Several configs — pass
--config <name>(the basename, no.json) to pick one, or choose interactively from the list. In an unattended run (--yolo, or a source loaded with no terminal) a multi-config directory with no--configis a hard error that names the available configs.
The same .drupalaibp/ directory can be loaded from several places — the launch
directory (auto-detected), a positional source drupalaibp <source>, or the
--config-path/--config-url/--global-config flags. See the
setup-site reference for the full source list and
flag equivalences.
Git sources and refs (--config-url)
A git URL source (drupalaibp <url> or --config-url <url>) is cloned and its
.drupalaibp/ read. Pin a branch, tag, or ref two ways:
- an
@<ref>suffix on the URL —drupalaibp https://git.example.org/acme/site@1.2.x; - the
--ref <ref>flag —--config-url https://git.example.org/acme/site --ref 1.2.x.
A ref may be a branch name, a tag, a full refs/… ref, or !<iid> for a GitLab
merge request (!64 → refs/merge-requests/64/head).
Paths inside a config resolve relative to the config file
Because the config file sits inside .drupalaibp/, every config-relative
path — hooks[].script, copy_paths, db_import_path — resolves against that
.drupalaibp/ directory (CONFIG_BASEDIR). Reference a script or asset that
lives next to the config as "./seed.sh" (→ .drupalaibp/seed.sh), and use a
leading "../" to reach the repo root. (This differs from the pre-2.x root
.drupalaibp.json, where such paths were relative to the repo root.)
Three things to get right
!!! warning "1. Drupal projects need a Drupal ddev_project_type"
For a Drupal build (is_drupal: true, the default), ddev_project_type
must be a Drupal type — "drupal11" (default), "drupal10",
"drupal12", and so on. It is passed straight to
ddev config --project-type. Using "php" or another generic type for a
Drupal site will not configure Drupal correctly.
!!! warning "2. Non-Drupal builds ignore the Drupal-only keys"
If you set is_drupal: false, the keys profile, site_template,
install_modules and recipes do nothing — there's no
composer create-project, drush, or config export. A non-Drupal build
still sets up DDEV, coding agents, skills, ddev_addons and hooks; drive the
actual project setup with hooks (e.g. npm/composer commands run in
the web container). Set ddev_project_type to a non-Drupal DDEV type such
as "php".
!!! tip "3. Keep hook scripts next to the config"
Store hook scripts in the same .drupalaibp/ directory as the config
and reference them as "./<name>.sh" (paths resolve relative to the config
file, which lives inside .drupalaibp/). Paths must be whitespace-free and
are copied into the new project root before they run. Use a leading "../"
only if a script deliberately lives at the repo root.
Full schema
{
// project shape
"ddev_project_type": "drupal11", // Drupal: drupal11/drupal10/drupal12… Non-Drupal: e.g. "php"
"is_drupal": true, // false => skip all Drupal steps
"docroot": "web", // ddev config --docroot. Default: "web" (Drupal) / "." (non-Drupal)
"launch_path": "", // ddev launch argument override, e.g. ":6006" for a dev-server port
// Drupal-only (ignored when is_drupal:false)
"profile": "cms", // cms (default) | standard | minimal | template
"site_template": "", // site-template project name, when profile=template
"install_modules": ["ai", "token"],
"recipes": ["drupal/drupal_cms_blog"], // apply-only (drush recipe) — fetch contrib recipes via composer_projects
// agents & skills
"agents": ["claude"], // claude | codex | opencode — empty = bring your own
"agent_auto_mode": false, // true => bake each agent's unattended/auto-approval flag into `ddev <agent>`
"extra_scripts": ["tool_api"], // ids of "Extra Drupal Installations" (src/lib/extras/) to run
"skills": [
"obra/superpowers", // "owner/repo" = all skills in the repo
{ "repo": "ivanboring/drupal-module-finder", "skills": ["drupal-module-finder"] }
],
"deny_commands": ["git push", "ssh"],
// composer
"composer_stability": "dev", // minimum-stability (prefer-stable always on)
"composer_with_all_dependencies": false, // true => composer_projects require gets --with-all-dependencies (-W)
"composer_allow_plugins": ["tbachert/spi"],
"repositories": [
{ "type": "vcs", "url": "https://github.com/acme/private-module" }
],
"composer_projects": ["drupal/ai:^1.1", "acme/private-module:dev-main"],
// ddev add-ons
"ddev_addons": ["ddev/ddev-redis"],
// AGENTS.md additions
"agents_md": ["Project rule appended to the AGENTS.md managed block."],
// extra files/directories copied into the project root (for hook-consumed
// assets that aren't scripts themselves), same path convention as hooks —
// relative to the config file inside .drupalaibp/
"copy_paths": ["./seed-assets"],
// --config-url existing-codebase mode: the clone already IS the Drupal
// codebase (its own composer.json/web/), so `ddev composer install` +
// `ddev import-db` stand it up instead of scaffolding. Set at most ONE —
// db_import: true if the dump is supplied at runtime via --db-path (too
// large/sensitive to commit); db_import_path if it's committed in the repo.
"db_import": false,
"db_import_path": "", // e.g. "db/minimal.sql.gz", relative to the config file
// welcome-message additions (replace wins if both are set)
"append_welcome_text": ["Read CONTRIBUTING.md before your first commit."],
"replace_welcome_text": [],
// custom questions (answers -> .ddev/.env)
"questions": [
{ "env": "OPENAI_API_KEY", "label": "OpenAI API key", "type": "password" },
{ "env": "SITE_FLAVOR", "label": "Flavor", "type": "single-select-options",
"select_options": ["blog", "shop"], "default": "blog" },
{ "env": "SITE_ENV", "type": "text", "value": "demo" }
],
// hook scripts at build events (paths relative to the config file in .drupalaibp/)
"hooks": {
"ddev_started": [ { "script": "./seed.sh", "weight": 10, "where": "web" } ],
"coding_agent_added": [ { "script": "./agent.sh", "weight": 0, "where": "host" } ]
}
}
Key reference
| Key | Applies to | Notes |
|---|---|---|
ddev_project_type |
all | ddev config --project-type. Drupal builds must use drupal11/drupal10/drupal12/… (see point 1). |
is_drupal |
all | default true. false skips every Drupal step. |
docroot |
all | ddev config --docroot. Default "web" (Drupal) / "." (non-Drupal). |
launch_path |
all | ddev launch argument override, e.g. ":6006" for a dev-server daemon on its own port. Default: launches the project root. |
profile |
Drupal | cms (default), standard, minimal, template. |
site_template |
Drupal | site-template project short name (with profile: template). |
install_modules |
Drupal | enabled with drush. |
recipes |
Drupal | apply-only (drush recipe); never fetched. A drupal/<name> entry is applied from ../recipes/<name> — fetch it via composer_projects. Any other entry (e.g. core/recipes/standard) is passed to drush verbatim, so a core / already-present recipe applies with no fetch. |
agents |
all | coding agents installed into the container. |
agent_auto_mode |
all | default false. true prepends each agent's own unattended flag to its ddev <agent> wrapper (Claude --permission-mode auto, Codex --dangerously-bypass-approvals-and-sandbox, OpenCode --auto). Config-only — no CLI flag or prompt. |
extra_scripts |
all | ids of "Extra Drupal Installations" (src/lib/extras/extra_<id>.sh) to run — the config equivalent of --extras. |
skills |
all | single list; "owner/repo" (all) or {repo, skills:[…]} (["*"] = all). |
deny_commands |
all | commands the agent(s) may never run. |
composer_stability |
composer | minimum-stability; default dev. |
composer_with_all_dependencies |
composer | default false. true adds --with-all-dependencies (-W) to the composer_projects require, so Composer may update the whole dependency tree (needed when a package — e.g. a recipe you fetch here — requires newer shared deps). |
composer_allow_plugins |
composer | plugins to trust; is_drupal adds core-dev/ABP defaults. |
repositories |
composer | registered before composer_projects. |
composer_projects |
composer | fetch-only — verbatim composer require args, version constraints allowed (drupal/ai:^1.1); never applied/enabled. Fetch a contrib recipe here, then apply it via recipes. |
ddev_addons |
all | added after the site is built, then one ddev restart. |
agents_md |
all | paragraphs appended to the managed AGENTS.md block. |
copy_paths |
all | extra files/directories copied into the project root, same path convention as hooks[].script — for hook-consumed assets that aren't scripts themselves. |
db_import |
--config-url |
default false. true selects existing-codebase mode (the clone IS the Drupal codebase) with the dump supplied at runtime via --db-path. Mutually exclusive with db_import_path. |
db_import_path |
--config-url |
a dump committed in the repo, path relative to the config file — same existing-codebase mode as db_import, but resolved automatically, no --db-path needed. |
append_welcome_text |
all | paragraph(s) appended to the end of the print_welcome summary. Ignored if replace_welcome_text is set. |
replace_welcome_text |
all | paragraph(s) that replace the entire print_welcome summary (no admin login, no handy commands, no DDEV footer) — takes precedence over append_welcome_text. |
questions |
all | see below; answers → .ddev/.env. |
hooks |
all | see below. |
Welcome-text placeholders and colors
Both append_welcome_text and replace_welcome_text support {TOKEN}
substitution:
Built-in tokens:
| Token | Value |
|---|---|
{PROJECT_NAME} |
the DDEV project name (lowercased directory name) |
{SITE_URL} |
https://<project>.ddev.site |
{DRUPAL_USERNAME} |
admin |
{DRUPAL_PASSWORD} |
admin |
{DRUPAL_LOGIN_URL} |
{SITE_URL}/user |
Env-var tokens: any questions[].env answer is addressable by its own
name, e.g. {OPENAI_API_KEY}, {SITE_FLAVOR}.
{
"append_welcome_text": [
"Environment: {SITE_ENV} • Flavor: {SITE_FLAVOR}",
"Log in at {DRUPAL_LOGIN_URL} with {DRUPAL_USERNAME} / {DRUPAL_PASSWORD}."
]
}
!!! warning "Unknown tokens print literally"
A {TOKEN} that isn't a built-in or a questions[].env name is left
untouched in the output (not emptied) — this surfaces a typo instead of
silently hiding it.
!!! warning "Secrets land on the terminal"
A password-type question's answer, if referenced by a welcome-text
placeholder, is echoed to the terminal in the final build summary. Don't
reference a genuinely sensitive value this way unless you're fine with it
appearing in that output.
Colors: {BOLD}, {DIM}, {GREEN}, {YELLOW}, {RED}, {RESET}
expand to the installer's own color codes when connected to a real terminal,
and to nothing when output isn't a TTY (piped/redirected), so logs stay
clean. Every paragraph also gets an automatic trailing reset, so an unclosed
color token can't bleed into the next paragraph or the rest of the terminal.
{
"append_welcome_text": "{BOLD}{GREEN}Welcome to the ACME demo!{RESET} Docs: https://example.com/handbook"
}
Questions
Each question object uses env (the environment-variable name — a valid
identifier), label, type, and optionally default, select_options,
value, depends_on.
- Types:
text,password,single-select-options,multi-select-options. select_options— the choice list for the two select types.default— pre-fills an editable prompt (still shown).value— pre-answers the question, so it is not shown. Usedefault(notvalue) if you want a question prompted with a suggestion.- Answers land in
.ddev/.env(mode600, gitignored). Never put a real secret invalue— leave it for the user to type.
Conditional questions (depends_on)
A question can be shown only when an earlier question's answer matches a condition:
{
"env": "REDIS_TTL",
"label": "Redis cache TTL (seconds)",
"type": "text",
"default": "3600",
"depends_on": { "env": "USE_REDIS", "equals": "yes" }
}
depends_on.envmust name a question that appears earlier in thequestionsarray. A forward reference, self-reference, or a name that doesn't match any earlier question is invalid: it's ignored with a warning and the question is asked unconditionally instead of being dropped.- Condition kinds (exactly one required):
"equals": "<value>","not_equals": "<value>", or"in": ["a", "b", ...](the dependency's answer is one of the listed values — useful when a select has more than two meaningful options). - A question whose condition isn't met is not shown, and its
ANSWERSvalue is set to an empty string — still written to.ddev/.envasKEY="", so hooks and welcome-text tokens can test for emptiness rather than the variable being entirely undefined. valuestill short-circuits a question beforedepends_onis ever evaluated for it — a pre-answered question is never conditionally skipped, it's simply always answered fromvalue.
!!! warning "Any depends_on disables .ddev/.env reuse for the whole block"
Normally a question already answered in a previous run (its key already
present in .ddev/.env) is reused silently instead of being re-prompted —
this is what makes a re-run in management mode not re-ask everything. A
conditional question's visibility has to reflect the current answer of
its dependency, not a possibly-stale one from a previous run — so the
presence of any depends_on anywhere in a config's questions disables
that reuse for the entire array, on every run, including questions with
no depends_on of their own and password questions. A config with no
conditional questions is unaffected. If you add one conditional question
to a config, every question in that config (including secrets) will be
re-prompted on every future re-run of that config.
Hooks and events
Each event name maps to a list of { script, weight, where }:
weight— integer; lower runs first.where—host,web(default; runs in the DDEV web container viaddev exec), or a named DDEV service.
!!! warning "Pre-start hooks must use where: host"
web/service hooks need the container running. A hook on a pre-start event
(before_ddev_start, ddev_addon_added, coding_agent_added, or anything
before ddev_started) must set "where": "host".
!!! warning "Question answers are only in the container"
Answers from questions are written to .ddev/.env, which DDEV injects into
the web container — not the host. A "where": "web" hook sees them as
environment variables ($OPENAI_API_KEY, …); a "where": "host" hook does
not. If a hook needs a question's answer, run it in web.
Available events: before_<step> / <step>_done for every pipeline step, plus
the semantic events before_ddev_start, ddev_started, coding_agent_added,
ddev_addon_added, skill_added, and ask_questions. The most common are
ddev_started (the site is up — run drush/composer/npm in web) and
coding_agent_added (host-side — tweak AGENTS.md).
!!! note "Hook script and copy_paths staging timing (Drupal fresh-scaffold builds)"
For is_drupal: true fresh-scaffold builds (without db_import or
db_import_path), copy_paths and most hook scripts are staged after
Drupal scaffolding completes (create_drupal_site_done), not before.
This is because ddev composer create-project refuses a non-empty project
directory.
**Exception: hook scripts on early events.** A hook script registered on an
event that fires **before** Drupal's scaffold
(`before_ddev_start`, `ddev_started`, `coding_agent_added`,
`ask_questions`, or `before_create_drupal_site` itself)
is staged early, but physically lives **only** at
`.ddev/drupalaibp-hooks/<configured-path>` in the final scaffolded
project — **not** at the `script` path written in the config.
This is a real, user-visible difference from every other hook script
(which does land at its configured path in the project root):
an early hook is classified as such and never additionally copied to where
a late hook would be. If you inspect the scaffolded project, an early
hook's script will only be found under `.ddev/`, not in the root.
**Existing-codebase mode and non-Drupal builds are unaffected:**
When `db_import` or `db_import_path` is set, or when `is_drupal: false`,
all hook scripts and `copy_paths` use the original timing — no
deferral, no `.ddev/`-only staging.
Importing Drupal config (.drupalaibp/config/)
Drupal-only. Drop a .drupalaibp/config/ directory (a config/ subdirectory
inside .drupalaibp/, alongside the config JSON) containing exported Drupal
config YAML, and after the site is installed and provisioned the installer runs a
partial drush config:import from it:
my-launch-dir/
└── .drupalaibp/
├── config.json # the installer config
└── config/
└── system.site.yml # e.g. override the site name
It's a partial import — the provided config is applied on top of the
installed site; config not present in the directory is left alone (nothing is
deleted). This runs after provision_from_config, so config that references
modules from install_modules imports cleanly. Ignored for is_drupal: false
builds and when the directory has no *.yml. See docs/examples/drupal/ for a
working system.site.yml.
Good to know
- selenium / toolbelt / dtk are not flags — they're ordinary provisioning:
Selenium as a
ddev_addonsentry (a built-in handler makes Chrome headed with noVNC on port 7900), Toolbelt/DTK ascomposer_projects+install_modules. - AI Best Practices + core-dev (
drupal/ai_best_practices,drupal/core-dev, and a starterphpunit.xml) are built-in defaults for everyis_drupal: truebuild — you don't list them.ai_best_practicesis installed and its Composer plugin allow-listed automatically. - The experimental Tool API + Toolbelt (
drupal/tool+drupal/tool_belt, so agents can run Drupal actions viadrush tool:*) is suggested during an interactive build (or--tool-api). In a config, add it throughcomposer_projects+install_modules(+tbachert/spiincomposer_allow_plugins). - Build order: DDEV starts vanilla, the Drupal site is created, then
ddev_addonsare added and applied with a single restart (some add-ons write files outside.ddev/that would otherwise breakcomposer create-project). - To replace the whole
AGENTS.mdblock content, drop anoverrides/agents_md.jsonnext to the config.
Examples
See the runnable examples and their hook scripts under docs/examples/:
docs/examples/drupal/—is_drupal: true, Drupal CMS on Drupal 11, with a private repo, version-constrained composer projects, modules, a recipe, the Selenium + Redis add-ons, password + select questions, and.drupalaibp/hook scripts onddev_startedandcoding_agent_added.docs/examples/storybook/—is_drupal: false, a Node/Storybook project built entirely throughwhere: webnpm hooks; every question type drives the npm setup.