Skip to content
LazyFrog

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.

  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';:

    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):

    $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

    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:

    #!/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:

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

For your AI: this page as Markdown · llms.txt