WordPress
How your AI sets up WordPress on LazyFrog, step by step in the right order, so your site keeps the correct address and its database works from day one.
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.
-
Files. WordPress in
workspace/(or a subfolder), PHP 8.5 as installed. Run it withphp-fpmand Caddy, both in the foreground in one start script (php-fpm -F,caddy run, nevercaddy startor 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'sclear_env = no,error_login a file under/tmp, andupload_max_filesizeandpost_max_sizeraised 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 returnsfalsefor a file that exists and otherwise requiresindex.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 theappuser your commands already run as: nosudo, nochown, anddefine('FS_METHOD', 'direct');so updates need no FTP form. -
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 itsdb.copytowp-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 inwp-config.php)./datasurvives 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. -
wp-config.phpbefore anything runs. The edge ends TLS and talks plain HTTP to the site, saying the visitor's scheme inX-Forwarded-Proto. Without the first lines below WordPress thinks every request ishttp, storeshttp://addresses, andwp-login.phpredirects forever (the browser'sERR_TOO_MANY_REDIRECTS). Put them aboverequire_once ABSPATH . 'wp-settings.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_URLis 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 setCOOKIE_DOMAIN, and don't addwww. Behind Caddy'sphp_fastcgi, addenv HTTPS oninside its block as well: Caddy replaces the forwarded scheme with its ownhttp.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 setWP_SALT_SEEDwithgenerate: "base64-32"for env preview and for env production (two calls: the environments don't share secrets), and any other password or key throughsecrets 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):$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/**"]). -
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 throughsecrets 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 checkhttp_geton/wp-login.php(200, no redirect) and the home page's<link>and image addresses (allhttps://). -
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.phpdefine('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'smigratecommand, 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 astools/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-rootis not needed: it runs as the app user). Onlycore 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: aversioncommand in services.json that prints the database version the code expects ($wp_db_versioninwp-includes/version.php) and the one the data is at (thedb_versionoption), as one JSON line. Commit it astools/lf-version.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) andproject getsay "App versions" with both numbers. Undoing a publish that ran it, with its data (deploy rollbackwithwith_database: true), restores the live database and/datato 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 ahealthcheck_paththat answers 200 only on an installed site, such as/wp-login.php, never the installer. -
Pitfalls.
-
Redirect loop on login: the snippet in step 3 is missing or comes after
wp-settings.php, or the site was installed under anhttp://address. Fix the file, then setsiteurlandhomein theoptionstable to thehttps://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-replaceor a SQL update on the SQLite file); always writehttps://or//. -
WP-Cron runs only when someone visits, and the preview sleeps after 15 quiet minutes. Set
define('DISABLE_WP_CRON', true);and add acronservice that runsphp wp-cron.phpevery 5 minutes (it needsalways_onfor 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.phpis 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_seedin services.json to WP-CLI's serialized-safe search-replace of the origin (scheme-relative, sohttps://and//links both change;guidleft alone, as WordPress advises), and drop the preview's salt seed so the live site makes its own:"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.phpmust setWP_HOMEandWP_SITEURLfromSITE_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-adminis 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
projectabout: 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.
-
For your AI: this page as Markdown · llms.txt