SiteSync

Branch-based deployment of /site/ via GitHub. Sister module to GitSync.

Branch-based deployment for ProcessWire installations via GitHub. Standalone companion to GitSync.

SiteSync deploys the whole /site/ of a ProcessWire installation from a single GitHub repository – the one currently configured. The admin lists every branch in that repository, a single click switches /site/ atomically to the selected branch, a snapshot of the pre-deploy state is taken before any change, and rollback or restore is one click away. Pushes can be auto-deployed via webhook, with safety guards against silently overwriting local edits.

Purpose


SiteSync turns a single ProcessWire installation into a git-deployed site: every change to /site/ – template, stylesheet, schema migration, content seed – lives on a branch in GitHub, and a Switch / Sync now / webhook delivery brings that branch into production atomically. The DB stays where it is; only the filesystem is mirrored, and only the paths SiteSync was asked to manage.

Single-target deploy is the point: only one repository is active at a time, so the active commit's tree IS the deployed state. Rollback is "switch to a previous SHA", staging is "branch + dedicated SiteSync install", and a code review on GitHub is also a deployment review. Pointing the same installation at a different repository is just a config change – nothing locks the site to a permanent pairing.

What you can build with it

Combined with RockMigrations (schema-as-config under site/RockMigrations/) and the editorial-seed convention under site/content/ – both scaffolded on install – the same git-deploy flow covers far more than file syncing:

  • Schema deploys. Fields, templates, roles, and permissions are config files in the repo. A branch that adds blog_index + blog_article templates plus their fields, when switched, applies all of that to the DB on the next module refresh. The diff IS the migration; no imperative scripts.
  • First-run bootstrap. A fresh PW install with an empty DB, plus the right branch + RockMigrations + site/content/ seeds, produces a complete site after one Switch.
  • Editorial patches that ride with code. When a content fix really needs to travel in the repo (renaming a section, correcting a seeded copy-block), a cache-gated patch in site/content/99-patch-…-vN.php runs once and skips on every subsequent deploy.
  • AI-assisted authoring, end to end. Direct a coding agent (Claude Code, Cursor, …) from wherever you happen to be: ask it to add a template, design a stylesheet, write a migration, draft article content. The agent pushes its proposal to a feature branch; the branch surfaces in the SiteSync admin as updates available; reviewing the diff on GitHub and tapping Switch finishes the loop. In practice this has produced a complete blog – schema, templates, styling, functionality, and seed articles – built end-to-end on a test installation from the iOS Claude app alone, without a laptop in the loop.
  • Safe hotfixes. A direct edit on the server (an emergency typo fix, a one-line debug log) is tracked: the next webhook push to the same path is held back with a clear admin banner until the operator pushes the local change. No silent overwrites.
  • Non-developer operators. Branch switching, rollback, and snapshot restore all happen through the PW admin UI. Git knowledge is optional for the people running the site day-to-day.

Working with coding agents


SiteSync ships agent-instruction templates that turn an AI coding agent (Claude Code, Cursor, OpenAI Codex, …) into a viable site collaborator: the agent reads the conventions, edits files on a feature branch, you deploy via the SiteSync admin when the diff looks right.

What gets installed

On module install SiteSync copies two templates from agent-templates/ to the ProcessWire installation root, skipping any file that already exists there:

  • AGENTS.md – universal agent instructions: branch discipline (never push to the active branch), RockMigrations schema-as-config under site/RockMigrations/, editorial seeds and patches under site/content/, and ProcessWire idioms (namespace, sanitizer, image sizing, hooks, Pageimage vs. Pageimages).
  • CLAUDE.md – Claude Code-specific addendum that defers to AGENTS.md and lists SiteSync's state surfaces (admin Setup page, sitesync.txt log channel, managed-files.json).

Commit both with the rest of the site repository. From then on any agent opening the repo finds them automatically.

What you can hand to an agent

Anything that maps to files in /site/:

  • Schema work"Add a blog_article template with hero image, body images, subtitle, and tags." The agent writes config files under site/RockMigrations/fields/ and site/RockMigrations/templates/; the next Switch applies them on the refresh hook.
  • Templates and styling"Build a single-column article view with a full-width header image." The agent writes the PHP and CSS files.
  • Content seeding"Create three seed articles about X, Y, Z under site/content/." Idempotent guards in the seeds mean a re-deploy never duplicates them.
  • Editorial patches"Rename the 'Blog' page to 'Articles'." The agent writes a cache-gated 99-patch-…-vN.php that runs once and then skips.
  • Bug fixes and refactors – same loop, just smaller diffs.

The loop

  1. Brief the agent in plain language. The agent reads AGENTS.md / CLAUDE.md from the repo root.
  2. Agent works on a feature branch (claude/<task>, feature/<name>, or a branch you name explicitly – never the active deploy branch). First push opens a draft PR against main, or whichever PR target you've set.
  3. The branch surfaces in the SiteSync admin in the Branches table.
  4. You review the diff on GitHub. Phone or laptop – same PR review you'd do for any change.
  5. Click Switch in the admin. SiteSync deploys atomically, RockMigrations applies schema changes on the module refresh, content seeds run if they're new and pass their idempotency guards. The Recent deploys row then shows the commit title alongside the short SHA.
  6. Webhook auto-deploys subsequent pushes to that branch once it's the active one and auto_deploy_on_push is enabled.

Updating the conventions

agent-templates/ is versioned with the module. The install-time copy is one-shot (skips existing files at the PW root), so updates aren't auto-merged into your committed AGENTS.md / CLAUDE.md. To pull in changes, diff your committed versions against the current agent-templates/ in this repository and cherry-pick what's relevant.

Staging environments


For a workflow where a deploy needs review before it reaches live visitors, run two ProcessWire installations against the same GitHub repository: one is the live site, the other is the staging site. SiteSync supports this without any module-side change – each install has its own repo_url, active_branch, webhook secret, and database.

This is the only deployment shape that gives a true preview, because /site/ holds a single tree at any given time and RockMigrations schema changes commit to the install's DB the moment the deploy refreshes modules. Real isolation of "what's deployed" requires real isolation of both filesystem and DB – which means a second PW install.

What you set up

  • Two PW installations with separate databases on separate URLs (e.g. example.com and staging.example.com). Either two virtual hosts on one server or two separate hosts.
  • One GitHub repository with at least two branches that matter to deploys:
    • A protected live branch (e.g. live-stable or main) with GitHub branch protection enabled.
    • A staging branch where feature work lands first (e.g. staging).
  • SiteSync installed on both installs, pointed at the same repo_url. Each install has its own webhook_secret.
  • active_branch differs per install:
    • Live install: active_branch = live-stable.
    • Staging install: active_branch = staging.
  • Two webhooks in GitHub, one per install's /sitesync-webhook/ endpoint. Each install only acts on pushes that match its own active branch, so a push to staging triggers only the staging install, a push to live-stable triggers only the live install.

The flow

  1. Feature branch off staging. Push feature/new-blog, open a PR against staging.
  2. Merge into staging. The staging install's webhook fires, SiteSync auto-deploys, RockMigrations applies any schema changes, content seeds run – all against the staging DB.
  3. Browse staging.example.com to see the rendered result. Anything broken stays on staging; live is untouched.
  4. Promote to live by opening a second PR from staging into live-stable, reviewing the cumulative diff of everything that landed on staging since the last promotion, and merging. The live install's webhook fires; live now mirrors what staging has been showing.
  5. Rollback independently on either side via SiteSync's switch history – staging and live each keep their own.

Caveats

  • Media diverges. site/assets/ is excluded from sync by design (uploads, sessions, cache, logs). Editorial work that adds images to pages on live needs the images on staging too if staging templates reference them by path. Easiest workaround: keep editorial work on live and let staging be the structural / code-only preview.
  • Content seeds run on both. A new site/content/00-seed-foo.php runs on staging when it lands there and on live when it lands there. The idempotency guards handle that correctly; both installs end up with the same seeded page – usually the intent.
  • DB drift. Staging and live DBs diverge once editors start touching either. Periodically resetting staging from a live DB dump keeps the preview meaningful for content-rich sites; for schema-and-code-only sites this is a non-issue.
  • Branch protection matters more. An accidental direct push to live-stable bypasses your staging review entirely. GitHub branch protection on the live branch is not optional in this setup.

Features


  • Atomic branch switching with file-level diff - only changed blobs travel.
  • Switch preview showing exactly which files would be updated, deleted, or preserved, plus any local modifications at risk.
  • Tracked sync, not strict mirror - only files SiteSync has actually written are eligible for deletion. Hand-installed modules, hand-written templates, and third-party additions stay untouched.
  • Pre-deploy snapshots as tar.gz of templates/, modules/, classes/, with configurable retention, one-click restore, and a per-switch override checkbox in the preview to skip or force the snapshot independently of the module-level default.
  • Switch history with rollback to any prior state via exact-commit SHA pinning.
  • Webhook endpoint for auto-deploy on push, with conflict detection that refuses to overwrite local edits silently.
  • Hardcoded protection list (PW-bootstrap files, secret-bearing files, SiteSync itself) - never touched, regardless of user config.
  • Symlink-safe - symlinks in /site/ and git symlink entries in the repository are skipped at both ends. SiteSync never replaces a symlink with a regular file.
  • Destructive-deploy guard - refuses a deploy that would wipe more than half of /site/ or delete files without adding any.
  • Pre-flight permission check - aborts before mutation with the full list of unwritable target directories, their mode/owner/group, and the PHP user.
  • Optional GitSync token reuse - when the GitSync sister module is installed, SiteSync can reuse its GitHub token instead of duplicating it.

Requirements


  • ProcessWire 3.0+
  • PHP 8.0+ with curl and phar extensions
  • GitHub Personal Access Token with Contents: read scope for the configured repository (optional for public repos, required for private repos and for higher API rate limits)
  • /site/ writable by the PHP process

Installation


  1. Copy the SiteSync/ directory into /site/modules/.
  2. In the ProcessWire admin: Modules → Refresh → Install SiteSync.
  3. The webhook HMAC secret is generated automatically on install.

The module exposes its main page at Setup → SiteSync.

Configuration


  1. Open Setup → SiteSync and click Settings in the header action bar.
  2. Fill in:
    • GitHub Personal Access Token - create a fine-grained token at GitHub → Settings → Developer settings → Personal access tokens. Grant Contents: read on the deploy repository.
    • Repository URL - e.g. https://github.com/owner/repo.
  3. Save.

The full configuration form covers GitHub connection, webhook, branch protection, path filters, and hooks. Sensible defaults apply on install.

Reusing a token from GitSync

If GitSync is also installed on the site, the GitHub Connection fieldset shows a Use GitSync token checkbox. Enable it to skip maintaining the same Personal Access Token in two modules. SiteSync's own token (when set) always takes precedence; the fallback only fires when the SiteSync token field is empty.

Usage


Switching to a branch

  1. Setup → SiteSync lists every branch in the repository with its latest commit (SHA + first line of the commit message), date, status, and a Switch action.
  2. Click Switch on the target branch. The preview page shows the diff summary in a definition list (target commit, repository subpath, files to update, files to delete, files preserved, local changes at risk) plus a collapsible change list with three sections (Updated / Deleted / Preserved). A Create snapshot before switch checkbox sits next to the change-list toggle, pre-filled from the module's snapshot setting – uncheck to skip the tar.gz for this switch only, check to force one against a disabled default. When tracked files have local modifications, an inline warning panel lists the affected paths above the Confirm button.
  3. Click Confirm switch to apply the change. SiteSync takes the snapshot (if enabled for this switch), writes the new files atomically via tmp + rename (preserving each existing file's permissions), deletes ones no longer in the branch (only those it manages), refreshes the module cache, and redirects back to the overview.

Syncing the active branch

When new commits land on the currently active branch, the status badge changes to updates available and the row action becomes Sync now. The flow is identical to a switch: preview, confirm, deploy.

Rolling back

The Recent deploys table at the bottom of the page shows the switch history (the most recent 10 entries; up to 50 are persisted). Every row except the live one has a Rollback action that returns /site/ to the state recorded by that entry, via a deploy pinned to the recorded commit SHA.

Restoring from a snapshot

When the live state still matches the very first deploy (the (initial) row in the history) and its pre-deploy snapshot is still on disk, that row gets a Restore snapshot action. It extracts the tar.gz back into /site/ and rebuilds the managed-files set from the restored contents, so the next preview won't flag every restored path as a local conflict.

Once you've switched to another branch or commit, the (initial) row is no longer the current state, and the UI no longer offers a one-click restore. In that case use the manual shell recovery described under Recovery.

The bottom of the admin page also shows the remaining GitHub API rate limit and when it resets, so you can tell a token problem from a transient quota problem at a glance.

Webhook setup


The Webhook Credentials modal in the header action bar shows the endpoint URL and the auto-generated HMAC secret. In your GitHub repository:

  1. Settings → Webhooks → Add webhook.
  2. Payload URL: as shown in the modal (https://yoursite/sitesync-webhook/).
  3. Content type: application/json.
  4. Secret: as shown in the modal.
  5. Events: Just the push event.
  6. Activate.

When auto_deploy_on_push is enabled (default), any push to the currently active branch triggers a deploy. Pushes to other branches are logged and ignored. Per-branch rate limit is one deploy every 30 seconds to absorb push storms.

Conflict hold-back

If a webhook delivery would overwrite a tracked file that has been edited directly on the server since the last deploy, the deploy is refused:

  • HTTP 409 is returned to GitHub. The failed delivery is visible in the GitHub webhook overview.
  • A red warning banner appears on the SiteSync admin page listing the affected files.
  • The banner clears automatically after the next successful deploy - typically after the user pushes the local change and GitHub re-delivers, or after a manual Sync now resolves the situation.

This prevents a hotfix applied directly on the server from being silently wiped by a later push.

Path filters


Two glob-pattern lists in the module config determine which files are in scope:

  • Allowlist - a file is eligible if it matches at least one pattern.
  • Denylist - a file matching here is always excluded, regardless of the allowlist.

Default allowlist:

templates/**
modules/**
classes/**
content/**
RockMigrations/**
ready.php
init.php
finished.php
*.php

content/** covers the editorial-content seed convention; RockMigrations/** covers the schema migration files. Both directories are scaffolded by SiteSync on install and stay empty until the operator (or a coding agent) populates them. Operators upgrading from an older version need to add these two lines manually in Settings → Path Filters — saved configurations are not overwritten by an updated default.

Default denylist:

assets/**
config.php
config-*.php
install.php
.htaccess
sessions/**
logs/**
cache/**
backups/**
templates/admin.php

Always protected (hardcoded, cannot be disabled):

  • PW bootstrap: templates/admin.php, config.php, config-dev.php, .htaccess, install.php
  • Secrets: .env, .htpasswd, plus any file whose basename starts with .env. (covers .env.local, .env.production, etc.)
  • SiteSync itself: anything under modules/SiteSync/

Symlinks in /site/ and entries with the git symlink mode (120000) in the repository tree are skipped on both sides. SiteSync neither hashes the link target nor schedules the link itself for deletion, and refuses to overwrite a local symlink with a regular file from the branch.

Tracked vs. mirror sync


SiteSync is not a strict mirror. It persists a per-installation tracking set at site/assets/cache/SiteSync/managed-files.json that records every path SiteSync has actually written, plus the blob SHA from the most recent deploy. Deletion rules:

  • A local file is only scheduled for deletion if it is in the managed set AND no longer in the branch.
  • Files SiteSync has never written - manually installed PW modules, files created by third-party modules, hand-written templates that never went through a deploy - stay unmanaged indefinitely and are never deleted.

The Files preserved column in the switch preview makes this explicit: every file listed there is safe through the deploy.

Local-modification detection

For every tracked file scheduled for update, SiteSync compares the local blob SHA, the SHA it wrote last time, and the SHA in the branch. If local ≠ last-written, somebody edited the file directly on the server. The preview flags this as a Local change at risk and the inline warning panel lists the affected paths so the user can decide whether to push the change first.

Pre/Post-deploy hooks


In Settings → Hooks & Snapshots:

  • Pre-deploy hook - PHP file (path relative to /site/) executed before any file mutation. A failing pre-hook aborts the deploy before any file is touched. Use for DB backups, activating maintenance mode, etc.
  • Post-deploy hook - executed after the deploy. A failing post-hook is logged but does not fail the deploy itself (all file mutations have already succeeded). Typical use: trigger RockMigrations, clear external caches (Cloudflare, Redis), restart queue workers.

The hook is included from inside a Wire-context closure. ProcessWire's bare API variables ($modules, $pages, etc.) are not in scope; use the functions API (wire('modules'), pages(), …) or \ProcessWire\wire(…).

Between the file mutations and the post-deploy hook SiteSync calls $modules->refresh() so PW picks up any new module class files. Exceptions raised by third-party hooks attached to Modules::refresh (notably RockMigrations' triggerMigrations when a watched migration file was just deleted by the switch) are logged but do not fail the deploy – the file state on disk is already final at that point.

Combining with RockMigrations

For DB schema changes (fields, templates, roles), the typical pattern is RockMigrations with its config-migration files under site/RockMigrations/{fields,templates,...}/. Push the files, SiteSync syncs them, RockMigrations applies them on the next module refresh - no custom post-deploy hook needed.

Notes:

  • The site/RockMigrations/ and site/content/ directories are scaffolded automatically by SiteSync on install, and the default allowlist covers both (RockMigrations/**, content/**). On installations upgraded from an older SiteSync version, the saved path-allowlist may still need the two patterns added manually in Settings → Path Filters.
  • See the RockMigrations repository for the schema-file format. Imperative migrations in site/migrate.php are also supported; SiteSync scaffolds that file too with a thin dispatcher that requires every PHP file under site/content/.
  • For sites where AI coding agents (Claude Code, Cursor, etc.) push to feature branches, the agent-templates/ directory in this repository ships AGENTS.md and CLAUDE.md templates documenting the branch-discipline, RockMigrations schema convention, and content-seed pattern. On install, SiteSync copies both to the ProcessWire installation root (skipping any file that already exists there), so coding agents picking up the site repository find them automatically. Commit the resulting files with the rest of the site.

Custom post-deploy hooks remain available for cases that don't fit this pattern (clearing external caches, pinging deployment monitors, etc.).

Server permissions


The most common deploy failure is a directory in /site/ that the PHP process cannot write to. Before the first byte is written, SiteSync walks every target directory and refuses the deploy with a combined error listing each unwritable path, its mode and owner/group, and the PHP user the deploy ran as - so the operator can fix the cause in one round-trip.

Typical fix on the server:

find site -type d -exec chmod 0770 {} +
find site -type f -exec chmod 0660 {} +

To make these defaults stick when ProcessWire creates new files itself, add to site/config.php:

$config->chmodDir  = '0770';
$config->chmodFile = '0660';

SiteSync preserves the existing permissions of any file it overwrites (and falls back to $config->chmodFile for brand-new files). It never changes ownership - correcting owner mismatches (often the result of FTP uploads as a different user) requires shell access on the server or a support ticket with the hosting provider.


The module assumes a workflow where the actively deployed branch (typically live-stable or main) is treated as protected, and changes flow in via pull requests from feature branches (e.g. claude/<task>, feature/<name>). Branch protection rules on the GitHub side are a strong prerequisite - SiteSync trusts them. For setups that want a previewable deploy step between feature branch and live, see Staging environments.

Recovery


If a switch leaves /site/ in a broken state:

  1. Rollback to a previous entry in the switch history. The per-row action restores the exact commit recorded for that entry.

  2. Restore snapshot of the pre-deploy state. The UI exposes this as a one-click action only while the live state still matches the very first deploy (see Restoring from a snapshot). In all other cases, restore from the snapshot directory on the shell:

    cd site/assets/cache/SiteSync/snapshots/
    tar -xzf <newest>.tar.gz -C ../../../../
  3. If the broken state was caused by SiteSync itself being overwritten (and tracked-sync protection wasn't yet in place at that time), restore site/modules/SiteSync/ from the snapshot first so the admin works again.

Known limitations


  • DB content (field definitions, template definitions, page tree) lives in the database, not in the filesystem. SiteSync only syncs files. Combine with RockMigrations (or similar) and sync the migration files with the rest of /site/ - see Combining with RockMigrations.
  • site/assets/ (uploads, cache, sessions, logs) is excluded by default and not synced.
  • site/config.php is excluded so live DB credentials stay untouched.
  • No git binary is used. SiteSync talks to the GitHub REST API only - no SSH keys, no working copy on the server.
  • GitHub Tree API caps recursive listings at 100 k files. Tree-truncated responses abort the deploy instead of producing a partial sync.
  • Line-ending drift between server and repository (LF vs CRLF) changes the blob SHA and shows up as a false-positive local modification in the switch preview, blocking webhook auto-deploy. The shipped .gitattributes normalises text files to LF on commit; keep editors configured to LF for files under /site/ to avoid this. Single-file overrides via .gitattributes are the right fix when a binary or generated file must stay verbatim.

License


MIT - see LICENSE.

More modules by Mikel

  • ProcessDataTables

    Displays customizable backend tables for any ProcessWire template with flexible column selection, per-field output templates, and global formatting options.
  • StripePaymentLinks

    Stripe payment-link redirects, user/purchases, magic link, mails, modals.
  • User Data Table

    Displays a configurable table of user fields in the admin interface.
  • Textformatter Smart Quotes

    Replaces straight quotes "..." with typographic quotes („...“, “...”, or «...»), in visible text only.
  • Data Migrator

    Migrate external data (SQL, CSV, JSON, XML) into ProcessWire
  • StripePaymentLinks Mailchimp Sync

    Sync purchases from StripePaymentLinks to Mailchimp
  • Stripe Payment Links Admin

    View customer, purchases & products with configurable metadata columns & filters. Export reports to CSV.
  • GitSync

    Synchronize ProcessWire modules with GitHub repository branches
  • StripePaymentLinks Customer Portal

    Adds a ready-to-use /account/ page with login flow, product grid, purchase history table, and direct access to the Stripe Customer Billing Portal

All modules by Mikel

Install and use modules at your own risk. Always have a site and database backup before installing new modules.