Designing a Central WordPress-to-GitHub Backup Plugin

New function updated: the centralized backup dashboard can now schedule all four WordPress backups automatically. The system runs them sequentially during an off-peak UTC window, while the existing global lock remains the final authority. In other words, four websites may queue politely, but they are not allowed to charge through the VPS door together.

Automatic Sequential Backups

The original dashboard supported manual background backups for four isolated WordPress installations. That worked well, but it still required an administrator to open WordPress and press a button. The next step was therefore a daily systemd timer using the same proven backup engine.

No alternative backup implementation was introduced. Scheduled jobs, dashboard jobs, and CLI jobs all continue to use the same root-owned engine, per-site configurations, private repositories, WARP safeguards, cleanup traps, and global resource lock.

Installed Schedule

  • Execution time: 02:30 UTC daily
  • Randomized delay: up to 10 minutes
  • Order: main site, site-b, site-c, site-d
  • Concurrency: one site at a time
  • Missed execution: skipped instead of running immediately after reboot

The randomized delay means a trigger may appear at 02:32 one day and 02:38 another day. This is expected. The timer is effectively saying, “I will arrive around 02:30, but please do not make me promise the exact second.”

NEXT                        UNIT
02:32:39 UTC                yin-wordpress-backup-all.timer

Daily base time: 02:30 UTC
RandomizedDelaySec: 10m
Persistent: false

Why the Backups Run Sequentially

The VPS has one logical CPU, approximately 1 GiB of RAM, and limited temporary disk capacity. Running several database exports and Git object-building processes together would add risk without improving recovery quality.

The scheduler therefore launches one complete backup at a time. The existing global flock remains authoritative, so a delayed automatic job cannot overlap a manual dashboard backup or another scheduled sequence.

Scheduler Controls in WordPress

Plugin version 1.3.0 adds an automatic-scheduling panel to Tools → YIN GitHub Backup. An administrator can now:

  • see whether scheduling is active, paused, or running;
  • see the configured UTC time and next calculated trigger;
  • see the last trigger and sequence status;
  • change the daily UTC time;
  • pause future automatic backups;
  • resume the timer;
  • continue using the four individual manual backup buttons.

Changing the time restarts only the timer so that systemd can calculate its next trigger. It does not immediately run a backup. Pausing the schedule also leaves manual site backups available.

A Narrow Root Controller

WordPress cannot submit arbitrary shell commands, filesystem paths, repositories, unit names, or calendar expressions. The PHP interface calls a restricted root-owned controller that accepts only four actions:

status
set
pause
resume

The set action accepts only a validated 24-hour HH:MM UTC value. The controller then creates a fixed systemd timer override. This keeps the convenient web interface separate from unrestricted root access.

One Active Plugin, Not Four Copies

Only the main WordPress administration site now contains the active dashboard plugin. The other three inactive copies were removed because they were historical replicas rather than operational dependencies.

The other websites can still be backed up because the dashboard controls fixed root-owned services, and each site is identified through its protected configuration. Future plugin development therefore follows a simpler model: update one canonical source and deploy it to one active control dashboard.

An Early Validation Error

The first scheduler installer stopped because systemd-analyze checked the final service before its candidate executable had been deployed:

Command /usr/local/sbin/yin-wordpress-backup-all
is not executable: No such file or directory

This was a validation-order problem, not a failed backup service. Nothing had been deployed, and no SQL export, WARP connection, maintenance mode, or GitHub push had started. The corrected installer validated the candidate executable directly, created a rollback checkpoint, deployed the files, reloaded systemd, and enabled the timer safely.

Current Validation Status

The timer, dashboard controls, restricted sudo contract, plugin version, service states, and safety cleanup have all been validated. WARP remained inactive and boot-disabled after installation, every website remained outside maintenance mode, and no backup was launched by the installer.

The first real timer-triggered four-site sequence is still pending. Automatic scheduling is implemented, but end-to-end success should be claimed only after that first unattended run has completed and all four remote commits, privacy states, cleanup results, maintenance states, and WARP shutdown have been verified.

Practical Lesson

Scheduling should be a thin orchestration layer over a backup process that already works. A timer should decide when to run the engine—not reinvent how databases, files, Git history, networking, and cleanup are handled. That separation made it possible to add automation without creating a mysterious fifth backup system hiding behind the other four.

***

A WordPress backup button sounds simple until it must export a database, capture thousands of files, create a Git commit, cross an IPv4 tunnel, verify repository privacy and clean everything afterward. This case study explains how I built one centralized WordPress dashboard for four isolated sites—without placing the backup engine, GitHub credentials or privileged shell access inside WordPress.

The Original Problem

A root-owned command-line backup system was already working on a small Debian VPS. It could create a consistent WordPress website and database snapshot, commit it directly into a protected Git object store, push it to a private GitHub repository and remove temporary data afterward.

The command-line workflow was reliable, but routine operation still required an SSH login. The practical goal was therefore to add a WordPress administration interface with one button per website.

That sounds like a request to “put the backup script into a plugin.” It was not.

WordPress would become the control panel for the backup system, but it would not become the backup engine.

The distinction was essential. PHP-FPM should not receive GitHub credentials, database passwords, arbitrary root access or responsibility for a multi-minute Git upload. WordPress should be allowed to request one of a few predefined actions and read carefully filtered status information. Nothing more.

Environment and Scope

The tested server had the following characteristics:

Component Tested environment
Operating system Debian GNU/Linux 13
Resources 1 virtual CPU and approximately 1 GiB RAM
PHP 8.4 with PHP-FPM
WordPress 7.0.4
MariaDB 11.8
WP-CLI 2.12.0
Git 2.47.3
GitHub CLI 2.97.0
Websites Four independent WordPress installations
Repositories Four independent private GitHub repositories

All names and paths in this article are anonymized. The four representative site identifiers are:

  • site-a;
  • site-b;
  • site-c;
  • site-d.

Their WordPress roots are represented as /var/www/example-site-a through /var/www/example-site-d.

Requirements and Constraints

The plugin had to satisfy several operational and security requirements.

  • Only WordPress administrators may access the dashboard.
  • Every state-changing request requires a WordPress nonce.
  • The browser may request only fixed start and status actions.
  • Site identifiers must come from a hard-coded allowlist.
  • No arbitrary path, repository, database or shell command may be accepted.
  • The real backup must continue after page refresh, navigation or browser closure.
  • Only one backup may run across the entire VPS.
  • Each website must retain its own repository, database export, state and logs.
  • GitHub and database credentials must remain unavailable to PHP and JavaScript.
  • The dashboard may display operational logs but not secrets or unrestricted root output.
  • Maintenance mode must always be removed.
  • Temporary SQL, indexes and Git objects must always be deleted.
  • The system must not offer an unsafe web-based Stop button.
  • The plugin must reuse the proven CLI engine instead of implementing a second backup system.

The VPS had only one CPU and roughly 1 GiB of memory, so simultaneous backups would have been adventurous in the same sense that juggling databases is adventurous. A single global lock was therefore non-negotiable.

Architecture: WordPress Is Only the Front Door

The completed system separated the web interface from privileged backup execution:

Administrator browser
        |
        | WordPress AJAX + nonce
        v
Central WordPress plugin
        |
        | exact sudo command
        v
Restricted root controller
        |
        | starts predefined systemd instance
        v
[email protected]
        |
        | invokes root-owned CLI with root-owned configuration
        v
Generic backup engine
        |
        +-- WordPress files
        +-- logical SQL export
        +-- Git object construction
        +-- temporary IPv4/WARP access
        +-- private GitHub push
        +-- remote verification
        +-- mandatory cleanup

Status flows back through the restricted controller,
not through direct access to root-owned files.

The architecture consisted of five layers:

Layer Responsibility
WordPress plugin Authorization, interface, AJAX requests and polling
Restricted controller Validate action and site ID, start service, return protected status
Systemd service template Run the backup independently of the web request
Root-owned site configuration Map each site to its fixed root, repository, state and logs
Generic CLI engine Perform preflight, snapshot, push, verification and cleanup

Step 1: Prove the CLI Before Building the Plugin

The plugin was deliberately developed only after multiple command-line backups had succeeded.

The CLI had already demonstrated:

  • consistent logical database export;
  • direct Git object construction without a second website copy;
  • private-repository verification;
  • incremental Git history;
  • global locking;
  • maintenance cleanup;
  • temporary-data removal;
  • remote commit verification.

This sequencing greatly simplified plugin development. The UI did not need to answer whether the backup algorithm worked. It needed only to launch and observe the already validated algorithm safely.

Step 2: Create Root-Owned Site Configurations

The original engine was tied to one WordPress root and one repository. It was generalized to accept a fixed site identifier:

sudo /usr/local/sbin/example-wordpress-backup site-a preflight
sudo /usr/local/sbin/example-wordpress-backup site-a run

Each site received a root-owned configuration file:

SITE_ID='site-a'
SITE_LABEL='Example Site A'
WP='/var/www/example-site-a'
REPO='example-owner/example-site-a-private-backup'
BRANCH='main'
STATE='/var/lib/example-wordpress-backup/site-a'
LOG_DIR='/var/log/example-wordpress-backup/site-a'
DOCUMENTATION_FILE=''

Representative configuration location:

/etc/example-wordpress-backup/sites/site-a.conf
/etc/example-wordpress-backup/sites/site-b.conf
/etc/example-wordpress-backup/sites/site-c.conf
/etc/example-wordpress-backup/sites/site-d.conf

The files were owned by root:root and were not writable by the web server. The controller accepted only known site IDs and verified that the SITE_ID inside the selected configuration matched the requested ID.

This prevented a request such as ../../another-file, a custom repository URL or an arbitrary WordPress path from reaching the backup engine.

Step 3: Use an Instantiated Systemd Service

A normal PHP request is a poor home for a long backup. It can time out, be terminated by PHP-FPM, disappear when the browser closes or be interrupted when WordPress enters maintenance mode.

The plugin therefore starts a systemd oneshot service:

[Unit]
Description=Example WordPress backup for %i
After=network-online.target mariadb.service nginx.service php8.4-fpm.service
Wants=network-online.target
ConditionPathExists=/etc/example-wordpress-backup/sites/%i.conf
ConditionPathExists=/usr/local/sbin/example-wordpress-backup

[Service]
Type=oneshot
User=root
Group=root
UMask=0077
WorkingDirectory=/root
ExecStartPre=/usr/bin/install -m 0600 /dev/null /run/example-backup-%i-plugin-started
ExecStart=/usr/local/sbin/example-wordpress-backup %i run
ExecStopPost=/usr/bin/rm -f /run/example-backup-%i-plugin-started
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
TimeoutStartSec=infinity
KillMode=mixed
PrivateTmp=true
NoNewPrivileges=true

The template can produce independent units:

[email protected]
[email protected]
[email protected]
[email protected]

The browser receives a quick “request accepted” response. Systemd then owns the real process. Refreshing or closing the page does not stop it.

Step 4: Build a Narrow Root Controller

The controller is the only command that PHP may execute through sudo. It is a Bash script, not Python, and must be validated with bash -n.

Its command contract is intentionally small:

example-wordpress-backup-control start SITE-ID
example-wordpress-backup-control status SITE-ID

The controller rejects everything outside a fixed allowlist:

case "$REQUESTED_SITE_ID" in
    site-a|site-b|site-c|site-d)
        ;;
    *)
        echo "Unknown backup site." >&2
        exit 64
        ;;
esac

case "$ACTION" in
    start|status)
        ;;
    *)
        echo "Unknown backup action." >&2
        exit 64
        ;;
esac

Starting a Backup

For start, the controller:

  1. confirms that it is running as root through the restricted sudo rule;
  2. loads the selected root-owned configuration;
  3. checks every configured backup service;
  4. refuses a cross-site concurrent launch;
  5. resets only a stale service failure state;
  6. starts the correct systemd instance with --no-block;
  7. returns a small JSON response.
SERVICE="example-wordpress-backup@${SITE_ID}.service"

for configured_site in site-a site-b site-c site-d; do
    state="$(
        systemctl show \
            "example-wordpress-backup@${configured_site}.service" \
            --property=ActiveState \
            --value 2>/dev/null
    )"

    case "$state" in
        active|activating|deactivating)
            printf '%s\n' \
                '{"accepted":false,"message":"Another backup is running."}'
            exit 1
            ;;
    esac
done

systemctl reset-failed "$SERVICE" >/dev/null 2>&1 || true
systemctl start --no-block "$SERVICE"

printf '%s\n' \
    '{"accepted":true,"message":"Backup request accepted."}'

The global flock inside the engine remains authoritative. The controller’s cross-site check improves the user experience, while the engine lock provides the final concurrency guarantee.

Returning Status

The source status file is root-owned and mode 0600. PHP does not read it directly.

For status, the controller combines:

  • the root-owned status JSON;
  • systemd ActiveState, SubState and result;
  • the current service-start marker;
  • the most recent applicable log;
  • the site’s maintenance file;
  • the protected temporary job directory;
  • the configured repository identity.

It then emits a constrained payload:

{
    "site_id": "site-a",
    "site_label": "Example Site A",
    "state": "completed",
    "message": "Backup pushed, verified and cleaned.",
    "service_active": false,
    "service_state": "inactive/dead",
    "started_at": "2026-08-13T10:00:00Z",
    "completed_at": "2026-08-13T10:04:00Z",
    "database_size": "18MiB",
    "captured_file_count": 12000,
    "captured_size_bytes": 402000000,
    "remaining_disk_bytes": 1800000000,
    "commit": "0000000000000000000000000000000000000000",
    "repository": "https://github.com/example-owner/example-private-backup",
    "commit_url": "",
    "latest_error": "",
    "maintenance_mode": false,
    "temporary_material_removed": true,
    "log_name": "backup-example.log",
    "log": "Complete protected operational log"
}

The all-zero commit above is an illustrative placeholder, not a real repository commit.

Step 5: Restrict Sudo to Exact Commands

The web server user was not granted general root access. The sudo policy listed every allowed action explicitly:

Defaults!/usr/local/sbin/example-wordpress-backup-control !requiretty

www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control start site-a
www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control status site-a

www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control start site-b
www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control status site-b

www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control start site-c
www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control status site-c

www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control start site-d
www-data ALL=(root) NOPASSWD: /usr/local/sbin/example-wordpress-backup-control status site-d

The controller itself still validates every input. The sudo policy and controller allowlist are independent layers rather than substitutes for one another.

Step 6: Build the WordPress Plugin

The plugin registers one administration page and two AJAX operations: start and status.

A simplified site registry looks like this:

private const SITES = [
    'site-a' => [
        'label' => 'Example Site A',
    ],
    'site-b' => [
        'label' => 'Example Site B',
    ],
    'site-c' => [
        'label' => 'Example Site C',
    ],
    'site-d' => [
        'label' => 'Example Site D',
    ],
];

The repository paths and database details do not appear here. Those remain in root-owned configuration.

Administrator and Nonce Checks

private const CAPABILITY = 'manage_options';
private const NONCE_ACTION = 'example_github_backup_action';

private static function authorize(): void {
    if (!current_user_can(self::CAPABILITY)) {
        wp_send_json_error(
            ['message' => 'Administrator permission is required.'],
            403
        );
    }

    check_ajax_referer(
        self::NONCE_ACTION,
        'nonce'
    );
}

The capability check limits the interface to administrators. The nonce prevents a third-party page from silently submitting a backup request through an authenticated browser session.

Strict Site Validation

private static function site(string $site_id): array {
    if (!isset(self::SITES[$site_id])) {
        wp_send_json_error(
            ['message' => 'Unknown backup site.'],
            400
        );
    }

    return self::SITES[$site_id];
}

The browser cannot supply a path, repository or service name. It supplies one short identifier, which must already exist in the plugin’s fixed registry and the controller’s independent allowlist.

Calling the Controller Without a Shell

The plugin uses an argument array with proc_open() and explicitly bypasses shell interpretation:

private static function control(
    string $action,
    string $site_id
): array {
    if (!in_array($action, ['start', 'status'], true)) {
        return [
            'ok' => false,
            'message' => 'Invalid backup action.',
        ];
    }

    self::site($site_id);

    $command = [
        '/usr/bin/sudo',
        '-n',
        '/usr/local/sbin/example-wordpress-backup-control',
        $action,
        $site_id,
    ];

    $descriptors = [
        0 => ['pipe', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
    ];

    $process = proc_open(
        $command,
        $descriptors,
        $pipes,
        null,
        null,
        ['bypass_shell' => true]
    );

    if (!is_resource($process)) {
        return [
            'ok' => false,
            'message' => 'The protected controller could not be opened.',
        ];
    }

    fclose($pipes[0]);

    $stdout = stream_get_contents(
        $pipes[1],
        1048576
    );

    $stderr = stream_get_contents(
        $pipes[2],
        8192
    );

    fclose($pipes[1]);
    fclose($pipes[2]);

    $exit_code = proc_close($process);

    if ($exit_code !== 0) {
        return [
            'ok' => false,
            'message' => substr(
                sanitize_text_field(
                    $stderr ?: $stdout ?: 'Controller failure.'
                ),
                0,
                500
            ),
        ];
    }

    $decoded = json_decode($stdout, true);

    if (!is_array($decoded)) {
        return [
            'ok' => false,
            'message' => 'Invalid protected status response.',
        ];
    }

    return [
        'ok' => true,
        'data' => $decoded,
    ];
}

No string is assembled into sudo sh -c "...". Consequently, punctuation in a request cannot become shell syntax.

Step 7: Run One Central Dashboard

The first generalized plugin version was able to identify the WordPress installation in which it was active. Identical physical copies were installed in all four sites, but only the primary administration site activated the plugin.

The next iteration changed the primary plugin into a centralized dashboard showing all four sites at once.

Each panel displayed:

  • site label;
  • current state;
  • status message;
  • start button;
  • progress indicator;
  • start and completion times;
  • database export size;
  • captured file count;
  • captured byte count;
  • remaining disk space;
  • maintenance state;
  • temporary cleanup state;
  • latest verified commit;
  • protected operational log;
  • latest error, when applicable.

Only the active primary plugin renders the interface. The inactive copies remain identical to the canonical source so that future deployment and checksum verification stay simple.

Step 8: Poll Status Without Controlling the Job

The browser polls every few seconds. Progress percentages are presentation estimates derived from named engine stages:

const progressByState = {
    idle: 0,
    preparing: 10,
    maintenance: 25,
    exporting: 40,
    staging: 58,
    committing: 72,
    pushing: 84,
    verifying: 93,
    cleaning: 97,
    completed: 100,
    failed: 100
};

The browser does not calculate whether a backup succeeded. Success comes from the root-owned engine after remote commit, recovery-artifact and repository-privacy verification.

The button is disabled while any service is active. Even if two browser windows race, the controller and global engine lock independently prevent overlap.

The First Major UI Failure: “Unexpected End of JSON Input”

The first real plugin-launched backup actually succeeded, but the WordPress page displayed:

Unexpected end of JSON input

The reason was subtle. While backing up the primary site, WordPress briefly entered maintenance mode. During that interval, admin-ajax.php returned an empty or non-JSON maintenance response. The browser called response.json() immediately and treated the parsing error as a backup failure.

However, the backup was running under systemd, not in the AJAX request. It continued normally and eventually pushed and verified the commit.

The corrected request handler first reads the response as text:

const request = async action => {
    const body = new URLSearchParams({
        action,
        nonce
    });

    const response = await fetch(
        ajaxUrl,
        {
            method: 'POST',
            credentials: 'same-origin',
            headers: {
                'Content-Type':
                    'application/x-www-form-urlencoded;charset=UTF-8'
            },
            body
        }
    );

    const raw = await response.text();
    let payload;

    try {
        payload = JSON.parse(raw);
    } catch (parseError) {
        const maintenanceGap =
            action === 'example_backup_status'
            && (
                raw.trim() === ''
                || [502, 503, 504].includes(response.status)
                || /maintenance|briefly unavailable/i.test(raw)
            );

        const error = new Error(
            maintenanceGap
                ? 'Status polling is paused briefly while '
                    + 'maintenance mode is active. '
                    + 'The background backup continues.'
                : `WordPress returned a non-JSON response `
                    + `(HTTP ${response.status}).`
        );

        error.transient = maintenanceGap;
        throw error;
    }

    if (!response.ok || !payload.success) {
        throw new Error(
            payload?.data?.message
            || 'The backup request failed.'
        );
    }

    return payload.data;
};

A transient maintenance response now changes the visible state to “maintenance,” keeps the button disabled and resumes polling later. It does not claim that the background service failed.

This became plugin version 1.0.1.

From One Site to Four

The plugin evolved through several versions:

Version Main change
1.0.0 Initial administrator interface for the proven single-site CLI
1.0.1 Maintenance-aware polling after the non-JSON AJAX response
1.1.0 Reusable site-aware plugin and root-owned per-site configurations
1.2.0 One centralized four-site dashboard with cross-site launch protection
1.2.1 Complete protected operational logs with automatic redaction

The move to multiple sites did not create four backup engines. The same generic CLI was invoked with a different fixed site ID.

This provided:

  • one implementation to maintain;
  • one canonical plugin source;
  • one service template;
  • one controller;
  • one global resource lock;
  • separate configuration, repository, logs and state for every site.

Why the Initial “Sanitized Log” Was Not Enough

The first controller exposed only lines matching a strict allowlist of regular expressions. This protected secrets, but it also hid unfamiliar errors—the exact lines most useful while debugging.

For example, an engine failure might appear only as:

FAILED during: metadata and direct Git capture

The detailed root log still existed over SSH, but requiring a second command after every failure defeated much of the central dashboard’s usefulness.

The final design returned all ordinary operational lines while applying automatic redaction before the data reached PHP.

The controller protects patterns representing:

  • GitHub tokens;
  • passwords and authorization values;
  • private-key blocks;
  • WARP registration and license identifiers;
  • query-string secrets;
  • public IP addresses.

It also enforces line and total-output limits. In the tested implementation, individual lines were bounded and the final protected log remained below the plugin’s 1 MiB controller-output limit.

“Full protected log” means every useful operational line after redaction. It does not mean transferring an unrestricted root log byte-for-byte into a web browser.

A private WordPress administration page is still a weaker security boundary than a root-only file. Privacy of the page does not magically turn credentials into appropriate UI decoration.

How Better Logging Exposed Real Backup Defects

The expanded dashboard log immediately became useful during the first backup of another site.

Absent Optional Documentation Directory

One site had no permanent documentation file. The engine created a docs directory only when documentation was configured, but later passed that directory unconditionally to find.

Under strict shell error handling, the missing optional directory terminated metadata capture.

The correction was simple: always create an empty protected documentation staging directory, then add the documentation file only when configured.

Unicode Filenames and Git Quoting

Another site contained Chinese filenames. A validation step used newline-delimited git ls-files output and AWK to verify top-level paths.

Git quoted the Unicode paths in its human-readable output. The validator then interpreted a valid path as unexpected. AWK exited early, Git received SIGPIPE, and the engine reported exit code 141.

The correction switched to NUL-delimited output and a full-input byte parser:

data = sys.stdin.buffer.read()

for item in data.split(bytes([0])):
    if not item:
        continue

    top_level = item.split(b"/", 1)[0]

    if top_level not in {
        b"website",
        b"database",
        b"restore",
        b"docs",
        b"README.md",
        b".gitattributes",
    }:
        raise SystemExit(
            "Unexpected staged path."
        )

The first NUL-safe attempt accidentally split on a literal backslash-zero sequence instead of a real NUL byte. The final expression, bytes([0]), was verified with real Chinese and accented filenames before deployment.

These were engine defects rather than plugin defects, but the improved plugin observability made them diagnosable from the administration page.

Correctly Distinguishing a Fixed Commit from a Successful Commit

During one backup, the engine created a valid local commit, disabled maintenance mode and attempted to push. GitHub returned an Internal Server Error and rejected the branch.

The dashboard nevertheless displayed the locally fixed hash under “Latest successful commit.” The value was syntactically valid but semantically wrong: it had not been remotely verified.

The controller was corrected so a failed final state suppresses both the commit and commit URL:

if state == "failed":
    commit = ""
    commit_url = ""

A commit becomes “successful” only after:

  1. the remote branch points to that exact hash;
  2. the GitHub API returns the same commit;
  3. required recovery files exist remotely;
  4. the repository remains private;
  5. cleanup completes.

The engine also gained bounded push retries for transient remote failures. It retries the same fixed commit without force-pushing or rewriting history.

Why There Is No Stop Button

A web-based Stop button was deliberately rejected.

Interrupting a backup during any of these stages can create an ambiguous state:

  • database export;
  • metadata generation;
  • Git object creation;
  • commit construction;
  • remote push;
  • remote verification;
  • cleanup.

An administrator might click Stop because a progress bar appears slow, while the server is safely packing hundreds of megabytes of Git objects. The interface therefore reports progress but does not offer casual process termination.

Emergency intervention remains a root-only SSH operation followed by explicit verification of:

  • maintenance mode;
  • temporary job directories;
  • WARP state;
  • systemd service state;
  • remote branch state.

Why Files and Database Are Not Separate Buttons

Separate “Back up files” and “Back up database” buttons might appear convenient, but they weaken recovery consistency.

The system intentionally produces one commit containing:

  • the website filesystem;
  • one current logical SQL export;
  • ownership and permission manifests;
  • checksums;
  • software inventories;
  • recovery instructions.

The interface may display file and database stages separately, but they belong to one recovery snapshot.

Validation Before Deployment

Every update was built as a protected candidate outside the webroot and validated before installation.

Representative validation commands included:

set -euo pipefail

php -l \
    /var/tmp/example-candidate/backup-dashboard.php

bash -n \
    /var/tmp/example-candidate/example-wordpress-backup

bash -n \
    /var/tmp/example-candidate/example-wordpress-backup-control

systemd-analyze verify \
    /var/tmp/example-candidate/[email protected]

visudo -cf \
    /var/tmp/example-candidate/example-wordpress-backup-sudoers

sudo -u www-data \
    sudo -n \
    /usr/local/sbin/example-wordpress-backup-control \
    status \
    site-a |
    jq -e '
        .site_id == "site-a"
        and (.state | type == "string")
        and (.log | type == "string")
    '

JavaScript extracted from the PHP candidate was also parsed before deployment. Plugin copies were compared using SHA-256 checksums to confirm that all physical installations matched the canonical source.

Timestamped rollback checkpoints were created before replacing:

  • the CLI engine;
  • the controller;
  • the systemd template;
  • the sudo policy;
  • the canonical plugin;
  • the installed plugin copies;
  • status files when their schema changed.

End-to-End Validation Results

Test Result
Administrator-only page Passed
Nonce enforcement Passed
Unknown site rejection Passed
Unknown action rejection Passed
Background continuation after refresh or browser closure Passed
Maintenance-aware polling Passed
Cross-site concurrency rejection Passed
Global engine lock Passed
Unicode filename capture Passed after correction
Complete protected dashboard log Passed
Failed local commit hidden from successful-commit field Passed after correction
Four separate private repositories Passed
Remote commit and required recovery files Verified for all four sites
Maintenance cleanup Passed for all final jobs
Temporary-data cleanup Passed for all final jobs
Network-tunnel cleanup Passed

The final dashboard showed a completed, remotely verified and cleaned backup for each of the four WordPress installations.

Alternatives Considered

Implement the Entire Backup in PHP

Rejected. This would expose credentials and filesystem authority to WordPress, duplicate the proven CLI logic and make browser or PHP-FPM timeouts part of the backup’s reliability model.

Run the CLI Directly Inside the AJAX Request

Rejected. The browser would wait for several minutes, maintenance mode could interrupt the request, and closing the page might create uncertainty about process ownership.

Use WordPress Cron as the Process Runner

Rejected for manual launches. WP-Cron depends on WordPress traffic and executes within the application environment. Systemd provides clearer process ownership, logs, timeouts and service state.

Install and Activate the Plugin Independently on Every Site

Technically possible, but unnecessary for the desired workflow. A central dashboard reduced maintenance and provided one place to see whether another site was already running.

Store Repository and Path Settings in WordPress Options

Rejected. An administrator account or WordPress database compromise could then redirect the privileged engine. Root-owned fixed configuration keeps those mappings outside WordPress.

Give PHP Read Access to Root Logs

Rejected. The controller instead returns a bounded, redacted status representation.

Security Boundaries and Remaining Risks

The final plugin is intentionally narrow, but no WordPress plugin should be mistaken for a perfect security boundary.

  • A compromised administrator account could request an allowed backup.
  • All sites used the same PHP-FPM operating-system user in the tested environment.
  • A compromise of another PHP application running as that user could potentially invoke one of the exact allowed controller commands.
  • The controller prevents arbitrary commands, paths and repositories, but it cannot make a compromised web server harmless.
  • GitHub credentials remain root-owned, but backup repositories themselves contain highly sensitive website and database data.
  • The dashboard log redactor must be maintained when new log formats are introduced.
  • Systemd and root status remain authoritative; the browser is only a view.

A stronger future isolation model would assign a separate PHP-FPM Unix user and pool to each site. The central dashboard could then use a dedicated broker identity or another authenticated local control mechanism.

This would improve site-to-site isolation but also increase configuration complexity. It is a future hardening option, not a confirmed part of the tested implementation.

Possible Future Improvements

Automatic Scheduling

Systemd timers could schedule sequential off-peak backups. The existing global lock should remain authoritative so a delayed job cannot overlap the next one.

Notifications

A notification service could report:

  • site label;
  • success or failure;
  • verified commit;
  • duration;
  • maintenance and cleanup state.

Notifications should never include unrestricted logs, tokens, database credentials or registration identifiers.

Per-Site PHP-FPM Isolation

Separate operating-system users would reduce the effect of one compromised WordPress installation on the central controller interface.

Log Pagination

The current bounded full-log response was sufficient for the tested job sizes. A future implementation could expose paginated protected log segments to reduce repeated AJAX payload size.

Restoration Test Status

The dashboard verifies backup creation and remote artifacts, but it does not prove a complete restoration. A disposable VPS should periodically clone a repository, verify checksums, restore files, import SQL and validate WordPress without touching production.

Practical Lessons

  1. Prove the backup engine before building the button.
  2. Use WordPress as a control plane, not a privileged execution environment.
  3. Move long-running jobs into systemd or another durable process supervisor.
  4. Accept only fixed site IDs and fixed actions.
  5. Keep repositories, paths and credentials in root-owned configuration.
  6. Use exact sudo rules and validate inputs again inside the controller.
  7. Pass commands as argument arrays instead of shell strings.
  8. Retain an engine-level lock even if the UI already blocks concurrency.
  9. Expect WordPress AJAX polling to disappear briefly during maintenance mode.
  10. Read response text before parsing JSON when transient non-JSON responses are possible.
  11. Do not label a locally created hash as successful until remote verification passes.
  12. Use NUL-delimited Git output for arbitrary filenames.
  13. Show useful logs, but redact secrets before they enter PHP or the browser.
  14. Avoid a casual Stop button for transactional backup stages.
  15. Validate PHP, JavaScript, Bash, systemd, sudo and status contracts before deployment.
  16. Create rollback checkpoints before changing operational files.

Conclusion

The finished plugin does surprisingly little—and that is its main strength.

It authenticates an administrator, validates a nonce, accepts a fixed site ID, invokes one exact controller command and displays a protected status response. Systemd owns the process. The root CLI owns the backup. Root-owned configuration owns the repository mapping. GitHub remains outside WordPress entirely.

This separation turned a complex four-site backup system into a practical administration page without turning a WordPress plugin into a miniature root shell with a cheerful blue button.

The result is one central dashboard, four isolated repositories, one reusable engine and one global resource lock. Routine backups no longer require an SSH login, but the security and recovery logic remain where they belong: outside the web application.