Running WordPress here — the manifest, and a wp-config that reads the environment instead of committing it.
WordPress needs no bootstrap file of ours, and very nearly no work: the
variables the platform writes are already the names WordPress reads.
DB_NAME, DB_USER, DB_PASSWORD and DB_HOST are its own constants. All
wp-config.php has to do is read them from the environment rather than hold
them.
version: 1
type: wordpress
runtime:
php: '8.4'
services:
- mariadb: '11.8'
- valkey: '8'
build:
steps:
- composer install --no-dev --optimize-autoloader
- 'mkdir -p bin && curl -fsSL -o bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && chmod +x bin/wp'
cron:
- name: wp-cron
schedule: '*/5 * * * *'
command: 'wp cron event run --due-now'
services is checked, not obeyed, for the database. A service such as
Valkey 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.
wp-cli is fetched as a build step rather than baked into the image, so it is
versioned with your project rather than with ours — the same reasoning as
drush living in a Drupal project's vendor/bin. bin/ is on the PATH of
every PHP container, which is how wp is found. The mkdir is not decoration:
curl will not create the directory it is asked to write into.
The document root is the root of the repository, so wp-config.php and
WordPress itself sit beside vallic.yaml — the classic layout, not one that
nests WordPress in a subdirectory.
WordPress's own wp-cron fires on visitor requests, which makes scheduled work
depend on somebody turning up. Run it from cron instead and add
define('DISABLE_WP_CRON', true); to your config.
Two commands run in your PHP container without being asked for, and both are wp-cli:
| When | Command | |
|---|---|---|
| Hourly | wp cron event run --due-now |
Only if your manifest declares no cron of its own |
| After a backup is restored | wp cache flush |
So the object cache does not serve what the restore replaced |
Hourly, as for Drupal. --due-now catches up on whatever is overdue, so
nothing scheduled is lost — but with DISABLE_WP_CRON set this is the only
thing running WordPress's schedule, so a post scheduled for 9:10 goes out at
10:00. If that is too late, declare your own, as the manifest above does every
five minutes. Declaring any cron replaces the platform's job, so to add a job
of your own, keep a wp cron event run beside it.
Both need bin/wp in the release, which is what the wp-cli build step puts
there. Without it the scheduled run fails every hour and the flush
after a restore fails with it, and nothing warns you beforehand — keep the
build step even if you never run wp-cli yourself.
A change to the schedule reaches an environment on its next deploy, which is when its cron is written. However many web servers the environment has, cron runs on one of them, so nothing scheduled runs twice.
wp-content/uploads is linked out of every release into storage that outlives
it — it is what VALLIC_PUBLIC_DIR points at — so uploads survive a deploy
with nothing set in wp-config.php.
Everything else in the release is read-only while the site runs. Installing or updating a plugin or theme from the dashboard cannot work, and neither can WordPress's automatic updates: add plugins in the repository (or with Composer in the build) and deploy. The config below turns those buttons off rather than leaving them to fail.
// The database, from the environment.
define('DB_NAME', getenv('DB_NAME'));
define('DB_USER', getenv('DB_USER'));
define('DB_PASSWORD', getenv('DB_PASSWORD'));
define('DB_HOST', getenv('DB_HOST') . ':' . (getenv('DB_PORT') ?: '3306'));
define('DB_CHARSET', 'utf8mb4');
// Salts, derived from the environment's own entropy rather than committed.
// One value, eight distinct keys: deriving them means they differ from each
// other and from every other environment, and rotating the entropy rotates
// all eight.
$vallic_entropy = getenv('VALLIC_ENTROPY') ?: '';
foreach ([
'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
] as $vallic_salt) {
define($vallic_salt, hash_hmac('sha256', $vallic_salt, $vallic_entropy));
}
// The edge terminates TLS and proxies over plain HTTP, so WordPress has to be
// told the scheme or it writes http:// into every absolute URL and redirects
// in a loop.
if (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
$_SERVER['HTTPS'] = 'on';
}
if (getenv('PROJECT_BASE_URL')) {
define('WP_HOME', getenv('PROJECT_BASE_URL'));
define('WP_SITEURL', getenv('PROJECT_BASE_URL'));
}
// Debugging everywhere but production, and errors to the log, never to a
// visitor. The log goes to VALLIC_LOG_DIR because wp-content is read-only, and
// because that directory is collected with the rest of your logs.
define('WP_DEBUG', getenv('VALLIC_ENVIRONMENT_TYPE') !== 'production');
define('WP_DEBUG_DISPLAY', false);
define('WP_DEBUG_LOG', (getenv('VALLIC_LOG_DIR') ?: '/var/log/app') . '/wordpress.log');
// The release is read-only: code changes arrive by deploy, not from wp-admin.
define('DISALLOW_FILE_MODS', true);
define('AUTOMATIC_UPDATER_DISABLED', true);
define('DISABLE_WP_CRON', true);
The X-Forwarded-Proto line matters more than it looks. Without it WordPress
sees plain HTTP, writes http:// into every generated URL, and a browser that
arrived over HTTPS is redirected back and forth until it gives up.
WordPress core keeps logins in signed cookies rather than server-side sessions, so a visitor stays logged in whichever machine answers. A plugin that starts PHP sessions of its own is the exception: those live in files on one machine, and need a Redis session handler once there are two.
The object cache is worth moving to Valkey at the same point, so every server sees the same cached values. With the Redis Object Cache plugin, tell it where the stack's Valkey is:
if (getenv('REDIS_HOST')) {
define('WP_REDIS_HOST', getenv('REDIS_HOST'));
define('WP_REDIS_PORT', (int) getenv('REDIS_PORT'));
define('WP_REDIS_PREFIX', getenv('VALLIC_SLUG'));
}