# WPMgr **Open-source, self-hostable WordPress fleet management.** WPMgr lets you enroll, monitor, update, back up, and secure a fleet of WordPress sites from one dashboard, running on infrastructure you control when you self-host it. The control plane is a Go binary with a React dashboard; a lightweight PHP plugin on each managed site handles the work. Everything between the agent and the control plane is Ed25519-signed. [![Latest release](https://img.shields.io/github/v/release/mosamlife/wpmgr?label=release&style=flat)](https://github.com/mosamlife/wpmgr/releases) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/mosamlife/wpmgr/ci.yml?branch=main&label=CI&style=flat)](https://github.com/mosamlife/wpmgr/actions/workflows/ci.yml) [![WordPress.org](https://img.shields.io/wordpress/plugin/v/fleet-agent-site-manager?label=wordpress.org&style=flat)](https://wordpress.org/plugins/fleet-agent-site-manager/) [![WPMgr: run your whole WordPress fleet from one dashboard you own](docs/images/landing.png)](https://wpmgr.app)

Website · Live dashboard · API reference

**v0.61.199**: open-source and production-usable for self-hosters. The agent plugin is reviewed and listed in the [WordPress.org plugin directory](https://wordpress.org/plugins/fleet-agent-site-manager/). --- ## Quick start Get the whole stack running on your own server with one command, no repo clone needed: ```bash curl -fsSL https://raw.githubusercontent.com/mosamlife/wpmgr/main/scripts/quickstart-selfhost.sh | bash ``` The script downloads every file the stack needs, generates all secrets, and prints the exact command to bring WPMgr up. You need a 64-bit Linux host with Docker 24+ (2 GB RAM is enough to start). See [system requirements](./docs/requirements.md) for sizing by fleet size, or the [full quickstart](#quickstart-self-host) below for options, prebuilt images, and the build-from-source path. --- ## Features ### Fleet connection - **Live enrollment**: Add a site by URL; paste a one-time code into the agent plugin; the dashboard flips from "Awaiting" to "Connected" automatically, no refresh needed. - **Real-time connection state machine**: Six precise states (`pending_enrollment` → `connected` → `degraded` → `disconnected` → `revoked` → `archived`) replace a vague up/down flag. Every transition is written to auditable, hash-chained history. - **60 s heartbeat with auto-recovery**: A background sweeper degrades → disconnects silent sites within minutes. A returning agent auto-recovers without operator action. - **Fleet SSE stream**: One shared Server-Sent Events stream keeps the entire sites list live (status dots, last-seen counters) without polling. Cursor-based replay catches events missed while offline; all performance and scan surfaces refresh automatically on reconnect. - **One-click autologin to wp-admin**: Single-use, short-lived, audited EdDSA tokens; no shared passwords. Deep-links straight to Plugins or Themes, or log in as a specific user. Bypasses common two-factor plugins. - **Signed dashboard-to-agent revoke**: Revoke from the dashboard; the agent verifies a signed token on its next heartbeat and self-destructs. A man-in-the-middle on the heartbeat response cannot forge the teardown. - **Signed last-will on deactivate/uninstall**: Agent disconnects itself on deactivation (3 s best-effort); the timeout sweeper is the safety net if it never arrives. - **Re-enrollment under a stable identity**: Re-connecting a site keeps the same `site_id`, preserving all backup history, scan runs, and lifecycle generations. - **Archive / restore soft-delete**: Retire sites from the active view without losing their history; restore them later. - **Per-site sharing**: Share exactly one site with a collaborator, enforced by both Gin middleware and Postgres RESTRICTIVE RLS, without exposing the rest of the fleet. - **Agent self-update channel**: Self-hosted control planes push signed agent releases to enrolled sites. The [WordPress.org build](https://wordpress.org/plugins/fleet-agent-site-manager/) ships without the self-updater and updates from the site's own Plugins screen instead. ### Backups & restore - **Pure-PHP streaming DB dump**: Server-side cursor (`MYSQLI_USE_RESULT`), `REPEATABLE READ` snapshot, ~1 MiB batched INSERTs. No mysqldump binary, no shell access, so it works on locked-down managed hosts. Memory use is independent of database size. - **Pure-PHP streaming file archiver**: ZipArchive streaming (never loads file bodies into memory), rotated at 200 MiB / 55 k entries per part. Splits wp-content into per-component sequences (plugins, themes, uploads, other, WP core) for targeted restore. - **Incremental archive-delta backups**: Each increment diffs the live file tree against the parent snapshot's `files.list` by size + mtime and packs only changed or new files into standard part archives, with deletions recorded as tombstone manifest sidecars. The database is dumped in full every run. - **Selective-component backups + exclusion patterns**: Per-site choice of which components to archive (plugins / themes / uploads / wp-content / database / WP core) plus exclude-path, exclude-extension, and max-file-size filters pushed to the agent on every run. Backup content settings are decoupled from the schedule so manual and scheduled runs share one definition. - **Content-addressed chunking with dedup**: Each artifact is chunked at ~4 MiB, content-hashed with BLAKE2b-256, and deduplicated across snapshots. Only changed chunks re-upload on the next full backup. - **Three backup destinations**: Control-plane-managed bucket (the default, so unless a site is pointed elsewhere the control plane holds its chunks), customer-owned S3-compatible bucket (agent never holds the credentials), or a local folder on the WordPress host. Chunks are stored as uploaded; uploads to a managed or S3-compatible bucket go over HTTPS, and a local destination writes straight to disk on the site's own server with no network transfer. Nothing is encrypted client-side in shipped builds, so whichever destination you pick, that destination's own access controls are what protect the data, along with whatever encryption at rest it provides: a bucket can be configured for it, a local folder gets only whatever the host's disk does. See [docs/features/backups.md](./docs/features/backups.md#backup-storage-and-encryption). - **SQL inspection at backup time**: A streaming constant-memory scanner produces `sql-inspection.json` (charset, table prefix, per-table row/byte estimates, WordPress detection, `siteurl`/`home`/`db_version`) stored with every snapshot. - **Environment fingerprint**: `environment.json` captures PHP/MySQL/WordPress versions, plugin/theme slugs, table list, and size at snapshot time. - **Resumable watchdog-driven state machine**: Phases are checkpointed to a task row; a watchdog re-enters stalled backups up to 6 times. Long backups survive PHP time limits and FPM worker recycling without redoing finished work. - **Scheduled backups**: Hourly, every-N-hours, daily, weekly, or monthly cadences, run in the site's own WordPress timezone, jittered per site so the fleet doesn't fire simultaneously. - **Snapshot lock**: Lock a snapshot to exempt it from retention GC regardless of age or keep-last rules; unlock explicitly when no longer needed. - **Mark-and-sweep retention GC**: Tenant-global reachability walk deletes a chunk only when it is unreachable from every retained snapshot across all sites and predates a fail-closed grace floor. Refcount is observability-only and never consulted for delete decisions. - **Backup-completion email**: Per-site notification config (always / on-failure / never + recipient list) sends a branded email on backup completion or failure for both manual and scheduled runs. - **Live SSE progress**: Phase-by-phase progress including chunk and byte counters streams to the dashboard in real time. - **Point-in-time chain restore with version picker**: Restoring a chain member overlays each generation's parts in order (newest-wins extraction) then applies tombstone deletes, so any snapshot in the chain restores correctly. The restore dialog renders a version picker across all completed chain generations. - **Component-scoped restore**: Restore the whole site, just the database, just files, or fine-grained components (plugins, themes, uploads, wp-content). Compose with per-path or per-table lists. - **Atomic file restore**: Extract to a hidden staging directory, move live tree aside to a `.wpmgr-old-files-/` rollback dir, rename staging into place. A crash never leaves a half-merged site. - **Online DB restore**: Import into `tmp_`-prefixed tables while WordPress stays live; swap each table atomically via `DROP + RENAME` at the end. A failed import leaves the live database untouched. - **Resumable restore**: Same watchdog pattern as backup: persisted phase state, chunk-level download resume, mid-table URL-rewrite resume. - **Two-leg disk-free precheck**: Estimates required disk before touching anything; refuses with a GB-denominated message if there is not enough space. - **Self-preservation guards**: Never clobbers the running agent plugin, keystore, `wp-config.php`, `.htaccess`, or cache drop-ins; copies them forward from the live tree into staging before the swap. - **Path-traversal & integrity hardening**: Every downloaded chunk is verified against its content hash; every zip entry is traversal-checked with a canonical-path containment check against the staging root. - **Maintenance-mode windowing**: Drops WordPress's `.maintenance` file around the destructive swap; removes it and flushes object cache, OPcache, and rewrite rules on completion. ### Updates - **Per-site available-updates inventory**: WordPress core, plugin, and theme update lists with current → new versions, active-first sorted. - **On-demand inventory refresh**: Force re-poll WP update transients via a signed CP→agent command; returns 409 with a clear message when a site is unreachable. - **Bulk fleet-wide update runs**: Target an explicit set of site IDs or a tag; expands to per-(site, item) tasks. - **Dry-run preview**: The bulk wizard defaults to `dry_run=true` so the first submit is a safe preview with exact version deltas. - **Pre-update snapshot + automatic health-check rollback**: The agent snapshots the component before updating; the CP health-probes the site after; a bad update is reverted automatically. - **WP-CLI-first with PHP upgrader fallback**: Works with or without WP-CLI; preserves active-plugin state on the PHP fallback path. - **Live SSE progress per run**: Task status transitions stream in real time; a snapshot-on-connect plus a 2 s poll safety net prevent stale "Queued" states. - **Post-update inventory auto-refresh**: Debounced per-site (30 s window) after every terminal task so the available-updates list self-heals. - **Argument-injection hardening**: Slugs and versions are validated on both the CP (`validateItems`) and agent (`sanitizeSlug`, `isValidVersion`) against a safe charset; shell metacharacters, `..` traversal, and flag separators are rejected. - **Per-tenant concurrency isolation**: Sharded River queues with a per-tenant running-task limit; one large fleet update cannot starve other tenants. - **Update run history**: Auditable trail of who updated what, when, outcome per site/item, with from/to version and timings. ### Monitoring & health - **Sites grid view with screenshots**: A list/grid toggle on the Sites dashboard. Each grid card is led by a real screenshot captured server-side (headless Chromium in the media-encoder, SSRF-guarded) and refreshed on connect, weekly, and on demand. Cards show capability status (page cache, object cache, HTTPS, backups, multisite), version/host/client/tag metadata, uptime, latency, SSL expiry, and backup health. The Status and Tags filters compose with search and persist in the URL. See [docs/features/sites.md](./docs/features/sites.md). - **Active uptime monitoring**: SSRF-hardened probes classify up/down with a full timing breakdown: DNS, connect, TLS handshake, TTFB, total. Also detects a WordPress fatal-error or database-connection page served with HTTP 200 and reclassifies it as down. See [docs/features/monitoring.md](./docs/features/monitoring.md). - **Uptime % + latency charts**: Windowed reports over 7 d / 30 d / 90 d; downsampled server-side. Tenant-wide status summary across the whole fleet. - **TLS certificate tracking**: Expiry, issuer, and subject captured on every HTTPS probe; flags "renew soon" at < 14 days. - **Pluggable time-series store**: Postgres (default, one row per probe) or ClickHouse (MergeTree, 90-day TTL) selectable at boot. - **Downtime/recovery alerts**: Transition-only (opens incident on N consecutive failures, closes on recovery). One downtime alert and one recovery alert per outage, never a flood. - **Alert channels**: Email (SMTP) and HMAC-SHA256-signed webhook. Both uptime and high-severity security events share one channel config. - **Full WordPress Site Health collection**: Ships the complete `WP_Debug_Data::debug_data()` dump (all native sections plus third-party plugin contributions from Yoast, WooCommerce, ACF, etc.) centrally. - **14-category extended diagnostics with fault isolation**: Identity, PHP, MySQL, filesystem, HTTP loopback, cron, themes, plugins, users, security constants, HTTPS, mail, performance, hosting, each in an isolated wrapper so one failing probe doesn't blank the screen. - **Leapfrog diagnostic signals**: Max-overdue WP-Cron age, OPcache hit-rate/memory, `site_as_of_hash` fingerprint (changes when any managed component moves), agent-side managed-host detection (Pressable, GridPane, WP Engine, Atomic/WPCOM, Kinsta, Flywheel, RunCloud, Cloudways), and control-plane offline egress-IP inference of the underlying cloud/IaaS provider (DigitalOcean, Hetzner, AWS, Google Cloud, Azure, Vultr, OVH) via embedded DB-IP ASN lookup (no API key; no IP leaves the operator). IP data by DB-IP.com. - **JIT directory sizes**: Fresh or cached (< 6 h), computed via `du` or PHP fallback, annotated with method + computed-at timestamp. Never blank like the native Site Health screen. - **Privacy redaction**: Agent-side recursive walker redacts `admin_email`, `*_password`, `*_secret`, `*_token`, API keys, and auth salts before the diagnostics blob leaves the site. - **On-demand diagnostics refresh**: One click re-collects all categories and lands the data before the response returns. - **PHP error monitoring**: Error + shutdown handlers (including a must-use plugin that arms before other plugins boot, catching bootstrap-time fatals). Deduped by `md5(code:file:line:message)` with occurrence counters and gzip-compressed backtraces. Near-immediate ship on fatal. - **Per-site error config**: Level bitmask + fingerprint silence list pushed to the agent. Silencing a fingerprint deletes its existing rows immediately. Fatals always captured regardless of mask. - **Hash-chained activity log**: ~30 WordPress events (posts, comments, users, auth + failed logins, plugins, themes, core updates, terms, security-relevant options, WooCommerce order/product events) written as a SHA-256 chain. Control plane re-verifies byte-for-byte on ingest and on demand. - **Filtered, paginated activity feed**: Newest-first cursor pagination with filters by event type, object type, actor, severity, and time range. Per-row `chain_valid` flag; a `/verify` endpoint reports the first broken link if any row was altered. ### Security - **Dashboard two-factor authentication**: Protect your account with a TOTP authenticator app and/or a WebAuthn passkey or security key. A guided setup wizard walks through QR scan (or manual key entry), live-code confirmation, and saving 10 one-time recovery codes. A second-step challenge appears at login with an optional "remember this device for 30 days" (revocable per device). The Settings → Security screen manages authenticator apps, passkeys, recovery codes, and trusted devices. TOTP secret is age-encrypted at rest; recovery codes are argon2id-hashed and single-use; cloned-authenticator detection (WebAuthn sign-count); brute-force lockout; re-auth required to disable; all events in the audit log. Every session-issuing path (password login, SSO, email verify) gates through one function, so no parallel path can bypass it. Self-hosted operators must set `WPMGR_AUTH_WEBAUTHN_RPID` and `WPMGR_AUTH_WEBAUTHN_RP_ORIGINS` for passkeys to work on their own domain; the authenticator-app factor works on any origin. See [docs/features/2fa.md](./docs/features/2fa.md). - **Core file-integrity scan**: Resumable, cursor-paginated MD5 walk diffed against the official WordPress.org Checksums API for the site's exact version + locale. - **Three finding types**: `core_modified` (high), `core_missing` (medium), `core_unknown_injected` (high). Only flags within core paths; operator-mutated files (`wp-content/`, `wp-config.php`, cache drop-ins, etc.) are allow-listed to minimize false positives. - **Checksum caching**: 30-day positive TTL (releases are immutable), 6-hour negative TTL, transparent `en_US` locale fallback. Repeated fleet scans never hammer the public wp.org API. - **Finding triage**: Mark findings ignored (audited). Fetch raw file contents in-dashboard (server-gated: only stored findings, access audited) without SSH. - **Brute-force login protection**: Three escalating tiers per sliding window: captcha gate → per-IP temporary block → global site-wide block. Known-good bypass (recent success from same IP) avoids locking out legitimate admins. - **Three protection modes**: `disabled` (inert by default), `audit` (records/logs, never blocks), `protect` (enforces 403). Malformed config push falls back to safe defaults. - **Early-boot WAF IP firewall**: Must-use plugin (loads before any other plugin) checks deny CIDRs at the very start of WordPress boot; allow CIDRs and private/loopback IPs always win first. DB error fails open. - **Operator-controlled CIDR allow/deny**: IPv4 + IPv6, validated on the CP (`net.ParseCIDR`), binary `inet_pton` bitmask comparison on the agent. Spoof-resistant configurable real-IP header. - **Lockout safety rail**: Enabling protect mode with an empty allow-list auto-adds the operator's own IP as a `/32` or `/128`. - **Manual IP unblock**: Deletes the IP's failure rows, resetting its sliding-window counter while preserving success rows. - **Login-page whitelabeling**: Per-site logo URL, logo link, and message. URLs scheme-validated (http/https only); message run through a narrow `wp_kses` allowlist (no `script`/`style`/`iframe`/`on*`). Safe to inject CP-controlled content into `wp-login.php`. - **Hashed, role-scoped API keys**: `wpmgr__` format; only sha256 hash + prefix stored; secret shown once at creation; constant-time compare (`crypto/subtle`); per-key role flows through the full RBAC matrix. - **Per-site access enforcement**: Every scan, login-protection, login-brand, and unblock route requires `RequireSiteAccess`. Routes resolved by global ID (get-run, ignore-finding, fetch-file) re-resolve the object's real site and call `CanAccessSite` before reading or mutating. - **Site-user two-factor, password policy, hide-login, and hardening toggles, plus a vulnerability scanner**: per-site controls covering the WordPress site's own users (not the dashboard operator). See [docs/features/security.md](./docs/features/security.md). ### Performance - **Zero-DB page cache fast path**: An `advanced-cache.php` drop-in serves anonymous pages as pre-gzipped HTML straight from disk (`wp-content/cache/wpmgr`) before WordPress, plugins, or the theme load. A cache HIT makes zero DB or plugin calls and streams the `.html.gz` with a 304 Not-Modified short-circuit. Every cached response carries an `x-wpmgr-cache` header. - **Variant-aware cache key**: Pages cache separately per device (mobile UA), per logged-in role, per operator-included cookie, and per cache-varying query string (marketing params stripped). The drop-in's key algorithm is byte-identical to the PHP writer. - **Safe cacheability gates**: Never caches non-200, admin/login/AJAX/feed/sitemap paths, password-protected posts, or any request carrying a bypass cookie (WooCommerce/EDD cart + session, logged-in, comment-author). WooCommerce cart/checkout/account pages are excluded outright. - **WooCommerce cart-session shell caching**: Catalog pages (shop, category, home, blog) can be cached for shoppers who already have items in their cart; cart totals update live via WooCommerce's own cart-fragments mechanism. WPMgr auto-probes theme support across real storefront renders before surfacing the toggle, requiring three independent page confirmations before reporting support. - **Automatic server fast-path install**: Enabling the cache writes the `WP_CACHE` define, installs the drop-in, and adds an Apache `.htaccess` block that serves `.gz` without PHP. nginx gets a copy-paste `try_files` snippet; built-in handling for OpenLiteSpeed and WP Engine Atomic. - **Purge: all, per-URL, and auto on content change**: Manual purge of the whole cache or a single URL's variants, plus automatic invalidation on post/comment/product/template change that purges and re-warms the exact affected URL set (permalink, home, archives, every assigned + ancestor term). - **Host / edge-cache purge integrations**: Every purge fires `wpmgr_purge_*:before/:after` actions so managed-host and CDN edge caches (Varnish, Kinsta, WP Engine, etc.) clear in lockstep. - **Full-site preload warmer**: Warms every cacheable URL (home, all public post types including WooCommerce products, every non-empty term archive, author archives) across desktop and mobile UAs, via a custom self-dispatching MySQL queue with atomic claim/lock, retry/backoff, and same-host SSRF filtering. - **Operator-tunable preload throttle**: Concurrency (1-4 workers), inter-request delay (0-10000 ms), batch size (1-500), and a per-core load-average ceiling above which a pass defers, clamped on both control plane and agent. - **Remove Unused CSS: own engine, no headless browser**: The agent POSTs page HTML + CSS to the control plane, which computes used-CSS with its own engine (no third-party service), caches it in object storage, and dedups across pages of the same structure hash. The agent inlines the used-CSS and defers the originals. Any failure or cache-miss serves full CSS unchanged, so rendering is never blocked. - **RUCSS safelist**: Built-in safelist of slider/lightbox/runtime-state classes plus operator-defined selectors are force-kept so JS-driven widgets survive the purge. - **Post-compute cache reheat**: When the async RUCSS worker finishes, the control plane purges the URL and re-fetches it so the next render is a HIT with optimized HTML already cached. - **CSS/JS minification**: Local same-site stylesheets and scripts are minified, content-addressed into a local asset cache, and tag URLs rewritten. Already-minified and external files are skipped. - **JavaScript delay (defer / interaction / idle)**: Defers first-party JS via the `defer` attribute, or rewrites scripts to `data-src` and runs them only on first interaction or `requestIdleCallback`. Structural scripts (ld+json, speculation rules) are never delayed. - **Font optimization**: `font-display:swap` injected into `@font-face` + Google Fonts links, optional self-hosting of Google Fonts stylesheets + woff2 files, and heuristic `rel=preload` for the first critical fonts. - **WOFF2 font transcoding**: Self-hosted TTF, OTF, and WOFF fonts are transcoded to WOFF2 by the media-encoder service (50 to 65% smaller for TTF/OTF, 20 to 30% for WOFF). The original serves until transcoding completes; any failure falls back to the original. - **Glyph subsetting**: When subsetting is enabled alongside WOFF2 transcoding, the encoder produces a latin-ext subset (U+0000 to 024F, U+1E00 to 1EFF) alongside the full WOFF2. Typical additional savings are 60 to 90% for body-text Latin fonts. Variable and icon fonts are detected and skipped. Per-font processing states (pending / converting / ready / subsetted / skipped / failed) are shown in a live table on the Optimize tab. - **Third-party asset self-hosting**: Downloads cross-origin CSS/JS to the local asset cache and rewrites tags to same-origin. Best-effort: failure leaves the external URL in place. - **Image markup optimization (CLS + lazy-load)**: Fills missing `width`/`height` from `getimagesize()` or the filename WxH suffix to prevent layout shift. Adds `loading=lazy` + `decoding=async` to below-the-fold images while keeping the first two eager; existing srcset preserved. - **YouTube facade + Gravatar self-host**: Replaces YouTube iframes with a click-to-load thumbnail facade (no YouTube JS until clicked) and downloads/self-hosts Gravatars to the local asset cache. - **Speculation Rules prefetch**: Injects a `