Skip to main content

WordPress

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.

#The manifest

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.

#What the platform runs for you

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.

#What is kept, and what is read-only

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.

#wp-config.php

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

#More than one web server

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'));
}

Next

  • Variables — everything available to read
  • vallic.yaml — the manifest in full
  • Logs — where your output goes and how to send a copy on