Open-source, self-hostable WordPress fleet management: backups, updates, uptime monitoring, security, and image optimization for every site from one dashboard you own. A self-hosted MainWP and WP Remote alternative.
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 β all running on infrastructure you control. 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.
Website Β· Live dashboard Β· API reference
v0.43.3 β open-source and production-usable for self-hosters.
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 distribution ("Fleet Agent for WPMgr") is pending directory review; that build strips the self-updater and declares GPLv2.
Backups & restore
- Pure-PHP streaming DB dump β Server-side cursor (
MYSQLIUSERESULT),REPEATABLE READsnapshot, ~1 MiB batched INSERTs. No mysqldump binary, no shell access β 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.listby 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, BLAKE3-hashed, and deduplicated across snapshots. Only changed chunks re-upload on the next full backup.
- Client-side age encryption β Optional X25519 + ChaCha20-Poly1305 per-chunk encryption to the site's public recipient; the control plane stores only ciphertext and never holds a decryption key.
- Three backup destinations β Control-plane-managed bucket, customer-owned S3-compatible bucket (agent never holds the credentials), or a local folder on the WordPress host.
- 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.jsoncaptures 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-<id>/rollback dir, rename staging into place. A crash never leaves a half-merged site. - Online DB restore β Import into
tmp<id>_-prefixed tables while WordPress stays live; swap each table atomically viaDROP + RENAMEat 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 BLAKE3-verified; every zip entry is traversal-checked with a canonical-path containment check against the staging root.
- Maintenance-mode windowing β Drops WordPress's
.maintenancefile 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=trueso 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.
- Active uptime monitoring β SSRF-hardened probes classify up/down with a full timing breakdown: DNS, connect, TLS handshake, TTFB, total.
- 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
WPDebugData::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,
siteasof_hashfingerprint (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
duor PHP fallback, annotated with method + computed-at timestamp. Never blank like the native Site Health screen. - Privacy redaction β Agent-side recursive walker redacts
adminemail,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_validflag; a/verifyendpoint 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 β no parallel path can bypass it. Self-hosted operators must set
WPMGRAUTHWEBAUTHNRPIDandWPMGRAUTHWEBAUTHNRPORIGINSfor passkeys to work on their own domain; the authenticator-app factor works on any origin. See 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 β
coremodified(high),coremissing(medium),coreunknowninjected(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_USlocale 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), binaryinet_ptonbitmask 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
/32or/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_ksesallowlist (noscript/style/iframe/on*). Safe to inject CP-controlled content intowp-login.php. - Hashed, role-scoped API keys β
wpmgr<prefix><secret>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 callCanAccessSitebefore reading or mutating.
Performance
- Zero-DB page cache fast path β An
advanced-cache.phpdrop-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.gzwith a 304 Not-Modified short-circuit. Every cached response carries anx-wpmgr-cacheheader. - 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
WPCACHEdefine, installs the drop-in, and adds an Apache.htaccessblock that serves.gzwithout PHP. nginx gets a copy-pastetryfilessnippet; 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
wpmgrpurge*:before/:afteractions 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 β 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
deferattribute, or rewrites scripts todata-srcand runs them only on first interaction orrequestIdleCallback. Structural scripts (ld+json, speculation rules) are never delayed. - Font optimization β
font-display:swapinjected into@font-face+ Google Fonts links, optional self-hosting of Google Fonts stylesheets + woff2 files, and heuristicrel=preloadfor the first critical fonts. - WOFF2 font transcoding β Self-hosted TTF, OTF, and WOFF fonts are transcoded to WOFF2 by the media-encoder service (50β65% smaller for TTF/OTF, 20β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β024F, U+1E00β1EFF) alongside the full WOFF2. Typical additional savings are 60β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/heightfromgetimagesize()or the filename WxH suffix to prevent layout shift. Addsloading=lazy+decoding=asyncto 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
<script type=speculationrules>block that prefetches same-origin links (eagerness moderate) while excluding wp-admin/login, cart/checkout/logout, query-string, and nofollow URLs for near-instant navigations. - CDN URL rewrite with encrypted credentials β Rewrites same-site static-asset URLs to a configured CDN host (all / css-js-font / image groups), runs last so it catches minified + self-hosted URLs, and purges the CDN edge (Cloudflare/Bunny/KeyCDN) on purge using age-encrypted credentials the control plane decrypts only in-process.
- WordPress bloat removal β Per-toggle de-bloat (block-library CSS, dashicons, emojis, jquery-migrate, XML-RPC, RSS feeds, oEmbeds, Heartbeat throttling, post-revision cap).
- Optimize-on-MISS, cache the optimized bytes β The full optimization pipeline runs inside the cache-writer's MISS path before gzip + write. Every transform is config-gated, wrapped so a failure degrades to a no-op, and skipped for logged-in/personalized responses.
- Cache hit-ratio history β The drop-in tallies HIT/MISS to per-hour counter files with zero DB work on a cache hit; the heartbeat drains completed buckets to the control plane, which appends to a Postgres time-series (120-day GC).
- Hit-ratio trend chart (7/30/90-day) β Dashboard renders a cache hit-percentage trend with a 7/30/90-day window toggle and an average-ratio badge.
- Live cache telemetry + verify card β Agent reports cached-page count, cache size, last purge, and live preload progress over SSE, plus the real install state (web server, drop-in present,
WP_CACHEdefine,.htaccessmanaged) so the verify card reflects on-host truth. - Page-cache conflict + multi-currency/i18n detection β Detects other active cache/optimization plugins (reported to the dashboard, never deactivated) and auto-derives the cache-varying cookies/queries that multi-language and multi-currency plugins need so language/currency variants cache separately.
- Portfolio bulk actions + presets β Purge the cache across many sites at once, or apply a safe / balanced / aggressive optimization preset to a whole group in one run (each preset spreads a small toggle set without clobbering per-site lists).
- Page-source footprint marker β Every cached response carries an HTML-comment footprint (with timestamp, and an
(optimized)suffix when transformed) so operators can confirm via view-source that WPMgr wrote and optimized the page.
Redis object cache
- Per-site persistent object cache β Full WordPress cache API surface (
add/get/set/replace/delete, multi-key variants,flushgroup,flushruntime,wpcachesupports,switchtoblogfor multisite). Accelerates logged-in users, admin screens, WooCommerce checkout, and every database round-trip the page cache cannot serve. - Connect test before enable β The agent dials the candidate config without persisting it, probes phpredis capabilities (igbinary serializer, lzf/lz4/zstd compression, TLS), reads the eviction policy, and returns a structured result. Enable is blocked until a test passes. Codec mismatch is caught up front and rejected with a clear message.
- Encrypted credentials β The control plane age-encrypts (X25519) the connection credential and delivers it via the signed command channel to a 0600 file on the site. Plaintext never appears in GET responses, logs, SSE payloads, test results, heartbeats, or backups.
- Two-level graceful degradation β A boot failure swaps in a pure in-memory array cache so the site never goes down. Mid-request Redis errors become cache misses with one reconnect attempt, then degrade for the rest of that request. A persisted reconnect cool-down (15 s, doubling to 5 min) stops a down Redis from being re-dialed on every request.
- Fully self-contained drop-in β One generated file contains the complete engine, connection layer, and config loader β no runtime dependence on the plugin folder name or location. The agent invalidates the drop-in's opcache on every version change; an outdated stub self-heals on the next heartbeat.
- Configuration drift detection β The agent reports the fingerprint of the configuration file it is actually reading; the dashboard flags divergence from saved settings.
- Live status + analytics β Dashboard shows connected / degraded / down with a 10 s SSE-debounced update. Charts track hit ratio, used memory, average command latency, and ops/sec over the last 7 days with a 90-day daily downsample. A per-site-prefixed flush never touches another site's keys on a shared Redis.
x-wpmgr-object-cachedebug header β When the "Debug response header" setting is on, front-end responses carryx-wpmgr-object-cache: state=connected hits=42 misses=3 reads=45 writes=12 ms=1.4. Administrators always receive it while logged in; visitor-side is opt-in. Pages served by the page cache do not carry the header because WordPress does not run on those responses.
Real user monitoring
- Web-vitals collection β A tiny first-party collector script (injected into cached pages at cache-write time) beacons LCP, INP, CLS, FCP, and TTFB directly to the control plane. Versioned collector URL forces edge-cache revalidation on every agent update.
- CrUX-style p75 dashboard β p75 per metric with a PageSpeed Insights-style good/needs-improvement/poor distribution bar, a 28-day trend chart with threshold lines drawn on, and per-URL and per-device breakdowns. Live updates stream over SSE.
- Postgres histogram rollups β Hourly and daily, with ClickHouse available as an opt-in scale backend via the same boot-selection pattern as the uptime store.
- Privacy-first β Off by default. No cookies, no cross-site identifiers. Page paths stored with query string stripped. Visitor IP used only transiently for coarse country lookup, then discarded. On a self-hosted control plane all RUM data stays on the operator's own infrastructure.
Per-site email
- Multi-provider sending β Amazon SES, SendGrid, Mailgun, Postmark, or any generic SMTP server, configured per-site or inherited from an org-wide default. Org default propagates automatically to every inheriting connected site in parallel on save.
- Named connections with failover β Define multiple named connections per site. Map FROM addresses to a specific connection; a fallback connection retries automatically on primary failure. The email log records which connection handled each send.
- Central cross-site email log β Every outgoing email logged with full detail: to, from, subject, headers, status, provider response, retry count, and attachment names/sizes. Paginated with free-text and column-scoped search, status and date filters, per-row detail with previous/next navigation, single and bulk resend, and CSV/JSON export. Email bodies not stored by default; opt-in per tenant. Log entries prune after 14 days.
- HTML preview sandbox β A logged email's body renders inside a locked-down sandboxed iframe (no scripts, no same-origin) with a strict CSP. Remote images and tracking pixels blocked by default with a per-message opt-in.
- Bounce and complaint suppression β Connect a provider webhook (SES SNS, SendGrid, Mailgun, Postmark) and WPMgr automatically suppresses hard-bounced and complained addresses fleet-wide. Manual add/remove supported. Scoped per-site or org-wide.
- Fleet deliverability dashboard β Cross-site sent/failed/bounced/complained counts in one view. Per-site deliverability charts on each site's Email tab. Live SSE updates.
- Failure alerts + digest β Opt-in alerts sent to operator-chosen recipients when sends start failing (throttled; configurable 15 minβ24 h). A separate weekly or monthly deliverability digest summarises sent, failed, and bounced counts per site with a top-failures list.
- Credentials encrypted at rest β age (X25519); a
secret_setflag is returned in place of the plaintext. A test-send button confirms delivery before saving.
Media
- Dedicated cloud encoder β Separate
media-encodercontainer decodes from magic bytes (never trusted MIME) and re-encodes to AVIF (q50/speed8), WebP (q80), or re-optimized original. Animated GIFs β animated WebP. - Runs by default, disable if unneeded β Part of the base self-host stack (
docker compose up -dstarts it, no profile needed); disable on constrained hosts with--scale media-encoder=0. Zero weight or native-library CVE surface on the core API either way, since encoding never runs there. - Per-image optimize and full reversible restore β Originals archived on-site (
.wpmgr-original.*rename); restore reverts every variant. A separate admin-gated delete-originals reclaims disk. - No image bytes on the control plane β Source and optimized bytes move agent-to-storage and encoder-to-storage via presigned URLs only. CP stores metadata rows only.
.htaccessAccept-header fallback β Serves the modern format only when the browser'sAcceptheader advertises support; legacy twin is served otherwise.Vary: Acceptfor CDN correctness. nginx map equivalent emitted for non-Apache hosts.- Auto-optimize on upload β Per-site opt-in hooks
wpgenerateattachment_metadata, debounces (~25 s) batch pushes to the CP, and runs the existing optimize pipeline. Four stacked guards prevent self-optimization loops. - Library sync + savings metrics β Total assets, optimized/pending/failed/unsupported counts, total bytes saved across all variants including thumbnails.
- Serialization-safe DB URL rewrite β Rewrites old-to-new media URLs across
post_contentand postmeta in a way safe for PHP-serialized data; recorded for reversible restore. - Resilient per-variant encoding β Per-variant failures never fail siblings; 50 MB / 100 megapixel source limits; 60 s per-encode timeout; size + magic-byte verification of downloaded outputs before write.
- Unused image library scan β Walks the entire attachment library and classifies every image as in-use or orphaned by building an exhaustive cross-content reference index. No image is flagged unused unless it appears nowhere.
- "Where is this image used" attribution β For every referenced image, surfaces each in-use location: post/page content, excerpts, revisions, featured images, gallery/block IDs, page-builder data (Elementor, Beaver, Bricks, Divi, WPBakery, Oxygen, Breakdance, ACF), SEO/OpenGraph meta (Yoast, Rank Math, SEOPress), theme mods, widgets, nav menus, term meta, and user/avatar meta β with a deep-link to edit the referencing object.
- Conservative "ambiguous = in use" design β Casts the widest net (revisions, builder JSON, serialized arrays, sub-size derivation, protocol-agnostic URL matching) and aborts the whole scan if the uploads directory is unresolvable rather than risk a false orphan.
- Reversible server-backed quarantine β Instead of deleting, isolate moves an image's original plus every generated sub-size (and optimizer-produced variants) into a web-blocked
wp-content/wpmgr-quarantine/store with a JSON manifest. The attachment post stays in the library so the action is fully reversible. - One-click restore from quarantine β Any isolated set can be moved back to its exact original upload paths from the manifest, with path-containment checks ensuring files only ever land back inside the uploads directory.
- Confirmation-gated permanent delete β Permanent removal of quarantined files plus their attachment posts requires an explicit
DELETEconfirmation token, enforced independently at both the control plane and the agent (constant-time compare). - Role-gated, audited cleaner endpoints β Scan and quarantine-list are viewer-level reads; isolate/restore/delete require operator-level write permission. Every action is recorded to the audit log and emitted over the live site-event bus.
Site tools
- File Manager β Browse, edit, upload, download, and manage files on any managed site from the dashboard, no SFTP required. Read access and write access are separate per-site toggles, both off by default, restricted to owner and admin. Write operations block PHP and executable content and refuse protected roots (wp-admin, wp-includes, core). Zip and download any path; extract a zip with zip-slip and zip-bomb protection. Search by name or content. Every edit auto-saves an encrypted prior version with one-click restore from a per-file history panel. Every read, write, delete, upload, extraction, and denial is written to the operator audit log, filterable by the File manager action group.
- Database Cleaner (scan / preview / clean) β Read-only scan estimates then bounded, batched cleanup across 14 categories: post revisions, auto-drafts, trashed posts, spam/trashed comments, expired transients, orphaned post/comment meta, orphaned term relationships, oEmbed cache, duplicate postmeta, Action Scheduler completed/failed, and OPTIMIZE TABLE on non-InnoDB tables. Per-category
{rowsdeleted, bytesfreed, state}results stream back over signed progress pushes. Cleanup results are stored server-side and restored on page load and stream reconnect so a running cleanup survives a refresh. - Database Cleaner safety β Each task deletes only the rows it targets; SELECTβDELETE batching in 2000-row chunks with a hard iteration cap, OPTIMIZE TABLE on a 12 h cooldown, and operator-level permission gate. WP core options/cron/tables and installed-plugin-attributable items are skipped.
- Per-table inventory + maintenance actions β Every table listed with row count, size, storage engine, overhead, and a "Belongs to" label (WordPress core / active plugin or theme / orphan). Per-table actions: optimize, repair, analyze, convert to InnoDB, empty, delete β each gated by a typed confirmation. Orphaned options and cron events classified by corpus confidence level (exact / prefix / heuristic / unknown) against a seed of ~120 high-orphan-risk plugins.
- Database Snapshots β One-click local DB snapshot before a risky change and one-click revert. Dumps to gzipped SQL under
wp-content/wpmgr-snapshots/db/(web-hardened, never uploaded), with create / list / revert / delete actions and a configurable retention cap (default 5, hard max 20, oldest pruned first). - Snapshot revert safety β Revert is a destructive whole-DB overwrite gated behind an exact
REVERTconfirmation token (constant-time compare). Before importing, auto-captures a pre-revert safety snapshot, then replays the dump intotmp-prefixed tables and atomically swaps each over the live table so WordPress stays readable throughout. A failed import leaves the live DB untouched. - Search & Replace β Operator-driven literal find-and-replace across the whole database (or a chosen table allowlist) for URL/domain migrations and string fixes. Mandatory dry-run preview returns tables-scanned / rows-matched counts before any write, then reports rows-changed on apply.
- Search & Replace safety β Serialization-safe walker (PHP-serialized blobs are unserialized, rewritten, and re-serialized so
s:NN:length prefixes are recomputed β never a naivestr_replace). Minimum 3-char search guard; community migration table denylist; binary/blob columns andposts.guidalways skipped; all values mysqli-escaped; an advisoryX-Backup-Warningheader fires on a live run with no recent backup.
Clients for agencies
- Client grouping β Create, edit, and delete clients (name, company, contact email, phone, notes, brand color, logo URL). Assign one or many sites via bulk "Set client" on the fleet view; filter by client; jump to a per-client detail page. Clients are tenant-isolated with RLS; a database-level composite constraint makes cross-tenant assignment impossible.
- White-label reports β Monthly or weekly schedule per client, with a "Generate now" button for any custom period up to 92 days. Aggregates uptime, backups, updates, Core Web Vitals p75, and email deliverability. Per-section on/off toggles; custom intro and closing text. Delivered as branded HTML email, print-optimized page, and server-side vector-chart PDF. The client's brand color and logo appear throughout; the "powered by" footer is removable on any plan.
- Read-only client portal β Invite client users by email from the Portal access tab. Existing users added immediately; new addresses receive a tokenized invite (7-day expiry) with a copyable fallback link. Clients land at
/portalwith their logo and brand color applied, a softened-wording sites overview ("Monitoring active", "Needs attention"), per-site uptime and incident history, backup inventory, applied updates, Core Web Vitals field data, and completed report downloads. Theclientrole ranks below viewer with zero permissions; access is revoked instantly on removal, client archive, or client delete. - Live portal summary dashboard β The portal landing shows a status banner, five animated headline counters (sites monitored, average uptime, backups, updates applied, speed rating), a month-at-a-glance section with fleet uptime trend and Core Web Vitals distribution, site cards with 30-day sparklines, and a day-grouped "Recent work" timeline. All data is strictly scoped to the client's own sites.
Team & access
- Multi-tenant organizations β Each resource scoped to an org. Isolation enforced by Postgres Row-Level Security (
app.tenant_idGUC) with the app roleNOSUPERUSER NOBYPASSRLS. - Four-role RBAC β
owner > admin > operator > viewerwith a discrete permission matrix. Privilege ceiling prevents granting a role higher than your own. - Per-site collaborator sharing β Outside users (no org membership) scoped to one site, enforced by both Gin middleware and RESTRICTIVE RLS. Blocked from all org-level actions.
- Tokenized invitations β Single-use, 7-day-expiry SHA-256-hashed tokens bound to the invited email. Existing accounts must re-authenticate; a leaked link alone cannot log anyone in.
- Email/password auth with first-run bootstrap β argon2id hashing; the first signup bootstraps the owner account as verified and active. After that, anyone can self-register β new accounts are created pending and gain access only after clicking the emailed verification link. Login responses never reveal whether an email exists.
- Self-serve password reset β Public forgot-password + reset endpoints issue a 30-minute single-use SHA-256-hashed token. Both endpoints are enumeration-safe (always-200, timing-equalized).
- Change-password with session invalidation β Verifies the current password then stamps
passwordchangedat, which logs out all other active sessions on their next request while keeping the acting session alive. - UI-configured per-instance SMTP β Owners configure the instance SMTP relay (host/port/TLS/credentials/From) in Settings. The password is age-encrypted at rest, masked on read. A test-send button confirms delivery before saving.
- OIDC / SSO β OpenID Connect relying party with PKCE, state, and nonce. Account linking only when the IdP asserts the email is verified. Ships with a Dex container so SSO works locally.
- Superadmin console β Instance-level cross-tenant admin area (
/api/v1/admin) gated byissuperadmin, granted only via theWPMGRSUPERADMIN_EMAILSenv allowlist and boot seeder β never API-settable. Lists/searches users cross-tenant, disables/deletes users with orphaned-org cleanup, resends verification, and shows instance stats. Intended for operators running multi-tenant self-hosted instances. - Tamper-evident audit log β SHA-256 hash-chained, append-only (DB role denied
UPDATE/DELETE). Covers logins, member/role changes, API-key changes, site lifecycle, sharing, autologin, media consent, updates./audit/verifyreports the first broken link. - API keys β Role-scoped, revocable, audited. Carries the same RBAC permission matrix as a user session.
Architecture
apps/api β Go 1.26 + Gin control plane (modular monolith)
apps/web β React 19 + TypeScript + Vite + TanStack dashboard
apps/agent β PHP 8.1+ WordPress agent plugin (MIT)
Data: Postgres (primary + RLS) Β· Redis (sessions, cache, dedup) Β· S3-compatible object storage (backups, media) Β· on-disk page cache (wp-content/cache/wpmgr, pre-gzipped .html.gz) Β· ClickHouse (optional, uptime + RUM time-series)
Agent β CP auth: Ed25519 signed requests (canonical METHOD\nPATH\nTIMESTAMP\nNONCE\nsha256(body)) with per-request nonce + timestamp for anti-replay. CPβagent commands are short-lived Ed25519-signed JWTs scoped to one site and one operation.
See docs/architecture.md for a full system diagram.
Quickstart (self-host)
The bundled compose brings up the full stack β control plane, dashboard, Postgres, Redis, object storage, and the media encoder (screenshots + Media Optimizer) β building all three images from source:
cp .env.example .env
Edit .env β at minimum set WPMGRSESSIONSECRET, WPMGRDBPASSWORD, WPMGRS3SECRET_KEY
docker compose -f infra/docker-compose.yml up -d
curl localhost:8081/healthz # {"status":"ok"} (default WPMGRAPIPORT=8081)
Open http://localhost:8088 in your browser (the default WPMGRWEBPORT) β the first signup creates the owner account. After that, anyone can self-register and gains access only after verifying their email.
The media encoder runs headless Chromium for screenshots, so it adds some RAM/CPU over the api/web images. On a constrained host you can skip it without editing the compose file:
docker compose -f infra/docker-compose.yml up -d --scale media-encoder=0
Site screenshot cards then fall back to favicon/monogram and the Media Optimizer tab is unavailable; everything else works normally.
Prebuilt container images
The control plane, dashboard, and media encoder are published on GitHub Container Registry as multi-arch (linux/amd64 + linux/arm64) images β wire them into your own compose, Kubernetes, or Swarm for production:
docker pull ghcr.io/mosamlife/wpmgr-api:v0.43.3
docker pull ghcr.io/mosamlife/wpmgr-web:v0.43.3
docker pull ghcr.io/mosamlife/wpmgr-media-encoder:v0.43.3
Or bring up the whole stack from the published images (no local build) with the pull-only Compose overlay:
export WPMGR_VERSION=v0.43.3 # omit to track :latest
docker compose -f infra/docker-compose.yml -f infra/docker-compose.prod.yml up -d
Migrations apply automatically on boot. The validate-env command (make validate-env) checks your configuration and prints every problem at once before the stack starts.
Full install guide, env reference, and production hardening: docs/install.md.
Install the Agent
- Download
wpmgr-agent.zipfrom the GitHub Releases page. - In WordPress: Plugins β Add New β Upload Plugin β choose the zip β Install Now β Activate.
- Open the top-level WPMgr admin menu, set the Control-plane URL field and click Save URL, then paste the dashboard's Pairing code into the Enroll form and click Enroll.
See docs/agent.md for WP-CLI install and configuration options.
Roadmap
The following are accepted architectural decisions with no implementation yet:
- Fleet-wide backup browser β Browse and filter snapshots across all sites from one view (route placeholder present, not built).
- Backup download β No presigned-download endpoint or web UI; restore is the current recovery path.
- Scheduled update runs β
scheduled_atis stored and accepted; no deferred dispatcher yet (tasks enqueue immediately on create). - CAPTCHA challenge on login block β Login protection currently serves a static 403; no challenge/solve flow built.
- Plugin/theme content malware scanning β The scan engine covers WordPress core checksums only; no signature or heuristic detection for wp-content files yet.
- Automatic restore rollback UI β The
.wpmgr-old-files-<id>/rollback directory is preserved; no operator-initiated rollback endpoint exposed. - Redis Sentinel / Cluster β Object cache v1 supports single instance or unix socket with TLS; the config schema reserves fields for both topologies.
- Helm chart / Terraform provider β Stubs exist under
infra/; not implemented. - AI features β
apps/api/internal/ai/contains only a.gitkeepplaceholder.
Repository layout
apps/
api/ Go control plane
web/ React dashboard
agent/ WordPress agent plugin
packages/
openapi-client/ generated TypeScript API client
infra/
docker-compose.yml
Dockerfile.api Β· Dockerfile.web Β· Dockerfile.media-encoder
postgres/ Β· seaweedfs/ Β· nginx/ Β· dex/ Β· grafana/
docs/
install.md Β· agent.md Β· architecture.md Β· contributing.md Β· security.md Β· api.md Β· adr/
Development
cp .env.example .env
docker compose -f infra/docker-compose.yml up -d # data plane
API
cd apps/api && go run ./cmd/wpmgr
Web
cd apps/web && pnpm dev
Regenerate OpenAPI client after schema changes
go generate ./internal/api/gen/...
pnpm -C packages/openapi-client generate
See docs/contributing.md for the full dev setup, PR checklist, and ADR process.
License
| Component | License | |---|---| | Control plane + dashboard (apps/api, apps/web) | AGPL-3.0-only | | WordPress agent plugin (apps/agent) | MIT |
