A multisite Drupal install on this cluster, built on the wodby/drupalโ base image. One pod (php-fpm + nginx sidecars) serves every site; the multisite router (web/sites/sites.php) maps host โ site at request time.
PR โ merge โ publish โ deploy, ref-driven images (branch โ :latest, version
tag โ :vX.Y.Z), with a changelog gate on every PR. Full flow in
CONTRIBUTING.mdโ ; agent-focused summary in AGENTS.mdโ ;
release notes in CHANGELOG.mdโ .
A custom image (kubed/drupal:11) is built on top of wodby/drupal:11. The build process:
hooks/, src/, and templates/ into the image (/hooks, /src, /templates)chmod +x /hooks/*.sh/hooks/install.sh as the wodby user โ installs contrib modules via ComposerBuilt and pushed by .github/workflows/image.yml. Bumping newTag in components/base/kustomization.yamlโ is what triggers the install Job to roll over.
Local build: docker-compose.yaml at the repo root lets you build and smoke-test the image locally (docker compose build). The multisite rendering pipeline (src/render-config.php) requires /sites/*.yaml files from a Kubernetes Secret, so only image build and basic container checks work without the cluster.
.
โโโ sites/<id>/ per-site YAML bundles (one folder per site)
โโโ components/ kustomize components โ pure K8s wiring
โโโ hooks/ lifecycle shell scripts baked into the image
โโโ src/ PHP scripts baked into /src in the image
โ โโโ merge-sites.php flat-merges component fragments โ one YAML per site in /tmp/sites
โ โโโ render-config.php renders settings.local.php from /tmp/sites/*.yaml (merged)
โ โโโ restore-guard.php guards DB restore against re-import (exits 1 if tables already exist)
โ โโโ setup-site.php drush site:install + bootstrap, per site
โ โโโ site-fields.php shell-friendly YAML reader used by hooks in place of yq
โ โโโ setup/ install sub-scripts (modules, admin, mcp, keycloak, โฆ)
โโโ templates/ Twig partials consumed by render-config.php
The app boots in three phases โ an init container (config render), a one-shot install Job (site install + setup), and the main container (php-fpm). See the Hooks table below for which script runs in each phase.
| Script | When | Purpose |
|---|---|---|
install.sh | Docker build | composer require for every contrib module |
before-start.sh | Init container | php /src/merge-sites.php then php /src/render-config.php |
after-start.sh | postStart | drush cache:rebuild |
on-create.sh | Install Job | restore-handoff + per-site setup-site.php loop |
cron.sh | Daily CronJob | Drupal cron + DB dump for backups |
restore.sh | Install Job (when restore component is on) | gpg-decrypt + psql import a backup |
Pure K8s wiring โ every component is optional unless marked (required):
| Component | Purpose |
|---|---|
base (required) | Deployment skeleton + drupal-env ConfigMap (PHP_DATE_TIMEZONE only) |
service-account (required) | LDAP user, GCP service account, k8s ServiceAccount, generated API token merged into drupal-ldap-creds |
db (required) | Postgres database + role (postgresql.kubed.io), DB_HOST/NAME/DRIVER env, DB_USER/PASSWORD from secretKeyRef |
redis | Cache backend env (REDIS_HOST/PORT/DB); per-site cache_prefix is generator-owned |
keycloak | OpenidClient CRD only โ runtime config lives in each site's YAML |
lifecycle (required) | One-shot install Job + per-site setup-site.php runner; init container for the renderer |
expose (required) | nginx sidecar + Service |
ingress | Ingress + cert-manager Certificate |
storage/nfs | NFS-backed /mnt/uploads volume + DRUPAL_FILE_PRIVATE_PATH=/mnt/uploads |
storage/pvc | Local-path PVC /mnt/uploads + DRUPAL_FILE_PRIVATE_PATH=/mnt/uploads (mutually exclusive with storage/nfs) |
backups | Daily encrypted DB dump โ GCS via rclone |
restore | One-shot DB restore from the backups bucket. Comment out after restoring. |
config | Debug-only ConfigMap mount of hooks/ + src/ + templates/ for live iteration |
Every site is a folder under sites/. Adding a site = adding one folder and one line in kustomization.yamlโ .
sites/<id>/ contains these files (not all are required for every site type):
sites/<id>/
โโโ drupal.yaml the site's full config โ template with {{ .field }} placeholders
โโโ 1pass.yaml (optional) ExternalSecret staging 1Password values into drupal-site-<id>-secrets
โโโ kustomization.yaml a Kustomize Component; contributes drupal.yaml's body into the shared
ExternalSecret (drupal-site-default, defined in components/base) via a
configMapGenerator + replacement that patches it into
spec.target.template.data."<id>.yaml"
At cluster apply time the flow is:
1Password "OpenAI Key/credential" โโ(1pass.yaml)โโโบ drupal-site-<id>-secrets (per-site Secret)
โ
drupal-ldap-creds, drupal-gcp-creds, drupal-keycloak-client โ
โ โ
โโโโโโโ(shared ExternalSecret drupal-site-default)โดโโโบ template renders drupal.yaml
(components/base/external-secret.yaml) โ
โผ
drupal-sites Secret (key: "<id>.yaml") โ shared across all sites
โ
โผ
Pod mounts drupal-sites at /sites on the init container
โ
โผ
merge-sites.php โ /tmp/sites/<id>.yaml (merged)
โ
โผ
render-config.php โ web/sites/<id>/settings.local.php
drupal-sites is one shared Secret with one data key per site (default.yaml, bikes.yaml, โฆ). Each site's Kustomize Component patches its YAML body into the single shared drupal-site-default ExternalSecret (defined in components/base) via a configMapGenerator + replacement (create: true).
Three patterns exist in sites/:
| Site | Pattern | Key traits |
|---|---|---|
default | Base instance | The Drupal install that runs the server. Every other site merges on top of it via merge-sites.php. Has its own Postgres DB and is the root of all storage/redis/mail cascades. |
kubed | Full instance | class: instance (default when class is omitted). Gets its own Postgres database and Redis cache prefix; setup-site.php runs a separate drush site:install. Use sites/kubed/ as a working reference for adding a new independent site. |
bikes | Domain subsite | class: subsite. Uses the Domain Access module. Shares default's database, users, and config tree โ no separate drush site:install. Requires domain.enabled: true on the parent. Differentiates only by hostname and per-domain config overrides (site:, theme:, domain:). |
drupal.yamlTop-level keys the generator + setup scripts understand:
| Key | Notes |
|---|---|
id, host | Required. The id is used as the Postgres database name (for non-default sites, unless database.name overrides it) and the redis cache_prefix base. |
account.{name,pass,mail,api_token} | Drupal admin user. Templated from drupal-ldap-creds. |
reverse_proxy.enabled | Whether to emit the reverse-proxy section in settings.local.php. |
redis.enabled | Install the redis module + emit the cache_prefix. Connection details come from env. |
mail.{enabled,host,port,from,username,password} | Symfony Mailer SMTP config. |
storage.s3.{enabled,bucket,region,endpoint,access_key,secret_key,root,path} | Flysystem S3. root defaults to /; path defaults to /<id>/public. Both cascade from the default site. |
storage.ftp.{enabled,host,port,username,password,root,path,passive,ssl} | Flysystem FTP. Same cascade contract. |
storage.webdav.{enabled,url} | Flysystem WebDAV. Marked as broken upstream โ leave disabled. |
storage.default_scheme | Override Drupal's default file scheme (public). |
keycloak.{enabled,client_id,client_secret,base_url,realm,always_save_userinfo,override_registration_settings} | OIDC client wiring. |
ai.{enabled,providers.<name>.{enabled,api_key}} | AI module + per-provider modules + Key entities. |
ckeditor.{enabled,plugins.<name>} | CKEditor 5 plugin pack and per-plugin sub-modules. |
Generator-owned (cannot be set from YAML):
db โ connection comes from DB_* env vars; non-default sites use their own database (named from database.name in the YAML, falling back to the site id) and the default site rides wodby's settings.php.redis.{host,port,db,prefix} โ connection comes from REDIS_* env vars; prefix is always <id>_.storage.private_path โ always ${DRUPAL_FILE_PRIVATE_PATH:-/mnt/files}/<id>/private. Per-site tenancy inside one pod-level volume.trusted_host_patterns โ built from every site's host so any pod can serve any site.storage.s3 and storage.ftp cascade every field except enabled from sites/default/drupal.yaml into every other site's YAML. The practical effect: a non-default site can enable s3+ftp with just
storage:
s3: { enabled: true }
ftp: { enabled: true }
and inherit credentials, host, region, bucket, root from default. The renderer computes the per-site prefix (s3) / root (ftp) as <cascaded root>/<id>/public. Override path: on a site to opt out of the default /<id>/public template.
A working reference for a full instance is sites/kubed/.
Before you deploy for the first time: drupal-sites must exist in the cloud namespace. Bootstrap once:
kubectl create secret generic drupal-sites -n cloud
This is deliberately not managed by Kustomize โ see kustomization.yaml for why.
Walkthrough โ let's say you want to add kubed.kellyferrone.com:
Create the folder under sites/:
sites/kubed/
sites/kubed/drupal.yaml โ start by copying sites/default/drupal.yaml, then:
id: kubed, host: kubed.kellyferrone.com.storage.s3 / storage.ftp blocks).db, redis.host/port/db/prefix, storage.private_path).enabled: toggles โ those are per-site, not cascaded.sites/kubed/1pass.yaml โ stages 1Password values into drupal-site-kubed-secrets. Same shape as sites/default/1pass.yaml, but point at this site's items in the homelab vault. Omit entirely if the new site has no 1Password-sourced secrets.
sites/kubed/kustomization.yaml โ a Kustomize Component containing a configMapGenerator + replacement. The replacement patches kubed.yaml into the shared drupal-site-default ExternalSecret's spec.target.template.data (using create: true). Two name changes from the default copy: the configMapGenerator name becomes drupal-site-kubed-tpl and the data key becomes kubed.yaml instead of default.yaml. If the new site has its own 1password secrets, include 1pass.yaml in resources:.
Add the line in kustomization.yamlโ :
components:
- sites/default
- sites/kubed # โ here
Deploy:
kubectl up apps/drupal
The shared ExternalSecret will gain a kubed.yaml key in drupal-sites. The next pod cycle's init container will merge and render web/sites/kubed/settings.local.php, and the install Job will run setup-site.php /tmp/sites/kubed.yaml, which runs drush site:install against the kubed Postgres database.
Point DNS at the cluster for kubed.kellyferrone.com (or however your ingress resolution works). Drupal will pick the site by host via web/sites/sites.php.
Shared cluster Postgres. The db component provisions the database + role via postgresql.kubed.io CRDs. Credentials come from drupal-ldap-creds. Per-site isolation is by separate Postgres databases: the default site uses the database provisioned for the shared drupal role; every other site gets its own database named after the site id (declared via the database.name key in the site's YAML, falling back to the site id). Drupal/drush only safely support the public schema (drush site:install / sql:drop target public regardless of any schema setting โ drupal.org #1060476), so each site lives in the public schema of its own database rather than a shared database with per-site schemas.
See: apps/postgresqlโ
Cluster Redis, DB index 4. The redis component sets REDIS_HOST/PORT/DB env. The renderer wires $settings['redis.connection'] for non-default sites; on the default site wodby's image-baked settings.php already does that, and the renderer only stamps $settings['cache_prefix'] = '<id>_'. The shared DB index is safe for multi-tenant because every key is prefixed.
redis.data:6379See: apps/redisโ
The keycloak component provisions the OpenidClient CRD only. Runtime config (base URL, realm, client_id, client_secret, prompt, registration overrides) all lives in each site's drupal.yaml keycloak: block. The client_secret is templated in via the drupal-keycloak-client ESO extract. setup/keycloak.php creates the Drupal openid_connect_client config entity on first install; subsequent runs are idempotent.
See: apps/keycloakโ , modules/keycloakโ
The service-account component provisions a Drupal LDAP user, an OpenLDAP Entry, a Kubernetes ServiceAccount (no token mounted), a GCP service account (drupal-gcp-creds โ backups + s3 HMAC), and an ESO-generated random API token merged into drupal-ldap-creds.api_token. The LDAP credential is reused for: Postgres login, SMTP submission, FTP backend, and the Drupal admin password.
See: kubed-io/openldapโ
Per-site mail: block enables symfony_mailer and writes the SMTP transport config to settings.local.php. The SMTP password comes from drupal-ldap-creds.password via the YAML template.
docker-mailserver.connect:587mail.fromSee: apps/mailserverโ
Storage has two independent axes:
private:// โ the pod-level volume mount (storage/nfs or storage/pvc) and a per-site subdirectory <base>/<id>/private that the renderer creates at boot.public:// โ flysystem stream wrappers configured per-site under storage.s3 / storage.ftp / storage.webdav. Choose any combination per site; each one lands at <root>/<id>/public by default.The bucket / share / WebDAV endpoint is shared across sites; per-site tenancy is purely in the subpath.
Enable in a site's YAML:
ai:
enabled: true
providers:
openai: { enabled: true, api_key: '{{ .openai_api_key }}' }
The api_key template variable resolves from drupal-site-<id>-secrets (which 1pass.yaml populates from a 1Password item). setup/modules.php installs key, ai, ai_agents, mcp plus the per-provider plugin. setup/mcp.php wires token auth using drupal-ldap-creds.api_token and enables the content, jsonapi, ai_function_calling, ai_agent_calling MCP plugins.
The MCP endpoint is at /mcp/post on each site's host. Per-site admin UI lives at /admin/config/mcp.
Optional semantic search over content, for retrieval-augmented generation. Disabled by default. Enable per-site under ai.vector:
ai:
enabled: true
vector:
enabled: true
schema: vector # pgvector tables live in this schema of the Drupal DB
metric: cosine # right metric for OpenAI (normalised) embeddings
dimensions: 1536 # must match the embeddings model (3-small=1536, 3-large=3072)
server: ai_vector # search_api server machine name
index: content # search_api index machine name
indexed_bundles:
- node:article
Stack: ai_search (bundled with drupal/ai) โ Search API โ the ai_vdb_provider_postgres VDB provider โ Postgres + pgvector. Embeddings use whatever ai.settings has wired for the embeddings operation (the renderer/setup do not duplicate the model choice).
setup/modules.php enables search_api, ai_search, ai_vdb_provider_postgres when ai.vector.enabled.setup/ai-search.php configures the VDB connection (reusing the Drupal DB_* env), then creates the Search API server + index. Idempotent.drush search-api:index content.Prerequisite โ pgvector in the Postgres image. The vector extension must exist in the database before any of this works. The default cluster Postgres image does not ship it. Steps:
pgvector/pgvector:pg17) โ a Server-CRD change.schemas + extensions blocks in components/db/db.yamlโ so the vector extension is created.ai.vector.enabled: true and redeploy.The
ai_vdb_provider_postgresconnection keys written bysetup/ai-search.phpare best-effort; verify againstdrush cget ai_vdb_provider_postgres.settingson first install and reconcile if they differ (the module is experimental).
Retrieve the API token for a site:
kubectl get secret drupal-ldap-creds -n cloud -o jsonpath='{.data.api_token}' | base64 -d
The mcp module's auth provider only accepts the HTTP Basic scheme. After base64-decoding the credential it branches on whether the result contains a colon: user:pass โ Basic auth, no colon โ the decoded value is treated as the raw API token. So token auth is Basic + base64 of the bare token โ no username: prefix, no colon, no trailing newline (Bearer is rejected outright).
Format the token (from $API_TOKEN) for the Authorization header:
printf '%s' "$API_TOKEN" | base64
Use it in an MCP client (e.g. .mcp.json):
"drupal": {
"url": "https://drupal.kellyferrone.com/mcp/post",
"type": "http",
"headers": {
"Authorization": "Basic <base64-of-bare-token>"
}
}
backups runs daily at 03:00. The Drupal container runs as an init container (cron.sh: drush cron + DB dump to /mnt/files/backups/drupal.sql โ the whole DB, all schemas), then the rclone container GPG-encrypts and uploads to gcs:backups.kellyferrone.com/drupal/<timestamp>.sql.gz.gpg. GCP creds from drupal-gcp-creds; GPG public key + email from drupal-gpg (ESO-pulled from gcpsm). Public file assets land in their backends natively (s3 โ GCS, ftp โ NAS); the NFS/PVC private volume is your responsibility to back up separately.
Comment in components/restore in kustomization.yamlโ . On the next install Job run, an rclone init container pulls the latest backup (or the object named by FILENAME in components/restore/conf.env), gpg-decrypts + untars it, and stages /mnt/files/backups/drupal.sql. on-create.sh detects the file and hands off to restore.sh before the per-site setup-site.php loop. Comment the component back out after a successful restore.
# drush against the live default site
./app.sh drush status
# tail php-fpm logs
./app.sh logs
# shell into the pod
./app.sh shell
# re-run the install Job (deletes the prior Job so it rolls over on next deploy)
kubectl delete job -n cloud drupal-install
kubectl up apps/drupal
Composer patches live in patches/โ and are applied at image build
time by hooks/install.shโ via cweagans/composer-patches.
content_entity_seeder MCP tool stored multi-property field values (body/text) as the literal string Array because it unwrapped only one level of the doubly-nested values context. Upstream: ai_agents#3586031โ . Drop the patch once a release ships the fix.Content type
Image
Digest
sha256:8c7f93f55โฆ
Size
284.6 MB
Last updated
28 days ago
docker pull kubed/drupal