Jump to content

Recommended Posts

Posted

A developer and AI assistant safely deploy code while the production database remains protected

Once a ProcessWire site is live, the production database is not a deployable artifact. It is the source of truth for content, users, roles, module settings, and module-owned data.

Deploy code from Git. Apply reviewed migrations to the production database. Preserve production data, uploads, configuration, and secrets. If an AI coding agent is involved, write these boundaries down in AGENTS.md and enforce them with scripts rather than relying on the agent to infer what “deploy” means.

I arrived at this rule through a real incident.

The incident

I was rebuilding a ProcessWire site locally with an AI coding agent. The data model had changed substantially, the development site contained useful demo content, and the production site had little activity. I asked the agent to deploy the new version and replace the old data.

Technically, the operation succeeded:

  • the code was deployed;
  • the new schema appeared;
  • the development database was imported;
  • development uploads replaced production uploads;
  • the public pages worked.

But a database is not just “content”. The import also replaced production-only module configuration, users, roles, support tickets, AI conversations, and records stored in custom module tables. SMTP happened to survive because its configuration matched in both environments. Other settings did not.

Backups existed, so the incident was recoverable. The site also had almost no real activity. On a busy site, the same operation could have destroyed orders, form submissions, member accounts, unpublished editorial work, audit history, or messages created during the deployment window.

The important lesson was not “the AI made a mistake”. The deployment process allowed an ambiguous instruction to become a destructive database operation. That is a systems problem.

Why ProcessWire needs an explicit deployment model

ProcessWire's file layout is deliberately clear: /wire/ contains the core, while /site/ contains site-specific templates, modules, configuration, and assets. The official documentation also identifies site/config.php as the site configuration file and site/assets/ as writable runtime storage.

That filesystem separation is useful, but a mature ProcessWire project has another boundary that is easier to miss:

Deployment ownership map

  • Git release — templates, hooks, module code, and frontend assets. Source of truth: the repository.
  • Database structure — fields, templates, fieldgroups, and module schemas. Source of truth: reviewed migrations.
  • Production database — pages, users, roles, settings, submissions, and module-owned tables. Source of truth: production.
  • Runtime configuration — database credentials, hostnames, and environment flags. Source of truth: the production runtime.
  • Secrets — SMTP, API, and OIDC credentials. Source of truth: a secret store or protected runtime files.
  • Writable assets — uploads, generated files, logs, sessions, and backups. Source of truth: production storage.

The initial launch is special. If production is empty, cloning a reviewed development database may be reasonable. After the first real user, form submission, editor change, or module configuration, that direction must stop.

Production may flow to development through a protected and preferably sanitized snapshot. Development must not flow back as a whole database.

This is the same problem described in ProcessWire's own article introducing migrations: once the live server receives content while features continue to be developed locally, the databases diverge and changes must be merged rather than replaced.

Use releases for code and migrations for change

A production release should be an immutable Git commit, preferably accompanied by a tag. Build the deployable file list from that commit—not from the current working directory.

A release manifest should include only public runtime code and static assets. It should explicitly exclude:

  • .git/ and CI configuration;
  • AGENTS.md and development documentation;
  • site/config.php and environment-specific config;
  • database dumps, credentials, and private keys;
  • site/assets/files, caches, logs, sessions, and backups;
  • tests, fixtures, local tooling, and editor configuration.

ProcessWire fields and templates live in the database, so code deployment alone is not always sufficient. Represent structural changes as idempotent, versioned migrations. A migration may use ProcessWire's API, a migration module, or a project-specific CLI script. The important properties are the same:

  1. it has a preview or audit mode;
  2. it is safe to run more than once;
  3. it changes only named structures or records;
  4. it preserves unknown production configuration;
  5. large transformations are bounded and resumable;
  6. destructive steps require a fresh verified backup and rollback data;
  7. successful application is recorded.

ProcessWire has community options such as ProcessDbMigrate and migration modules, but no tool removes the need to classify production data correctly.

A backup is not verified until it can be restored

“The backup command returned zero” is not enough.

For every production release:

  1. create a timestamped database backup outside the document root;
  2. verify its size and checksum;
  3. verify compressed-stream integrity;
  4. periodically restore it into an isolated database;
  5. keep a backup of the exact files being replaced;
  6. record the rollback path before changing production.

This matters because backup tools can produce syntactically valid-looking files that fail only during restoration. Test the recovery path, not merely the backup button.

The official ProcessWire upgrade guide likewise recommends backing up both files and database and testing upgrades on a development or staging site first.

Add a fail-closed deployment guard

Documentation is necessary, but an executable invariant is better.

Before deployment, create a private snapshot containing no secret values:

  • row counts for protected tables;
  • page counts by template;
  • stable page IDs by template;
  • installed module names;
  • names and configured/empty state of module configuration keys;
  • intended Git commit;
  • production hostname;
  • verified backup metadata.

After code deployment and migrations, take the same measurements and compare them. Block completion if:

  • a protected table disappeared or lost rows;
  • a pre-existing page ID disappeared;
  • a template or installed module disappeared;
  • a module configuration key disappeared;
  • a configured value became empty;
  • the host or release commit does not match.

Stable IDs matter. Row counts alone can miss a replacement database containing the same number of different records.

Some legitimate migrations intentionally remove obsolete data. That should be an exceptional workflow requiring a change ID, a table-by-table impact report, a tested restore, and a second explicit approval. A generic “deploy everything” instruction must never authorize it.

What to tell an AI coding agent

An AI agent is fast enough to turn an ambiguous sentence into a complete deployment before a human notices the ambiguity. Give it a narrower contract.

The repository instructions should state:

  • development and production paths and hostnames;
  • production is read-only unless a production change is explicitly requested;
  • commit, push, deploy, migrate, publish, and replace data are separate actions;
  • “deploy” means code-only unless named migrations are included;
  • a development database must never overwrite a launched production database;
  • production users, content, module configuration, custom tables, and uploads must be preserved;
  • secrets must not be read, printed, hashed, copied locally, or committed;
  • every mutation needs a fresh verified backup and exact rollback path;
  • migrations run in preview first and must be bounded/idempotent;
  • a data decrease is a blocker, not a warning;
  • if scope is ambiguous, stop after read-only investigation;
  • destructive approval is two-phase: impact report first, explicit confirmation of that report second;
  • maintenance is not removed and success is not reported while checks fail.

The open AGENTS.md format is useful here because it gives multiple coding agents a predictable repository-level instruction file. Its documentation explicitly recommends including build commands, testing, security concerns, and deployment details, and supports more specific nested files where needed.

I prepared a reusable template and attached it to this post as ProcessWire-AGENTS-production-safety-template.zip. The archive contains a single AGENTS.md file. Download it, replace the placeholders, and commit it to the root of your project. Do not deploy it into the public document root.

A practical release checklist

Before maintenance

  • Select an immutable Git commit.
  • Review the diff and release manifest.
  • List every migration and classify its risk.
  • Create and verify database and file backups.
  • Create the protected data snapshot.
  • Write the rollback command/path.

During maintenance

  • Deploy only manifest files.
  • Preserve production config, secrets, uploads, logs, sessions, and backups.
  • Run migrations in preview, then apply.
  • Run the fail-closed comparison.
  • Stop on any unexplained decrease.

Before reopening

  • Test origin and CDN routes.
  • Test authentication and representative member workflows.
  • Verify email delivery readiness and background jobs.
  • Inspect new logs without exposing secrets.
  • Record commit, manifest checksum, migration results, backups, and rollback.
  • Remove maintenance only when every blocker is resolved.

Final principle

The database is not “the backend version of the code”. On a live ProcessWire site it is accumulated production state.

AI does not change this rule. AI makes it more important to encode the rule in files, scripts, permissions, and fail-closed checks—because the agent can execute a bad assumption much faster than a human can recognize it.

References

ProcessWire-AGENTS-production-safety-template.zip

 

 

 

 

  • Like 5
Posted

Thanks for this useful post 🙏!  I think this kind of tutorials are very important.

  1. To make it even more practical, could you somehow share a step by step workflow?  Not what to do, but exactly how you think it should be done.  What tools, what commands, where to look for what. Maybe I'm asking for too much. It's better to ask than not)
  2. What migration strategy, module, tool do you use? Could you elaborate on this a bit?
  • Like 1
Posted

Great insights there. Your issues have revealed a few gaps in my own AI modules workflow. 

I tend not to replace databases wholesale (anymore), but this is a relatively new approach for me, and I am still tweaking it.
I.E. the production database is regarded as something I push specific changes to, not something I replace wholesale.
It makes syncing the changes more granular, preview-first, and path-aware. Which I kinda prefer as it feels more surgical.

But I have the benefit of working on medium-ish sites with no external contributors, which certainly helps.

BTW, on the subject of the gaps you highlighted, I'll feed them into my own AI module (with your permission) and that's how I evolve it.
Anytime I have an issue, it gets fed back into the model, and over time, has evolved into a really great part of my workflow.

  • Like 1
Posted

@Ivan Gretsky Thank you — you are not asking for too much. The commands are the useful part, and this probably deserves a follow-up post with a small reference repository.

My current workflow is deliberately boring and project-specific:

1. Select the release from Git, never from the working tree:

git status --short
git rev-parse HEAD
git diff --check
git tag -a vX.Y.Z -m "Release vX.Y.Z"

2. Build an allowlist from that exact commit. In my project this is:

php site/templates/scripts/build-release-manifest.php --commit="$(git rev-parse HEAD)" --output=/private/releases/vX.Y.Z/manifest.txt
sha256sum /private/releases/vX.Y.Z/manifest.txt

3. Run a read-only production preflight: verify the host and document root, PHP/database versions, free disk space, maintenance mechanism, and classify every migration as additive, transforming, or destructive.

4. Create a database backup outside the web root, verify its size, checksum and compression, and periodically prove it with an isolated restore. I also back up only the production files listed for replacement.

5. Create a protected pre-deploy snapshot. My guard records table counts, page IDs grouped by template, installed modules, module configuration key presence (never secret values), host, commit and backup metadata:

php site/templates/scripts/deployment-guard.php --environment=production --snapshot=/private/deployments/DEPLOY_ID/pre.json --backup=/private/backups/DEPLOY_ID/production-before.sql.gz --commit=FULL_COMMIT

6. Deploy only the committed allowlist. tar, rsync or scp are all fine when driven by the reviewed manifest; a full workspace sync and a development database import are not.

7. Run each migration in preview mode, review it, then apply it:

php site/templates/migrations/2026-08-11-example.php
php site/templates/migrations/2026-08-11-example.php --apply

8. Run the guard in compare mode and fail closed:

php site/templates/scripts/deployment-guard.php --environment=production --compare=/private/deployments/DEPLOY_ID/pre.json --commit=FULL_COMMIT

9. Smoke-test origin and CDN, login/member flows, forms/email readiness/background jobs, and inspect new logs before leaving maintenance mode.

For migrations, I do not use one universal module for everything. On this project I use small, versioned CLI PHP migrations built on the ProcessWire API. Fields, templates, fieldgroups and named structural module settings are changed through ProcessWire, not by replacing the database. Module-owned table migrations live with the module. Every script has preview/apply modes, is idempotent, and large jobs are bounded and resumable. Direct SQL is reserved for a reviewed migration of a table the module owns.

ProcessDbMigrate or another migrations module can absolutely implement the same policy. For me the important part is not the brand of tool but the contract: preview first, named changes only, preserve unknown production state, record what ran, and stop on unexplained decreases.

The ZIP attached to the original post contains the AGENTS.md safety template, not these project scripts. I can publish generic versions of the manifest builder, deployment guard and an example migration in a follow-up, with placeholders and without LQRS-specific assumptions.

@Peter Knight Yes, absolutely — please use, adapt and feed any of these gaps into your AI module. That feedback loop is exactly what I am trying to formalise: every incident should become a persistent rule, a check, or an executable invariant instead of remaining a memory. I would also be interested to see how your more surgical, path-aware workflow evolves.

  • Like 2
  • 2 weeks later...
Posted

Interesting discussion, let me add my own experiences. I asked Opus 4.8 to explain how "she" and I solved it. I have been using the following method for a few weeks now and found it to be a solid solution for AI-aided ProcessWire development.

This is the exact problem we've spent months building around, and your one-liner ("the production database is not a deployable artifact") is almost word-for-word the sentence at the top of our own rules. Since Ivan asked for mechanics rather than concepts, here's the concrete shape of what we run.

A word on what "our setup" actually is, since it isn't a module you install: it's a handful of small command-line tools, each doing one job, that a human and an AI agent drive exactly the same way. Two of them are ours: one stamps the local/production split onto a site (the environment flag, credentials, local mail rerouting), and one handles sync and deploy through a short list of verbs, check, snapshot, pull, restore-snapshot, baseline, deploy. The third we don't own at all, the migration and CLI layer is Ryan Cramer's AgentTools module, which applies our migrations and gives full command-line access to the PW API (--at-migrations-apply, --at-eval). They share three habits: every write is dry-run by default and needs an explicit --apply; all configuration lives in files, tracked policy kept separate from git-ignored secrets and connection details, never in the admin; and everything is referred to by name, never by database id. That last habit matters more than it looks: ids can differ between environments, names don't, so a change described by name means the same thing everywhere it's applied.

Everything hangs off one idea: state has a direction, and each kind of state is only allowed to move one way. We wrote it as a fixed mapping and treat it as law:

  • Content (pages, field values), copied by database clone, prod → local only
  • Schema (templates, fields, roles, permissions), carried by a migration, local → prod only
  • Module install state + config, carried by a migration, local → prod only
  • Per-environment values + secrets, a git-ignored file, never transferred either way

Once content only flows prod→local and structure only flows local→prod, "push the dev database to prod" stops being a mistake you have to remember not to make, it's simply a direction the system doesn't have. Three pieces implement it:

1.

A clone-proof environment flag, file state, not DB state. Your incident is one half of the danger; the mirror image bit us too: a prod→local database copy can make local believe it's production, because "which environment am I?" is usually answered from the database. So we keep that flag in a git-ignored file, never in the DB, a database clone physically can't flip it. The same file holds the DB credentials and salts, and on local it reroutes all outgoing mail to a local catcher (MailHog), so a stray "email every user" from a script can't reach a real inbox.

2.

Migrations as the only structural channel, and a git payload before they're a DB payload. We landed on nearly your contract independently: small, idempotent, versioned PHP migrations on the PW API, everything referenced by name never by database id, preview-then-apply. Two lessons I'd add to your list:

Because we deploy over git (below), a migration is committed and pushed before it runs. $modules->install() on a module whose files arrived in that same push silently does nothing until you call $modules->refresh() first, and then saveConfig() dies with "Unable to find ID for Module" after the migration already printed success. So: refresh, install, then verify the install actually took instead of trusting the return value.
This maps straight onto your "installed modules" guard: module install state lives in the modules table, it is database state. So every prod→local pull overwrites local's install states with production's, and you can't maintain different module sets per environment without fighting the core. Dev-only tools can't ride a migration either: the applied-migrations registry is itself in the database, so a migration marked "applied" on prod travels back down on the next pull and is then never re-applied locally. We re-activate dev-scope modules with a separate idempotent "baseline" step after every pull, and it never uninstalls anything.

3.

docroot-as-repo deploy, with the two foot-guns bolted shut. Deploy is a git push into a non-bare repo whose working tree is the live web root (receive.denyCurrentBranch = updateInstead). Simple, and it has exactly two sharp edges, both turned into hard stops rather than reminders, because in the AI era the rule has to live in config and permissions, not in someone's head:

A reflexive git push is a live deploy. So git config push.default nothing, a bare git push now exits 128. Deploy only happens through the explicit tool call. The error is the feature; we never "fix" it by removing the setting.
A write from the production admin wedges every future deploy. Installing/upgrading a module or language pack from the live admin dirties the remote working tree, and updateInstead then refuses all pushes until it's clean, with an error pointing nowhere near the cause. So nothing is ever installed or upgraded on production, you do it on local, commit, deploy. A read-only preflight reports the remote's checked-out branch and working-tree dirtiness in about half a second, writing nothing.

Why all of this makes operations deterministic. Put the three pieces together and the live site's state becomes a pure function of the committed history, the same commits plus the same migrations always produce the same site, regardless of who runs them or in what order. That property comes from four things stacking up:

One source of truth per kind of state, one direction of travel. You never have to ask "is this fact in the database or in a file, and which environment's copy wins?", the mapping decides it in advance.

  • Every change is a named, idempotent, versioned artifact, not a click. A migration that names its targets yields the same schema no matter the local database ids or the order things ran, and re-running it is a no-op. "Apply the migrations in commit X" has exactly one possible outcome.
  • Preview-first. Because every write shows its effect before it commits, what you approve is what runs, there's no gap between intent and result.
  • The guards close every side channel. This is what turns "predictable tools" into a "predictable system". The reflexive push fails, the production admin is off-limits for installs, a database clone can't flip the environment flag, so no unreviewed path is left open, and the live site's state is a function of the reviewed history and nothing else.

In practice that's the difference between "deploy" being a delicate procedure you perform carefully and "deploy" being an operation with one knowable result, which is exactly what you want the moment an agent, rather than you, is the one performing it.

On the questions the thread left open:

  • Rollback, this is the piece I'd most push you to automate; it's the cheapest insurance you'll ever buy. Every write is dry-run by default and needs an explicit --apply; we snapshot before a deploy, and there's a one-command restore plus snapshot/* git tags to roll back to. When "undo" is a single command, you operate far more fearlessly.
  • Staging / multi-developer, full honesty: we're single-developer-plus-agent on a handful of sites, close to Peter's context, so I won't pretend this is team-tested. The directional-state idea should scale; the "one flag file per machine" part clearly needs more thought once several developers share it.

The meta-point, and I think the real lesson of your post: none of this is a module you install. It's guardrails placed where the agent, or the tired human, actually hits them: a git setting that makes the dangerous push fail outright, a flag a database copy can't corrupt, verbs that do nothing until you add --apply, and a permission layer that makes every git command stop and ask first.

The distinction I'd underline: your AGENTS.md is prose, it tells the agent what not to do, then trusts it to honour that on every single run. A mechanism asks for no cooperation, it makes the wrong move fail on its own, whether or not anyone remembered the rule. "Don't push the dev database to prod" is prose; a system where that direction simply doesn't exist is a mechanism.

We also started with prose and learned it isn't enough on its own. An agent acts on a confident-but-wrong assumption fast, faster than you can read the command and think "wait, no", so with only a written rule, the push has already landed by the time the rule would have caught it. So we keep the prose to explain the why, but put a hard mechanical stop behind every rule that can reach the live site.
 

  • Like 1

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...