# WordPress (https://lazyfrog.dev/docs/guide/wordpress)

{/* Generated by scripts/generate-docs.ts from services/gateway/src/guide.md. Do not edit. */}

The recipe when the user chose WordPress (see "A stack someone else suggested" first). The order
matters: a site installed before its `wp-config.php` is final keeps the wrong address in its
database.

1. **Files.** WordPress in `workspace/` (or a subfolder), PHP 8.5 as installed. Run it with
   `php-fpm` and Caddy, both in the foreground in one start script (`php-fpm -F`, `caddy run`, never
   `caddy start` or a php-fpm that daemonizes: LazyFrog ends one with the service when it restarts
   or stops, but its errors and exit aren't the service's, so a crash goes unseen), with php-fpm's
   `clear_env = no`, `error_log` in a file under `/tmp`, and `upload_max_filesize` and
   `post_max_size` raised for the uploads the site takes (sermons, podcasts: 64M or more).
   `php -S 0.0.0.0:$PORT -t <dir> <router.php>` is for a first look only (the router returns
   `false` for a file that exists and otherwise requires `index.php`, or pretty permalinks 404): it answers a
   range request with the whole file, so audio and video won't play or seek on iPhones, and it
   takes uploads of 2 MB at most. Files belong to the `app` user your commands already run as: no `sudo`, no
   `chown`, and `define('FS_METHOD', 'direct');` so updates need no FTP form.
2. **Database: SQLite, not MySQL.** LazyFrog's database is PostgreSQL, which WordPress can't use.
   Put the official SQLite Database Integration plugin in `wp-content/plugins/`, copy its
   `db.copy` to `wp-content/db.php` (the drop-in; fill in its two path placeholders as the
   plugin's readme says), and keep the database file in `/data` (for example
   `/data/wordpress/.ht.sqlite`, with the directory and file name constants your plugin version
   reads in `wp-config.php`). `/data` survives restarts and publishes, and LazyFrog snapshots
   it. Uploads too: `ln -s /data/wp-uploads wp-content/uploads` (git-ignored), or they vanish with
   the next publish.
3. **`wp-config.php` before anything runs.** The edge ends TLS and talks plain HTTP to the site,
   saying the visitor's scheme in `X-Forwarded-Proto`. Without the first lines below WordPress
   thinks every request is `http`, stores `http://` addresses, and `wp-login.php` redirects
   forever (the browser's `ERR_TOO_MANY_REDIRECTS`). Put them above `require_once ABSPATH .
   'wp-settings.php';`:

   ```php
   if (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') { $_SERVER['HTTPS'] = 'on'; }
   $lf_site = getenv('SITE_URL') ?: 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
   define('WP_HOME', rtrim($lf_site, '/'));
   define('WP_SITEURL', WP_HOME);
   ```

   `SITE_URL` is the address of the environment it runs in (the preview's own on the preview, the
   live address on the live site), so the same file works in both and the constants win over
   whatever the database holds. Do not set `COOKIE_DOMAIN`, and don't add `www`. Behind Caddy's
   `php_fastcgi`, add `env HTTPS on` inside its block as well: Caddy replaces the forwarded
   scheme with its own `http`.

   **No secret values in `wp-config.php`.** The commit and every publish refuse a file with the
   salts, the database password or a key written into it ("this commit contains what looks like
   a secret", and the publish stops at the commit step), so never paste salts from WordPress's
   generator or an example, and never write a password in the file. Read them from the
   environment: `secrets set` `WP_SALT_SEED` with `generate: "base64-32"` for env preview and for
   env production (two calls: the environments don't share secrets), and any other password or
   key through `secrets request`. Then the file derives the eight salts from it, and falls back
   to a seed generated once under `/data` (kept across restarts and publishes, never committed):

   ```php
   $lf_seed = getenv('WP_SALT_SEED');
   if (!$lf_seed) {
     $f = '/data/wordpress/salt-seed';
     if (!is_file($f)) { @mkdir(dirname($f), 0700, true); file_put_contents($f, bin2hex(random_bytes(32))); }
     $lf_seed = trim((string) file_get_contents($f));
   }
   foreach (['AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT',
             'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT'] as $n) {
     define($n, hash_hmac('sha256', $n, $lf_seed));
   }
   // Another secret the site needs, for example a mail or API key: define('SMTP_PASSWORD', getenv('SMTP_PASSWORD'));
   ```

   Changing the seed signs everyone out, nothing more. The scan leaves WordPress's own files,
   plugins and default themes alone (they aren't yours to edit) but still refuses a real key in
   any of them. A plugin or library you copied in and keep yourself is yours to declare: add its
   folder to `"vendored"` in services.json (for example `"vendored": ["wordpress/wp-content/mu-plugins/vendor-lib/**"]`).
4. **Install last.** Only now run the installer (the browser wizard, or `wp core install --url=
   "$SITE_URL"` if WP-CLI is installed), choosing the admin password with the user through
   `secrets request`, never in a command. A password-reset link (`wp-login.php?action=rp`) is no
   way round that: it works as a password in the chat, and your own test login as that user voids
   it. Then check `http_get` on `/wp-login.php` (200, no
   redirect) and the home page's `<link>` and image addresses (all `https://`).
5. **Code in preview, content in live.** Tell the user before the first publish: "Update
   WordPress and plugins here in preview, then publish. Write posts on the live site." Core,
   plugin and theme updates and installs happen only in the preview (they are code, and they
   change the database's version); the live site only receives them through a publish. So the
   live site must not allow them: in `wp-config.php`

   ```php
   define('AUTOMATIC_UPDATER_DISABLED', true);
   if (getenv('LF_ENV') === 'production') { define('DISALLOW_FILE_MODS', true); }
   ```

   Automatic updates are off in both, so security releases arrive only when you update WordPress
   in the preview and publish: check for them when you pick the project up. On the live site
   "Add New" and the update buttons are gone and WordPress says "Sorry, you are not allowed";
   tell the owner why (an mu-plugin can say it in the admin:
   `add_action('admin_notices', fn() => getenv('LF_ENV') === 'production' && print '<div class="notice notice-info"><p>Updates and new plugins are made in the preview, then published.</p></div>');`).
   A plugin added in the preview arrives on the live site inactive (which plugins are active is
   live content): activate it in the live admin once it is published; a plugin with its own
   database upgrade (WooCommerce's, for one) runs it from the live admin's notice. A plugin
   installed or updated in the live admin would be lost at the next publish, or would leave the
   code and the database on different versions. **The database upgrade** after an
   update is WordPress's own, run by the project's `migrate` command, which a publish runs on a
   copy of the live data first and then on the live data after a snapshot (a failure puts the
   snapshot back and the live site stays as it was). Put WP-CLI in the project, a pinned version
   downloaded once, its checksum checked, and committed as `tools/wp-cli.phar` (it must be in
   the release; never fetched at runtime), and set `"migrate": "php tools/wp-cli.phar core update-db --path=<the WordPress
   folder>"` in services.json (`--allow-root` is not needed: it runs as the app user).
   Only `core update-db`: plugin and theme updates are made in the preview, not by the
   migrate command. **Declare the versions** so LazyFrog can refuse older code on a newer
   database: a `version` command in services.json that prints the database version the code
   expects (`$wp_db_version` in `wp-includes/version.php`) and the one the data is at (the
   `db_version` option), as one JSON line. Commit it as `tools/lf-version.sh`:

   ```sh
   #!/bin/sh
   wp=wordpress   # the WordPress folder
   code=$(grep -oE 'wp_db_version[[:space:]]*=[[:space:]]*[0-9]+' "$wp/wp-includes/version.php" | grep -oE '[0-9]+$')
   data=$(php tools/wp-cli.phar option get db_version --path="$wp" --skip-plugins --skip-themes 2>/dev/null)
   printf '{"code":"%s","data":"%s"}\n' "$code" "${data:-0}"
   ```

   and set `"version": "sh tools/lf-version.sh"`. The command runs in the code's own folder
   (use paths relative to it), against the data of the environment it runs in. The rehearsal
   refuses a publish whose code is older than the live database, and so does a rollback to
   older code; `service_status` (per environment) and `project get` say "App versions" with
   both numbers. Undoing a
   publish that ran it, with its data (`deploy rollback` with `with_database: true`), restores
   the live database and `/data` to just before: posts, pages and uploads written on the live
   site since are lost from it (kept in a snapshot): say so to the user. Give the web service a
   `healthcheck_path` that answers 200 only on an installed site, such as `/wp-login.php`, never
   the installer.
6. **Pitfalls.**
   - Redirect loop on login: the snippet in step 3 is missing or comes after `wp-settings.php`,
     or the site was installed under an `http://` address. Fix the file, then set `siteurl` and
     `home` in the `options` table to the `https://` address (or reinstall into an empty
     database).
   - Mixed content (unstyled or half-loaded pages): `http://` addresses saved in posts, widgets,
     page-builder settings and the options table. Replace them in the database (`wp
     search-replace` or a SQL update on the SQLite file); always write `https://` or `//`.
   - WP-Cron runs only when someone visits, and the preview sleeps after 15 quiet minutes. Set
     `define('DISABLE_WP_CRON', true);` and add a `cron` service that runs `php wp-cron.php`
     every 5 minutes (it needs `always_on` for the live site to wake; otherwise scheduled posts
     wait for the next visit).
   - The live site starts empty (its own database and `/data`) unless the one-time copy was made
     at the first publish: the preview's pages, settings and admin account are not otherwise
     copied. Then the live address's `/wp-admin/install.php` is open to anyone who finds it
     until it is completed: tell the user before publishing, and have them complete it right
     after the publish (or protect the live site with a password in routing until then).
   - **Before offering the one-time copy**, declare what the copied database needs: WordPress
     stores its own address inside posts and options, so the copy must have the preview's
     address replaced by the live one. Set `after_seed` in services.json to WP-CLI's
     serialized-safe search-replace of the origin (scheme-relative, so `https://` and `//` links
     both change; `guid` left alone, as WordPress advises), and drop the preview's salt seed so
     the live site makes its own:

     ```json
     "after_seed": "rm -f /data/wordpress/salt-seed; php tools/wp-cli.phar search-replace \"//${LF_PREVIEW_ORIGIN#*//}\" \"//${LF_LIVE_ORIGIN#*//}\" --all-tables --skip-columns=guid --skip-plugins --skip-themes --path=wordpress --format=count"
     ```

     `wp-config.php` must set `WP_HOME` and `WP_SITEURL` from `SITE_URL` (step 3) first, or the
     copied options send visitors back to the preview. Appearance and customizer settings live
     in the database too: after a publish without the copy, tell the user to set them on the
     live site (theme files and plugins are code and come from the preview).
   - Plugins and themes: add them in the code (the preview) and publish; installing them through
     the live site's `/wp-admin` is switched off (above). Posts, pages and media written in the
     live admin stay on the live site.
   - Sign-in on the preview: the preview has its own address, so an admin session there does not
     carry to the live site; use two accounts or the live admin. A preview password (project
     settings) shows its own page before WordPress's.
   - Record it with `project` `about`: link "WP admin" with url `/wp-admin`, the WordPress
     version, where the database file and uploads live, how to update, and the secret's name for
     the admin password (`secrets`, never the value).
   - Tell the user it's done only after a real login and a page view in `preview_screenshot`,
     never from the installer's success page.
