Drupal
Running Drupal here — the manifest, where updates run, and the settings file that reads the environment.
The manifest
The whole file our own Drupal demos deploy with — Umami and Commerce Kickstart run exactly this:
version: 1
type: drupal
runtime:
php: '8.5'
# Installing a profile is the heaviest thing a Drupal site ever does, and
# 256M is where an install dies halfway with nothing anybody can act on.
memory_limit: 512M
services:
- mariadb: '11.8'
- valkey: '9.0'
build:
steps:
- name: PHP dependencies
run: composer install --no-dev --optimize-autoloader
# What Composer installs, kept for the next build, which then only fetches
# what changed rather than unpacking every module again.
cache:
- vendor
- web/core
- web/modules/contrib
- web/themes/contrib
- web/profiles/contrib
- web/libraries
deploy:
steps:
- sh scripts/deploy.sh
on_failure: rollback
cron:
- name: drupal
schedule: '*/15 * * * *'
command: 'drush cron'
health:
path: /
timeout: 10
And the deploy script it names, scripts/deploy.sh. The first deploy finds an
empty database and installs the site; every deploy after that runs
drush deploy — database updates, config import, cache rebuild:
#!/bin/sh
set -eu
root="${COMPOSER_ROOT:-$(pwd)}"
drush="$root/vendor/bin/drush"
cd "${DRUPAL_ROOT:-$root/web}"
if "$drush" status --field=bootstrap 2>/dev/null | grep -qi successful; then
"$drush" deploy --yes
exit 0
fi
# An admin password that is never in the repository and is the same after
# every deploy: derived from VALLIC_ENTROPY, which each environment has.
password=$(php -r 'echo substr(hash("sha256", (string) getenv("VALLIC_ENTROPY")), 0, 20);')
"$drush" site:install standard --yes --account-name=admin --account-pass="$password"
A site that already has its database — moved in with vallic db import — never
reaches the install: its first deploy is a drush deploy like every other.
With a theme to build
version: 1
type: drupal
runtime:
php: '8.4'
services:
- mariadb: '11.8'
- valkey: '8'
# Only for the theme build below; nothing of it runs beside the site.
- node: '24'
build:
cache:
- vendor
- web/core
- web/modules/contrib
steps:
- composer install --no-dev --optimize-autoloader
# The PHP image has no npm, so the theme builds in the Node image.
- name: Theme
image: node
run: npm ci --prefix web/themes/custom/acme && npm run build --prefix web/themes/custom/acme
deploy:
steps:
- 'drush deploy'
on_failure: rollback
cron:
- name: drupal-cron
schedule: '*/15 * * * *'
command: 'drush cron'
Composer's and npm's downloads are cached between builds without being asked
for. What Composer installs — vendor/ and the directories Drupal's
installers put modules in — is not, and build.cache keeps it: the next build
checks it against composer.lock and fetches only what changed. See
Configuration for the rules.
services is checked, not obeyed, for the database. A service such as
Valkey or Node is started by the next deploy if the environment does not run it
yet, but a database cannot be added or changed from a commit — moving means
migrating everything in the old one. The database named here has to be the one
the environment was created with, or the deploy refuses. Each name needs a
version after it, and that version is what the environment runs from the next
deploy — see Service upgrade before you
change one that keeps data.
A step with no image runs in the application's own PHP image, which is what
you want for Composer — the PHP that installs the dependencies is the PHP that
runs them — and which has no Node in it. That is why the theme step names
node, and why node is listed under services: a step can only run in an
image the environment has.
The document root is web/, so the repository is expected to have the
drupal/recommended-project layout, with composer.json at the top and Drupal
under web/.
Cron
With no cron in the manifest, the platform runs drush cron hourly.
Declaring any cron replaces that job rather than adding to it, which is why
the manifest above names drush cron itself — every fifteen minutes, because a
site that sends digests or clears expired content usually wants it more often
than once an hour.
Cron runs on one machine only, however many web servers the environment has, so a job never runs twice at once.
Where updates run
drush deploy in deploy.steps, which is the one command that runs database
updates, imports configuration and rebuilds the cache in the order Drupal wants
them. Running drush updb and drush cim yourself works and is the same thing
in more lines — the order matters, and drush deploy is the order.
It runs on the machine, after the release is live, not during the build. A build has no database: it produces an artifact that could be deployed to staging or production, and neither of their databases is its business. See vallic.yaml for what separates the two phases.
Nothing runs there unless you say so. With no deploy.steps, a deploy puts
the new code live and stops — no updates, no configuration import — so a
Drupal site needs drush deploy written down. On an environment with more
than one web server the steps run once, on one of them, rather than once per
machine.
Removing a module takes two releases
The new code is live before drush deploy runs, so for those seconds the
site runs new code against the old database. That matters when a module goes:
- First release: uninstall it — an update hook calling
\Drupal::service('module_installer')->uninstall(['the_module'])— and keep its code incomposer.json. - A later release, once every environment has run that hook: remove the code.
Removed in the same release, the module is still enabled while its code is
gone: the site breaks until the update runs, and the update hook that should
uninstall it can break with it. Before deploying the removal to an
environment, drush pm:list there should no longer show the module.
When a backup is restored, the platform runs drush cache:rebuild afterwards,
so the site does not serve pages cached from the database the restore
replaced.
A deploy does not clear the CDN. With the CDN on, see After a deploy for the step that does.
The settings file
A Drupal site needs to know where its database is, where uploads go, what to salt its hashes with and which Host headers to trust — and every one of those differs between environments. On the platform they all come from the variables the environment carries, so a site reads them instead of committing them.
The file below does that. Copy it to web/sites/default/settings.vallic.php
and include it from settings.php, last, so it wins over whatever the defaults
above it say:
if (file_exists($app_root . '/' . $site_path . '/settings.vallic.php')) {
include $app_root . '/' . $site_path . '/settings.vallic.php';
}
Nothing in it applies anywhere else. Under DDEV, on a laptop, on another host,
VALLIC_ENVIRONMENT is not set and the file returns before touching a setting,
so it can be committed and forgotten. The platform never edits it: it is your
file, and the names it reads are the contract.
The file
The control plane you are reading this in is a Drupal site on the platform, and this is the file it runs on — the handbook shows it rather than a copy that would drift from it.
<?php
/**
* @file
* Settings for a Drupal site running on Vallic Cloud.
*
* Copy this file next to settings.php and include it from there, after
* anything it should be allowed to override:
*
* @code
* if (file_exists($app_root . '/' . $site_path . '/settings.vallic.php')) {
* include $app_root . '/' . $site_path . '/settings.vallic.php';
* }
* @endcode
*
* Everything it sets comes from the environment the platform writes for the
* container — the names are documented in the handbook under "Variables". The
* platform never edits this file: it is yours, committed with the project,
* and what it reads is the contract. Anywhere else — DDEV, a laptop, another
* host — none of those names are set, and the file does nothing.
*/
use Drupal\Core\Installer\InstallerKernel;
use Symfony\Component\HttpFoundation\Request;
// Only on the platform. Without this name nothing below is present either.
if (getenv('VALLIC_ENVIRONMENT') === FALSE) {
return;
}
$settings['vallic_environment'] = getenv('VALLIC_ENVIRONMENT');
$settings['vallic_environment_type'] = getenv('VALLIC_ENVIRONMENT_TYPE') ?: 'development';
// Database. The service is reachable by DB_HOST from every container in the
// stack; the credentials were generated once with the environment.
if (getenv('DB_HOST')) {
$driver = getenv('DB_DRIVER') ?: 'mysql';
$databases['default']['default'] = [
'driver' => $driver,
'host' => getenv('DB_HOST'),
'port' => getenv('DB_PORT') ?: '3306',
'database' => getenv('DB_NAME'),
'username' => getenv('DB_USER'),
'password' => getenv('DB_PASSWORD'),
'prefix' => '',
];
if ($driver === 'mysql') {
// READ COMMITTED is what Drupal recommends for MariaDB and MySQL; it
// avoids the gap-lock deadlocks the default isolation level produces
// under concurrent cache writes.
$databases['default']['default']['init_commands'] = [
'isolation_level' => 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED',
];
}
unset($driver);
}
// The hash salt. VALLIC_ENTROPY is 256 random bits generated once for this
// environment and never changed, so sessions and one-time links survive every
// deploy — and nothing secret is committed.
if (getenv('VALLIC_ENTROPY')) {
$settings['hash_salt'] = getenv('VALLIC_ENTROPY');
}
// Files. Both directories are mounted into every release, so uploads outlive
// the code that received them. Drupal wants the public path relative to the
// document root, so it is derived from the absolute one the platform gives.
if (getenv('VALLIC_PUBLIC_DIR') && str_starts_with(getenv('VALLIC_PUBLIC_DIR'), $app_root . '/')) {
$settings['file_public_path'] = substr(getenv('VALLIC_PUBLIC_DIR'), strlen($app_root) + 1);
}
if (getenv('VALLIC_PRIVATE_DIR')) {
$settings['file_private_path'] = getenv('VALLIC_PRIVATE_DIR');
}
// Trusted hosts: exactly the hostnames the edge routes here. Nothing else can
// reach the container, but Drupal builds absolute URLs from the Host header
// and should not take anyone's word for it.
if (getenv('VALLIC_HOSTNAMES')) {
$settings['trusted_host_patterns'] = array_map(
static fn(string $hostname): string => '^' . preg_quote(trim($hostname), '/') . '$',
explode(',', getenv('VALLIC_HOSTNAMES')),
);
}
// The edge terminates TLS and proxies to the container, so the request Drupal
// sees comes from the proxy over plain HTTP. Trusting the address the request
// arrived from — which is only ever the edge — restores the client's address,
// scheme and port from the X-Forwarded headers the edge sets.
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['REMOTE_ADDR'];
$settings['reverse_proxy_trusted_headers'] = Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO;
// Cache in Redis (or Valkey — same protocol, same name) when the stack runs
// one and the site ships the module. The module does not have to be installed:
// its services are registered here and its classes made loadable, so the
// container cache itself lives in Redis from the first request — and so does
// the installer's, which is why the block steps aside during installation.
if (getenv('REDIS_HOST')
&& extension_loaded('redis')
&& file_exists($app_root . '/modules/contrib/redis/redis.services.yml')
&& !InstallerKernel::installationAttempted()) {
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host'] = getenv('REDIS_HOST');
$settings['redis.connection']['port'] = (int) (getenv('REDIS_PORT') ?: 6379);
// Every environment on a server shares nothing, but a prefix costs nothing
// and keeps two sites apart should they ever share a service.
$settings['cache_prefix'] = getenv('VALLIC_SLUG') ?: 'drupal';
// Every bin not set otherwise. Core already puts bootstrap, config,
// discovery and routes on its chained backend — APCu in the process, Redis
// behind it — and a bin's own default is read before this one, so those
// need no line of their own; naming one is how a bin is taken off it.
$settings['cache']['default'] = 'cache.backend.redis';
// Lock, flood and cache-tag checksum in Redis too; then the module's own
// services, so the backends exist before the module is enabled.
$settings['container_yamls'][] = 'modules/contrib/redis/example.services.yml';
$settings['container_yamls'][] = 'modules/contrib/redis/redis.services.yml';
$class_loader->addPsr4('Drupal\\redis\\', 'modules/contrib/redis/src');
// The container cache is read before the container exists, so it needs
// its own definition of the services that reach Redis.
$settings['bootstrap_container_definition'] = [
'parameters' => [],
'services' => [
'redis.factory' => [
'class' => 'Drupal\redis\ClientFactory',
],
'cache.backend.redis' => [
'class' => 'Drupal\redis\Cache\CacheBackendFactory',
'arguments' => ['@redis.factory', '@cache_tags_provider.container', '@serialization.phpserialize'],
],
'cache.container' => [
'class' => '\Drupal\redis\Cache\PhpRedis',
'factory' => ['@cache.backend.redis', 'get'],
'arguments' => ['container'],
],
'cache_tags_provider.container' => [
'class' => 'Drupal\redis\Cache\RedisCacheTagsChecksum',
'arguments' => ['@redis.factory'],
],
'serialization.phpserialize' => [
'class' => 'Drupal\Component\Serialization\PhpSerialize',
],
],
];
// And the container itself chained the same way, which core cannot do for
// it: the definition is a megabyte or so, read on every request, and from
// APCu that is a local read where from Redis it is a fetch and an
// unserialise. Redis stays authoritative.
//
// Wherever the APCu functions exist — not only where APCu is on — as core
// decides for its own chained bins. The CLI usually has APCu off, and
// drush is what rebuilds the container on a deploy. Chained, its write
// moves the bin's last-write timestamp in Redis, which is what makes every
// web server drop the copy in its own APCu; the fast half simply misses
// and stores nothing. Left plain on the CLI, drush wrote to Redis alone,
// the web servers went on running the container from before the deploy,
// and rebuilt plugin definitions with modules the deploy had just
// uninstalled — a search that failed days later, when APCu was emptied.
if (function_exists('apcu_fetch')) {
$settings['bootstrap_container_definition']['services'] += [
'cache.container.consistent' => $settings['bootstrap_container_definition']['services']['cache.container'],
'cache.container.fast' => [
'class' => 'Drupal\Core\Cache\ApcuBackend',
'arguments' => ['container', $settings['cache_prefix'], '@cache_tags_provider.container', '@datetime.time'],
],
'datetime.time' => [
'class' => 'Drupal\Component\Datetime\Time',
],
];
$settings['bootstrap_container_definition']['services']['cache.container'] = [
'class' => 'Drupal\Core\Cache\ChainedFastBackend',
'arguments' => ['@cache.container.consistent', '@cache.container.fast', 'container'],
];
}
}
// What differs by kind of environment. Production hides errors from visitors;
// everything else shows them to the people who are there to find them.
switch ($settings['vallic_environment_type']) {
case 'production':
$config['system.logging']['error_level'] = 'hide';
$config['environment_indicator.indicator'] = [
'name' => 'Production',
'bg_color' => '#8b0000',
'fg_color' => '#ffffff',
];
break;
case 'staging':
$config['system.logging']['error_level'] = 'some';
$config['environment_indicator.indicator'] = [
'name' => 'Staging',
'bg_color' => '#b86e00',
'fg_color' => '#ffffff',
];
break;
default:
$config['system.logging']['error_level'] = 'verbose';
$config['environment_indicator.indicator'] = [
'name' => ucfirst($settings['vallic_environment_type']) . ': ' . $settings['vallic_environment'],
'bg_color' => '#005f87',
'fg_color' => '#ffffff',
];
break;
}
// Outbound mail through the stack's relay, when it runs one.
//
// Drupal sends through PHP's mail() by default, which hands the message to a
// sendmail binary the application container does not have — so a site with a
// relay sitting beside it still could not send a password reset until somebody
// found out why. The relay is what talks to the outside world; the application
// only has to be told where it is.
//
// Written as configuration rather than as a module setting, so it applies
// whether the site uses Symfony Mailer 1.x or 2.x, and is simply ignored by a
// site that uses neither. A project sending through a hosted provider sets its
// own key and overrides this in its own settings.
if (getenv('SMTP_HOST')) {
foreach (['symfony_mailer', 'mailer_transport'] as $prefix) {
$config[$prefix . '.settings']['default_transport'] = 'vallic';
$config[$prefix . '.mailer_transport.vallic']['plugin'] = 'smtp';
$config[$prefix . '.mailer_transport.vallic']['configuration'] = [
'user' => '',
'pass' => '',
'host' => getenv('SMTP_HOST'),
// 25, stated rather than read: the relay listens on it inside the stack
// and the platform publishes no variable to change it. Reading one would
// suggest it is configurable and quietly fall back the day somebody set
// it expecting that to matter.
'port' => '25',
];
}
}
What it decides
The database from DB_HOST, DB_PORT, DB_DRIVER, DB_NAME, DB_USER
and DB_PASSWORD. The credentials were generated once with the environment;
nothing about them is in the code.
The hash salt from VALLIC_ENTROPY, which is generated once per
environment and never changes — so sessions and one-time login links survive a
deploy, and the salt is never in a repository.
Files from VALLIC_PUBLIC_DIR and VALLIC_PRIVATE_DIR. Both are mounted
into every release, so uploads outlive the code that received them. The public
one is web/sites/default/files; the private one is private/ at the root of
the repository, beside web/ rather than inside it, so it is never served.
Everything else in the release is read-only while the site runs, which is why
nothing but these directories should be written to.
Trusted hosts from VALLIC_HOSTNAMES: exactly the hostnames the edge
routes to this environment, nothing else. Add a domain in the console and the
pattern follows on the next reconcile.
The reverse proxy. The edge terminates TLS and forwards over plain HTTP,
so the file trusts the address a request arrives from — which is only ever the
edge — and reads the client's address, scheme and port from the X-Forwarded
headers it sets. Without this, Drupal would build http:// links and log the
edge as every visitor.
Redis when the stack has one (REDIS_HOST, whether it is Redis or Valkey)
and the site ships the redis module. The module does not need to be
installed for the cache to move there: its services are registered from the
file, and the container cache lives in Redis from the first request.
The kind of environment from VALLIC_ENVIRONMENT_TYPE: production hides
errors from visitors, everything else shows them, and an environment indicator
is coloured accordingly if the site has that module.
Mail, through the stack's relay (SMTP_HOST). Drupal's default sends
through a sendmail binary the container does not have, so the file points
Symfony Mailer — 1.x or 2.x — at the relay on port 25, inside the stack,
instead. The relay sends it on through the provider you give it as
RELAY_HOST, on port 587; without one, mail does not leave — see
OpenSMTPD. A site that uses neither module is
unaffected; one that sends through a hosted provider's API overrides it with
its own transport.
Sessions need nothing. Drupal keeps them in the database, which every web server shares, so a visitor stays logged in whichever machine answers. The Redis block above moves the cache, not the sessions.
What it leaves to you
Some settings are wiring the platform can name but a site has to place,
because they live in a module's own configuration rather than in
$settings. Add these to the same file as the site needs them.
Solr, when the stack runs it. The search_api server is configuration, so
override it with the host and the login the platform names. Solr refuses a
request without the login, so the server needs the basic-auth connector:
if (getenv('SOLR_HOST')) {
$solr = &$config['search_api.server.solr']['backend_config'];
$solr['connector'] = 'basic_auth';
$solr['connector_config']['host'] = getenv('SOLR_HOST');
$solr['connector_config']['port'] = 8983;
$solr['connector_config']['core'] = getenv('VALLIC_SLUG');
$solr['connector_config']['username'] = getenv('SOLR_USER');
$solr['connector_config']['password'] = getenv('SOLR_PASSWORD');
unset($solr);
}
Vinyl Cache (Varnish), when the stack runs it. A purger needs the cache's address and
the hostnames a purge applies to — both named by the platform. A purge sent
to VARNISH_HOST from the site needs no key; one arriving from outside needs
VARNISH_PURGE_KEY in an X-VC-Purge-Key header:
if (getenv('VARNISH_HOST')) {
$config['varnish_purger.settings.YOUR_PURGER_ID']['hostname'] = getenv('VARNISH_HOST');
$config['varnish_purger.settings.YOUR_PURGER_ID']['port'] = 6081;
}
The CDN, when the project has one. Install Vallic Purge and there is nothing to write here: it reads the purge settings from the environment and clears the pages a change affects as it is saved. See CDN.
The config sync directory is a project convention rather than a platform
fact — ../config/sync on most projects — and stays in settings.php.