How to set up a Craft CMS site so update runs, previews and backups work well.
Ledge works with most Craft CMS sites as they are. Update runs copy the site into a sandbox from two sources, the linked repository and an encrypted copy of the production database, so the closer those two are to what's live, the more an update run can tell you. Each item below says what it changes.
composer.lock and config/project/ committed.Update runs build the sandbox from the repository's branch, so they need one linked. Without it, health checks, uptime and advisories still work, but you can't start an update run.
composer.lock and deploy with composer installThe sandbox installs exactly what's in composer.lock, and the pull request changes it. If production runs composer update on deploy, what's live can drift from what was tested. Ledge also compares the lockfile on the site with the one in the repository, so it can tell when they drift apart.
A pull request can carry changes to composer.json, composer.lock and config/project/. If someone changes settings in production's control panel, production's project config gets ahead of the repository. Ledge then marks the run for review and warns you in the pull request.
Set allowAdminChanges to false in production so settings changes go through the repository. The plugin reports whether it's on.
composer.jsonLedge runs the sandbox on the PHP version the plugin reports from production. Setting config.platform.php to match keeps Composer resolving packages for the PHP you actually run.
{
"config": {
"platform": { "php": "8.3" }
}
}Ledge detects a build, production or prod script in package.json and runs it with your lockfile's package manager. The Node version comes from .nvmrc, .node-version, engines.node or DDEV's config. A failed build doesn't fail the run, and you can override the build command for each site.
Update runs can't authenticate to private Composer repositories yet, such as private Git repositories, Private Packagist or Satis. Plugins from the Craft Plugin Store are fine. If a site needs a private package, its update runs fail when Composer installs dependencies.
Use environment variables for each site's base URL, for example $PRIMARY_SITE_URL, rather than a hard-coded address. Ledge points those variables at the sandbox so the preview links to itself. A hard-coded URL sends preview links to production instead, and Ledge flags it in the run.
Ledge turns email off in the sandbox so a test run can't send anything to real people. It does this through environment variables, so keep Craft's email settings in env vars rather than hard-coded in project config.
The sandbox gets the environment variables the site's queue worker can see, with the site URL, database and mail settings replaced. The plugin's own LEDGE_* variables are never included. To keep other secrets out, list them in acquireEnvDenylist. If a removed value is needed to boot the site, give the sandbox a safe replacement as an environment override in Ledge.
Set environment variables where the queue worker can see them, not only in the web server's config. The copy runs as a queue job, so a value that only php-fpm knows won't reach the sandbox.
Ledge never copies uploaded files into the sandbox. It copies the code and the database, and nothing else.
| Where uploads live | In the preview | In Ledge backups |
|---|---|---|
| Remote storage with an absolute base URL | Real images | Not included. Keep the bucket versioned. |
| A local folder on the server | Broken images | Not included |
Remote storage is the better setup for Ledge, but not because update runs need it. The crawl checks pages for errors, not images, so broken images don't fail a run either way. What remote storage changes:
If you leave your storage credentials out of the sandbox, add read-only ones as an environment override so remote volumes can still load.
mysqldump or pg_dump must be on the queue worker's PATH, or set backupCommand in Craft's config.Run Craft's queue with a daemon or a cron job, not only from web requests. Backups and update runs start as queue jobs, and each must finish within an hour, including the time the job waits in your queue. On a quiet site that only runs the queue when someone visits, that may not happen in time.
If you run more than one web or worker server, use a shared cache such as Redis or the database, so every server sees the same state for a run.
www or not./_ledge/* with their headers, including the POST that starts a copy. If Cloudflare or a WAF gets in the way, see Sites behind Cloudflare. The origin IP, basic auth and custom headers you set on the health check are used for every request to the plugin.ledgehq.app and its subdomains, where the copy is sent.After the updates are applied, Ledge crawls the sandbox: your homepage, your uptime check URLs, a sample of entries from each section, and your sitemap, up to 1,000 pages. Ledge doesn't crawl production first, so pages that already return errors show up in the results and send a run to review. Fixing broken entries in your sitemap gives cleaner results. See Update runs for what else sends a run to review.