Managing a fleet of WordPress installs from one Google Sheet comes down to a website column and a small controller. Each row in the sheet says which site it targets; the controller reads the sheet, groups the rows by site, and pushes each site its own batch through the same REST endpoint you would use for a single install. The part that separates a real tool from a demo is what happens when one site is down: a fleet push has to treat each site independently, so a dead or slow install fails on its own line and the rest still land. This article is that controller, run against two live sites.
It builds directly on the single-site push endpoint: every site in the fleet runs that same one-file MU-plugin, each with its own secret. The new piece is the controller that sits above them and fans one sheet out to all of them.
The shape of it
┌─▶ blog-a /wp-json/te-sheet/v1/post
Google Sheet │
(website column) ──▶ controller ─┼─▶ shop-b /wp-json/te-sheet/v1/post
│
└─▶ events-c /wp-json/te-sheet/v1/post (down → fails alone)
One sheet, one controller, N endpoints. The controller is the only new code; each site is just the single-site receiver you already have. Think of the sheet as the control plane and the controller as the thing that fans it out.
The sheet: one column says where each row goes
The only change from a single-site sheet is a website column naming which install the row targets. Everything else (the post_id join key, the fields to apply) is the same.
| website | post_id | title |
|---|---|---|
| blog-a | 1 | Spring Campaign: 20% Off Storewide |
| blog-a | 5 | Spring Campaign: Start Your Free Trial |
| shop-b | 6 | Spring Campaign: Summer Sale Is Live |
| shop-b | 7 | Spring Campaign: Free Shipping Weekend |
| events-c | 3 | Spring Campaign: Early-Bird Tickets |
One campaign, five rows, three sites. A non-technical editor maintains this sheet; the controller turns it into fifteen-odd API calls without anyone touching wp-admin.
The site registry: each site, its URL and its own secret
The controller needs to know where each website lives and how to authenticate to it. Keep that in a registry, and give every site its own secret, so a leak on one install is contained to that install and you can rotate one key without touching the others.
$te_sites = array(
'blog-a' => array( 'url' => 'https://blog-a.example/wp-json/te-sheet/v1', 'key' => getenv( 'TE_KEY_BLOG_A' ) ),
'shop-b' => array( 'url' => 'https://shop-b.example/wp-json/te-sheet/v1', 'key' => getenv( 'TE_KEY_SHOP_B' ) ),
'events-c' => array( 'url' => 'https://events-c.example/wp-json/te-sheet/v1', 'key' => getenv( 'TE_KEY_EVENTS_C' ) ),
);The keys come from the environment, never the source. On the receiving end, each site defines its own TE_SHEET_SECRET in its wp-config.php, exactly as the single-site endpoint does. The controller is the only place that holds all the keys, so it is the one machine you have to keep locked down.
The controller: group, then fan out
The controller is a standalone script run from a control machine (a small box, a CI job, your laptop), not a plugin on any of the managed sites. It reads the sheet with the same service-account helpers used across the sheet cluster (included at the top of the file, so this script is self-contained), groups the rows by website, and POSTs each site its batch.
<?php
/**
* TE Fleet Push: fan one Google Sheet out to many WordPress installs.
* Author: Ishan Karunaratne - https://techearl.com/manage-multiple-wordpress-sites-google-sheet
*/
/** Base64url-encode (JWT segments are unpadded and use -_ instead of +/). */
function te_b64url( $s ) { return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' ); }
/**
* Mint a short-lived service-account access token (JWT bearer grant).
* Scope defaults to read-only; pass the read-write Sheets scope to write back.
*/
function te_sheet_token( array $key, string $scope = 'https://www.googleapis.com/auth/spreadsheets.readonly' ) {
$now = time();
$header = te_b64url( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
$claims = te_b64url( wp_json_encode( array(
'iss' => $key['client_email'],
'scope' => $scope,
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
) ) );
$sig = '';
openssl_sign( "{$header}.{$claims}", $sig, $key['private_key'], 'sha256WithRSAEncryption' );
$jwt = "{$header}.{$claims}." . te_b64url( $sig );
$res = wp_remote_post( 'https://oauth2.googleapis.com/token', array(
'body' => array(
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
),
) );
return json_decode( wp_remote_retrieve_body( $res ), true )['access_token'] ?? '';
}
/** Read a sheet range over the Sheets API. Returns the raw rows, header included. */
function te_sheet_read( string $sheet_id, string $range, string $token ): array {
$url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s', rawurlencode( $sheet_id ), rawurlencode( $range ) );
$res = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => "Bearer {$token}" ) ) );
return json_decode( wp_remote_retrieve_body( $res ), true )['values'] ?? array();
}
/** Group the sheet rows by their target site. */
function te_group_by_site( array $rows ): array {
$by_site = array();
foreach ( $rows as $row ) {
$site = $row['website'] ?? '';
if ( '' !== $site ) { $by_site[ $site ][] = $row; }
}
return $by_site;
}
/** Push one site's batch. Returns [ ok, failed, unreachable ]. */
function te_push_site( array $cfg, array $rows ): array {
$ok = 0; $failed = 0;
foreach ( $rows as $row ) {
$ch = curl_init( $cfg['url'] . '/post' );
curl_setopt_array( $ch, array(
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3, // give up reaching a dead site fast
CURLOPT_TIMEOUT => 8,
CURLOPT_HTTPHEADER => array( 'X-TE-Key: ' . $cfg['key'] ),
CURLOPT_POSTFIELDS => http_build_query( array(
'post_id' => $row['post_id'],
'title' => $row['title'],
) ),
) );
$body = curl_exec( $ch );
$code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
$err = curl_errno( $ch );
curl_close( $ch );
// A connection error means the whole site is unreachable; stop hammering it.
if ( $err ) { return array( 'ok' => $ok, 'failed' => $failed, 'unreachable' => true ); }
200 === $code ? $ok++ : $failed++;
}
return array( 'ok' => $ok, 'failed' => $failed, 'unreachable' => false );
}
/** Fan the whole sheet out, printing one line per site. */
function te_fleet_push( array $sites, array $rows ): void {
$by_site = te_group_by_site( $rows );
$total_ok = 0; $reached = 0; $down = 0;
printf( "Reading %d changes from the sheet across %d sites...\n", count( $rows ), count( $by_site ) );
foreach ( $by_site as $site => $batch ) {
if ( ! isset( $sites[ $site ] ) ) { printf( " %-9s not in registry, skipped\n", $site ); continue; }
$r = te_push_site( $sites[ $site ], $batch );
if ( $r['unreachable'] ) {
printf( " %-9s UNREACHABLE (connection refused)\n", $site );
$down++;
} else {
printf( " %-9s %d updated, %d failed\n", $site, $r['ok'], $r['failed'] );
$total_ok += $r['ok'];
$reached++;
}
}
printf( "Done: %d updated across %d sites, %d site(s) unreachable.\n", $total_ok, $reached, $down );
}
// Read the sheet (website, post_id, title per row) and fan it out to the fleet.
$key = json_decode( file_get_contents( '/etc/te/sa-key.json' ), true );
$token = te_sheet_token( $key );
$rows = te_sheet_read( 'YOUR_SHEET_ID', 'Sheet1!A:C', $token );
$head = array_shift( $rows ); // drop the header row
$cols = array_flip( $head ); // map column name to index
$changes = array();
foreach ( $rows as $r ) {
$changes[] = array(
'website' => $r[ $cols['website'] ] ?? '',
'post_id' => $r[ $cols['post_id'] ] ?? '',
'title' => $r[ $cols['title'] ] ?? '',
);
}
te_fleet_push( $te_sites, $changes );The $changes come from te_sheet_read(), the Sheets values.get wrapper included at the top of this file; each row is keyed by the website, post_id, and title columns so the controller can group on website and forward the rest. Everything else is grouping and a loop. The same service-account helpers (te_b64url, te_sheet_token, te_sheet_read) are the ones used across the sheet cluster, so this controller copies and runs on its own.
Partial failure is the whole point
Run it against the fleet and the output is one line per site:

That third line is the reason the controller exists. events-c was unreachable, and the only consequence is one red line in the report. blog-a and shop-b still received every one of their changes. A naive script that pushed everything in one loop and threw on the first connection error would have stopped after the dead site and left the fleet half-updated, with no clear record of which sites got what. Three rules make the difference:
- Each site is independent. A failure on one site never aborts the others. The loop catches the error per site and keeps going.
- A connection error is the site, not the row.
curl_errnobeing set means the host did not answer at all, so there is no point trying the rest of that site's rows; mark the whole site unreachable and move on. An HTTP 404 or 500, by contrast, is a per-row problem (a missing post on a site that is otherwise up), counted as a failed row, not a dead site. - Fail fast on the dead one. A short
CURLOPT_CONNECTTIMEOUT(three seconds) means one unreachable site costs you three seconds, not thirty. Without it, a single down host can stall the entire run behind the default connect timeout.
The per-site report is the deliverable. After a fleet push you know exactly which sites are current and which need a second look, instead of "it printed an error somewhere."
Run it on a schedule
A fleet push is usually a cron job on the control machine, so the sheet becomes the thing the team edits and the fleet reconciles to it on its own.
# crontab on the control box: push the sheet to the fleet every 30 minutes
*/30 * * * * cd /opt/te-fleet && php fleet-push.php >/var/log/te-fleet.log 2>&1Because the underlying endpoint applies the change-only rule (a row already at its target value is a no-op), re-running the push every half hour is cheap and idempotent: only genuinely changed rows cause a write on any site.
Scaling past a handful of sites
The sequential version above is the right default and is plenty for a dozen sites. Two things to reach for as the fleet grows:
- Push sites in parallel. Sequential means the run takes as long as the sum of all sites. With
curl_multiyou fire all sites at once and the run takes as long as the slowest single site. Worth it past roughly twenty sites, or when some are geographically far. - Keep the secrets out of the registry file. The example pulls each key from
getenv(); in production those live in the control machine's environment or a secrets manager, so the registry checked into git holds only URLs and the env-var names. If a public write endpoint is doing anything you would hate to see forged or replayed, harden each site the way the secure write-endpoint article lays out before you point a fleet controller at it.
If your sites are genuinely one installation (WordPress Multisite, a single wp-content), you do not need any of this: a network-activated plugin and a switch_to_blog() loop reach every site in the network directly. The controller here is for the common case that gets called "multisite" loosely but is really N separate installs on N hosts, which is exactly what a website column and a fan-out controller are built for.
Sources
Authoritative references this article was fact-checked against.
- register_rest_route() - WordPress Developer Referencedeveloper.wordpress.org
- wp_remote_post() - WordPress Developer Referencedeveloper.wordpress.org
- curl_multi_init - PHP Manualphp.net
- spreadsheets.values.get - Google Sheets APIdevelopers.google.com
- Hooking WP-Cron Into the System Task Scheduler - WordPress Plugin Handbookdeveloper.wordpress.org





