Drupal
Deploy Drupal on Kubernetes using the Docker Official drupal image with a persistence model tailored for the installer-managed sites/ directory, plus native backup automation and safe scaling guardrails.
Why This Chart Matters
- Reference CMS chart — built to be HelmForge’s production baseline for installer-driven PHP CMS workloads
- Seeded
sites/persistence — preserves uploads and installer-generated state without masking Drupal core files from the image - Built-in backup automation — archives Drupal
sites/plus either MySQL or SQLite to S3-compatible storage - Safe horizontal scaling — HPA is supported only when the storage and database combination is actually safe
- Installer-first workflow — the chart prepares runtime, storage, ingress, and database connectivity, then guides setup through
NOTES.txt - Real application listener —
containerPorts.httpchanges both the Pod declaration and Apache on the official image - Secure defaults — Kubernetes API token automount is disabled, privilege escalation is blocked, and
RuntimeDefaultseccomp is enabled - Optional network isolation — ingress and egress policy can be tailored to the ingress controller, database, update, and mail paths
Image Decision
This chart uses docker.io/library/drupal because Drupal does not currently publish its own upstream-maintained runtime container image.
HelmForge pins:
docker.io/library/drupal:11.4.10-php8.5-apache-bookworm
That choice is intentional:
- the Apache variant already includes PHP
- the image includes the Drupal-relevant extensions such as
pdo_mysql,pdo_sqlite,sqlite3,gd,mbstring,curl,dom,openssl, andzip - Drupal 11.4 supports PHP 8.5, so the explicit PHP 8.5 tag is a better production pin than the older generic
apache-bookwormform bookwormkeeps the Debian base explicit and conservative for production environments
Official Product
Release Notes
Drupal 11.4.10 fixes SA-CORE-2026-014, SA-CORE-2026-015, and
SA-CORE-2026-016; no other fixes are included. Review the
official release notes.
The bundled HelmForge MySQL dependency is aligned to the current 2.4.0 chart
while preserving the same Drupal installer values for the default bundled
database path. Before upgrading production workloads, test the site in staging
with the same PHP extensions, Composer dependency set, storage class, and
database mode used in production. Review the Drupal 11.4.10 release notes before
running database updates.
Architecture And Security Boundary
An optional Ingress routes to a Service whose named target resolves to the
configurable Apache listener in the Drupal Deployment. Only
/var/www/html/sites is persistent, so mutable site state survives while core
continues to come from the pinned image. The database can be the bundled MySQL
2.4.0 subchart, an external MySQL-compatible service, or SQLite for one replica.
The pod does not receive a Kubernetes API token by default, uses
RuntimeDefault seccomp, and blocks privilege escalation. The Docker Official
Apache image still starts its master process as root to bind port 80 before
delegating requests to unprivileged workers; the chart therefore does not claim
a fully non-root or read-only-root-filesystem boundary. NetworkPolicy support is
opt-in and requires a CNI that enforces it.
Installation
HTTPS Repository
helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install drupal helmforge/drupal
OCI Registry
helm install drupal oci://ghcr.io/helmforgedev/helm/drupal
Install Example
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '1'
memory: 1Gi
mysql:
enabled: true
auth:
database: drupal
username: drupal
persistence:
enabled: true
size: 8Gi
ingress:
enabled: true
ingressClassName: traefik
hosts:
- host: drupal.example.com
paths:
- path: /
pathType: Prefix
After installation:
- Wait for Drupal and MySQL to become ready.
- Open the configured URL or port-forwarded service.
- Run the Drupal web installer.
- Use the database details printed by
helm get notes <release>.
Database Modes
Bundled MySQL
database:
mode: auto
mysql:
enabled: true
External Database
database:
mode: external
external:
host: db.example.com
port: 3306
name: drupal
username: drupal
mysql:
enabled: false
SQLite
database:
mode: sqlite
mysql:
enabled: false
For SQLite installs, use sites/default/files/.ht.sqlite in the Drupal installer unless you intentionally choose another writable path.
SQLite is supported and backed up by the chart, but it remains a single-replica path and is not the preferred default for most production Drupal sites.
Persistence
This chart persists only /var/www/html/sites.
That layout is intentional:
settings.php, uploaded files, and installer output live undersites/- Drupal core remains in the container image instead of being shadowed by a PVC
- an init container seeds the initial
sites/directory into the mounted volume before Drupal starts
Single-replica deployments are the default recommendation. If you scale above one replica, use shared writable storage for sites/.
Backup Strategy
When backup.enabled=true, the chart creates a CronJob that always captures two things:
- the Drupal
sites/tree - the active database
Database backup behavior depends on the selected mode:
- MySQL or external MySQL-compatible database —
mysqldump - SQLite — a consistent SQLite snapshot created through Python’s SQLite backup API before compression
Backup Example
backup:
enabled: true
schedule: '0 2 * * *'
s3:
endpoint: https://minio.example.com
bucket: drupal-backups
existingSecret: drupal-backup-s3
For IRSA or another compatible projected credential provider, enable workload identity instead of storing S3 keys in a Secret. This annotation example configures IRSA:
backup:
enabled: true
s3:
endpoint: https://s3.us-east-1.amazonaws.com
bucket: drupal-backups
region: us-east-1
credentialsFromPodIdentity: true
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/drupal-backup
EKS Pod Identity does not use a ServiceAccount annotation. Give the dedicated backup ServiceAccount a stable name and create the association in EKS instead:
backup:
enabled: true
s3:
endpoint: https://s3.us-east-1.amazonaws.com
bucket: drupal-backups
region: us-east-1
credentialsFromPodIdentity: true
serviceAccount:
name: drupal-backup
aws eks create-pod-identity-association \
--cluster-name <cluster-name> \
--namespace <release-namespace> \
--service-account drupal-backup \
--role-arn arn:aws:iam::123456789012:role/drupal-backup
This mode uses a dedicated backup ServiceAccount and does not render static S3 credentials. Grant it only bucket-list and object-write access for the selected prefix, plus bucket creation only when that option remains enabled. If egress NetworkPolicy is enabled, allow DNS, HTTPS, and any identity-provider metadata endpoint required by the cluster. Token automount remains disabled unless the provider explicitly needs it; metadata address-family routing remains a cluster responsibility rather than a hardcoded chart address.
If database.mode=external, also provide backup credentials for the dump job:
backup:
database:
existingSecret: drupal-backup-db
Autoscaling And HPA
HPA is supported, but only when the chart can guarantee safe shared state.
Required for multi-replica or HPA Drupal:
persistence.enabled=truepersistence.accessMode=ReadWriteMany- a MySQL-compatible database mode
If those requirements are not met, the chart fails during render instead of letting an unsafe deployment reach the cluster.
When HPA is enabled, the Deployment omits spec.replicas and leaves the replica
count exclusively under HPA control. This prevents routine Helm upgrades from
resetting a scaled workload to a chart value. The first upgrade from an older
release that rendered spec.replicas can reduce the Deployment to one replica
before the HPA reconciles. A fresh installation with minReplicas > 1 can also
start at one replica until that first reconciliation. Plan for reduced capacity
or redundancy during this transition.
HPA Example
persistence:
accessMode: ReadWriteMany
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
pdb:
enabled: true
minAvailable: 1
Values
The chart still has an installer-oriented surface, but it now covers production runtime, storage, backups, scaling, networking, probes, and escape hatches.
Runtime And Installer Values
| Key | Default | Description |
|---|---|---|
replicaCount |
1 |
Fixed Drupal replicas when HPA is disabled |
image.repository |
docker.io/library/drupal |
Docker Official Drupal image repository |
image.tag |
11.4.10-php8.5-apache-bookworm |
Pinned Drupal image tag |
image.pullPolicy |
IfNotPresent |
Container pull policy |
image.configureApachePort |
null |
Auto-configure a non-default Apache port for the official image |
containerPorts.http |
80 |
Actual Apache listener and Pod container port |
imagePullSecrets |
[] |
Optional image pull secrets |
drupal.siteName |
Drupal |
Suggested site name shown in install guidance |
drupal.installProfile |
standard |
Suggested Drupal install profile |
drupal.locale |
en |
Suggested locale for the installer |
drupal.sqlitePath |
sites/default/files/.ht.sqlite |
Suggested SQLite path for the web installer |
drupal.extraEnv |
[] |
Extra container environment variables |
php.ini |
"" |
Extra PHP configuration mounted as custom.ini |
Database And Persistence Values
| Key | Default | Description |
|---|---|---|
database.mode |
auto |
Database mode: auto, external, mysql, or sqlite |
database.external.host |
"" |
External database hostname |
database.external.port |
3306 |
External database port |
database.external.name |
drupal |
External database name |
database.external.username |
drupal |
External database username |
mysql.enabled |
true |
Enable the bundled MySQL subchart |
mysql.architecture |
standalone |
MySQL deployment architecture |
mysql.auth.database |
drupal |
Database name created by the MySQL subchart |
mysql.auth.username |
drupal |
Database username created by the MySQL subchart |
mysql.auth.password |
"" |
Optional application password override |
mysql.auth.rootPassword |
"" |
Optional root password override |
mysql.auth.existingSecret |
"" |
Existing secret for MySQL passwords |
mysql.auth.existingSecretUserPasswordKey |
mysql-user-password |
Secret key used for the app user password |
mysql.standalone.persistence.enabled |
true |
Enable bundled standalone MySQL persistence |
mysql.standalone.persistence.size |
8Gi |
Bundled standalone MySQL PVC size |
persistence.enabled |
true |
Enable persistence for /var/www/html/sites |
persistence.storageClass |
"" |
Explicit storage class for the sites PVC |
persistence.accessMode |
ReadWriteOnce |
Access mode for the sites PVC |
persistence.size |
8Gi |
Sites PVC size |
persistence.existingClaim |
"" |
Reuse an existing sites PVC |
persistence.annotations |
{} |
Annotations applied to the sites PVC |
Backup, Scaling, And Availability Values
| Key | Default | Description |
|---|---|---|
backup.enabled |
false |
Enable scheduled backups |
backup.schedule |
0 3 * * * |
Cron schedule for backups |
backup.s3.endpoint |
"" |
S3-compatible endpoint |
backup.s3.bucket |
"" |
S3 target bucket |
backup.s3.region |
"" |
Region required for workload identity |
backup.s3.credentialsFromPodIdentity |
false |
Use workload identity instead of static S3 keys |
backup.s3.prefix |
drupal |
Key prefix in the bucket |
backup.s3.existingSecret |
"" |
Existing secret for S3 credentials |
backup.serviceAccount.create |
true |
Create a dedicated backup ServiceAccount |
backup.database.host |
"" |
Optional database host override for backup |
backup.database.password |
"" |
Inline database password override for backup |
backup.database.existingSecret |
"" |
Existing secret for backup database password |
autoscaling.enabled |
false |
Enable HPA |
autoscaling.minReplicas |
2 |
HPA minimum replicas |
autoscaling.maxReplicas |
5 |
HPA maximum replicas |
autoscaling.targetCPUUtilizationPercentage |
75 |
CPU utilization target |
autoscaling.targetMemoryUtilizationPercentage |
80 |
Memory utilization target |
pdb.enabled |
false |
Create a PodDisruptionBudget |
pdb.minAvailable |
1 |
Minimum healthy pods during voluntary disruption |
Service, Ingress, Probes, And Platform Values
| Key | Default | Description |
|---|---|---|
service.type |
ClusterIP |
Drupal service type |
service.port |
80 |
Drupal service port |
service.annotations |
{} |
Annotations applied to the service |
service.ipFamilyPolicy |
omitted | Service IP family policy: SingleStack, PreferDualStack, or RequireDualStack. Omit for cluster default. |
service.ipFamilies |
omitted | Ordered list of IP families (IPv4, IPv6). Omit for cluster default. |
ingress.enabled |
false |
Enable ingress resources |
ingress.ingressClassName |
"" |
Ingress class name |
ingress.annotations |
{} |
Ingress annotations |
ingress.hosts |
[] |
Ingress host and path definitions |
ingress.tls |
[] |
TLS secret mappings for ingress |
startupProbe.enabled |
true |
Enable the startup probe |
startupProbe.path |
/ |
Startup probe HTTP path |
livenessProbe.enabled |
true |
Enable the liveness probe |
livenessProbe.path |
/ |
Liveness probe HTTP path |
readinessProbe.enabled |
true |
Enable the readiness probe |
readinessProbe.path |
/ |
Readiness probe HTTP path |
resources.requests.cpu |
250m |
Default CPU request for the Drupal container |
resources.requests.memory |
512Mi |
Default memory request for the Drupal container |
resources.limits.cpu |
1 |
Default CPU limit for the Drupal container |
resources.limits.memory |
1Gi |
Default memory limit for the Drupal container |
podSecurityContext.fsGroup |
33 |
Group ownership applied to the mounted Drupal files volume |
podSecurityContext.seccompProfile.type |
RuntimeDefault |
Pod seccomp profile |
securityContext.allowPrivilegeEscalation |
false |
Prevent the container from gaining privileges |
serviceAccount.create |
false |
Create a dedicated service account |
serviceAccount.name |
"" |
Existing or custom service account name |
serviceAccount.annotations |
{} |
Service account annotations |
serviceAccount.automountServiceAccountToken |
false |
Mount a Kubernetes API token in Drupal pods |
networkPolicy.enabled |
false |
Create ingress isolation for Drupal pods |
networkPolicy.egress.enabled |
false |
Enable explicit egress rules |
nodeSelector |
{} |
Node selector rules |
tolerations |
[] |
Pod tolerations |
affinity |
{} |
Pod affinity and anti-affinity |
topologySpreadConstraints |
[] |
Topology spread constraints |
priorityClassName |
"" |
Priority class for the pod |
terminationGracePeriodSeconds |
30 |
Pod termination grace period |
podLabels |
{} |
Extra pod labels |
podAnnotations |
{} |
Extra pod annotations |
extraVolumes |
[] |
Additional volumes injected into the pod |
extraVolumeMounts |
[] |
Additional volume mounts injected into the Drupal container |
extraManifests |
[] |
Extra Kubernetes manifests rendered by the chart |
Dual-stack Networking
The Drupal Service accepts Kubernetes dual-stack configuration. By default, both service.ipFamilyPolicy and service.ipFamilies are unset and the chart inherits whatever the cluster advertises (matching prior behavior).
service:
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
PreferDualStack is the safer choice for clusters that may be single- or dual-stack: omit ipFamilies and the cluster auto-populates whatever it supports. Set ipFamilies explicitly only when the cluster is configured for both families — the Kubernetes API rejects an explicit family the cluster does not advertise. RequireDualStack enforces both.
Application Port And NetworkPolicy
service.port is client-facing. containerPorts.http changes the actual Apache
listener and remains the target of the named Service port:
containerPorts:
http: 8080
service:
port: 80
The official image is configured automatically. A custom image must use the
same /etc/apache2 layout and explicitly set image.configureApachePort=true,
otherwise the chart rejects a non-default listener.
NetworkPolicy is disabled by default to avoid silently blocking Drupal updates, mail, databases, or integrations. When enabled, declare the actual ingress controller and every egress path required by the site:
networkPolicy:
enabled: true
ingress:
allowSameNamespace: false
extraFrom:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/component: controller
egress:
enabled: true
allowDNS: true
allowSameNamespaceDatabase: true
allowHTTPS: true
Troubleshooting
Use kubectl logs deploy/<release>-drupal -c drupal for Apache and PHP output.
For a non-default port, inspect the one-time configuration step with kubectl logs deploy/<release>-drupal -c configure-apache-port and confirm the live
virtual host with kubectl exec deploy/<release>-drupal -c drupal -- apache2ctl -S. If isolation blocks traffic, inspect the rendered policy and
verify its namespace and pod selectors against the ingress controller and
database placement.