Sign inSign up

kubed/drupal

By kubed

โ€ขUpdated 28 days ago

Image
0

10K+

kubed/drupal repository overview

โ Drupal

๐Ÿ“ธ Image Builder ๐Ÿงฌ Publish Version ๐Ÿ” Plan ๐Ÿš€ Deploy ๐Ÿงญ Plan Agent

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.

โ Build, release, deploy

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โ .

โ Docker Image

A custom image (kubed/drupal:11) is built on top of wodby/drupal:11. The build process:

  1. Copies hooks/, src/, and templates/ into the image (/hooks, /src, /templates)
  2. chmod +x /hooks/*.sh
  3. Runs /hooks/install.sh as the wodby user โ€” installs contrib modules via Composer

Built 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.

โ Architecture

.
โ”œโ”€โ”€ 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.

โ Hooks
ScriptWhenPurpose
install.shDocker buildcomposer require for every contrib module
before-start.shInit containerphp /src/merge-sites.php then php /src/render-config.php
after-start.shpostStartdrush cache:rebuild
on-create.shInstall Jobrestore-handoff + per-site setup-site.php loop
cron.shDaily CronJobDrupal cron + DB dump for backups
restore.shInstall Job (when restore component is on)gpg-decrypt + psql import a backup
โ Components

Pure K8s wiring โ€” every component is optional unless marked (required):

ComponentPurpose
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
redisCache backend env (REDIS_HOST/PORT/DB); per-site cache_prefix is generator-owned
keycloakOpenidClient 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
ingressIngress + cert-manager Certificate
storage/nfsNFS-backed /mnt/uploads volume + DRUPAL_FILE_PRIVATE_PATH=/mnt/uploads
storage/pvcLocal-path PVC /mnt/uploads + DRUPAL_FILE_PRIVATE_PATH=/mnt/uploads (mutually exclusive with storage/nfs)
backupsDaily encrypted DB dump โ†’ GCS via rclone
restoreOne-shot DB restore from the backups bucket. Comment out after restoring.
configDebug-only ConfigMap mount of hooks/ + src/ + templates/ for live iteration

โ Multisite

Every site is a folder under sites/. Adding a site = adding one folder and one line in kustomization.yamlโ .

โ Anatomy of a site bundle

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).

โ Site patterns

Three patterns exist in sites/:

SitePatternKey traits
defaultBase instanceThe 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.
kubedFull instanceclass: 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.
bikesDomain subsiteclass: 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:).
โ What goes in drupal.yaml

Top-level keys the generator + setup scripts understand:

KeyNotes
id, hostRequired. 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.enabledWhether to emit the reverse-proxy section in settings.local.php.
redis.enabledInstall 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_schemeOverride 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.
โ Default-site cascade

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.

โ Adding a new site

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:

  1. Create the folder under sites/:

    sites/kubed/
    
  2. sites/kubed/drupal.yaml โ€” start by copying sites/default/drupal.yaml, then:

    • Set id: kubed, host: kubed.kellyferrone.com.
    • Strip the verbose comments and any fields you want to inherit from default (most of storage.s3 / storage.ftp blocks).
    • Drop the fields the generator owns (db, redis.host/port/db/prefix, storage.private_path).
    • Keep per-section enabled: toggles โ€” those are per-site, not cascaded.
  3. 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.

  4. 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:.

  5. Add the line in kustomization.yamlโ :

    components:
    - sites/default
    - sites/kubed   # โ† here
    
  6. 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.

  7. 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.

โ Integrations

โ PostgreSQL

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โ 

โ Redis

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.

  • Service: redis.data:6379

See: apps/redisโ 

โ Keycloak

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โ 

โ LDAP

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โ 

โ Mail

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.

  • Service: docker-mailserver.connect:587
  • From: per-site mail.from

See: apps/mailserverโ 

โ File storage

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.

โ AI / MCP

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.

โ Vector search (RAG)

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.
  • Populate embeddings after first install: 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:

  1. Point the postgresql Server at a pgvector-enabled image (e.g. pgvector/pgvector:pg17) โ€” a Server-CRD change.
  2. Uncomment the schemas + extensions blocks in components/db/db.yamlโ  so the vector extension is created.
  3. Set ai.vector.enabled: true and redeploy.

The ai_vdb_provider_postgres connection keys written by setup/ai-search.php are best-effort; verify against drush cget ai_vdb_provider_postgres.settings on 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
โ MCP client auth

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

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.

โ Restore

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.

โ Useful commands

# 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

โ References

โ Patches

Composer patches live in patches/โ  and are applied at image build time by hooks/install.shโ  via cweagans/composer-patches.

Tag summary

Content type

Image

Digest

sha256:8c7f93f55โ€ฆ

Size

284.6 MB

Last updated

28 days ago

docker pull kubed/drupal