Automatically Upload WordPress Plugin and Theme Updates from macOS and Windows

If you manage multiple WordPress websites on your own Linux server, manually uploading premium plugin and theme updates to every site can become tedious.

A much faster workflow is to create a right-click action on your computer.

With this setup, you can:

  • Download a premium plugin or theme ZIP.
  • Right-click the ZIP in Finder or Windows Explorer.
  • Choose Upload Plugin to Server or Upload Theme to Server.
  • Upload the ZIP securely over SSH.
  • Let the server automatically detect the completed upload.
  • Update only sites where that plugin or theme already exists.
  • Clear WordPress caches.
  • Clear filesystem caches.
  • Restart PHP-FPM and Nginx if required.
  • Delete the processed ZIP from the server.

This is especially useful when managing many WordPress installations, including a mixture of traditional WordPress installations and Bedrock installations.

Using this process allows me to upload plugins or themes once directly from my desktop, and then my server auto updates every site on my server automatically.

This guide covers the desktop-side automation for both macOS and Windows.


How the Workflow Works

The basic workflow looks like this:

Plugin or Theme ZIP
        ↓
Right-click in Finder / Explorer
        ↓
Upload through SSH
        ↓
Temporary filename on server
        ↓
Upload completes
        ↓
Rename to .zip
        ↓
Linux watcher detects ZIP
        ↓
Plugin or theme updater runs
        ↓
Existing installations are updated
        ↓
Caches are cleared
        ↓
ZIP is deleted

One important design decision is that the uploaded file should initially use a temporary extension such as:

.uploading

and only be renamed to:

.zip

after the transfer finishes.

For example:

gravityforms.zip

might initially be uploaded as:

.gravityforms.zip.uploading

and then renamed on the server to:

gravityforms.zip

This prevents a server-side file watcher from trying to process a partially uploaded ZIP.


Server Requirements

This guide assumes your Linux server already has separate directories for incoming plugin and theme updates.

For example:

/root/plugins/
/root/themes/

The plugin watcher monitors:

/root/plugins/

while the theme watcher monitors:

/root/themes/

Your server might use different directories. If so, simply change the paths in the scripts below.

You will also need SSH access to the server.

For example:

[email protected]

or:

[email protected]

For automation, SSH key authentication is strongly recommended.

You should be able to run:

ssh [email protected]

without being prompted for the server account password every time.

You will need to update the instructions to match your server settings/credentials


Settings You Need to Customize

Throughout this guide you will see settings similar to:

REMOTE="[email protected]"

and:

REMOTE_DIR="/root/plugins"

Change these values for your own server.

For plugins:

REMOTE="[email protected]"
REMOTE_DIR="/root/plugins"

For themes:

REMOTE="[email protected]"
REMOTE_DIR="/root/themes"

If your SSH username is not root, use your actual account:

REMOTE="[email protected]"

If your server uses an IP address:

REMOTE="[email protected]"

If SSH runs on a non-standard port, such as port 2222, your scripts will also need the appropriate SSH options.

For example:

ssh -p 2222

and with rsync:

rsync -avP -e "ssh -p 2222"

macOS Setup

macOS has an excellent built-in tool called Automator that can create Finder Quick Actions.

The final result will allow you to right-click a ZIP file and choose:

Quick Actions
    → Upload Plugin to Server

or:

Quick Actions
    → Upload Theme to Server

Step 1: Create the Plugin Upload Command on macOS

Open Terminal.

Create:

sudo nano /usr/local/bin/upload_plugin

Paste:

#!/bin/bash

REMOTE="[email protected]"
REMOTE_DIR="/root/plugins"

if [ "$#" -eq 0 ]; then
    echo "Usage: upload_plugin plugin.zip [plugin2.zip ...]"
    exit 1
fi

FAILED=0

for FILE in "$@"; do

    if [ ! -f "$FILE" ]; then
        echo "ERROR: File not found: $FILE"
        FAILED=1
        continue
    fi

    case "$FILE" in
        *.zip|*.ZIP)
            ;;
        *)
            echo "ERROR: Not a ZIP file: $FILE"
            FAILED=1
            continue
            ;;
    esac

    FILENAME=$(basename "$FILE")

    REMOTE_TEMP="$REMOTE_DIR/.${FILENAME}.uploading"
    REMOTE_FINAL="$REMOTE_DIR/$FILENAME"

    echo
    echo "Uploading plugin: $FILENAME"

    if ! rsync \
        -avP \
        "$FILE" \
        "$REMOTE:$REMOTE_TEMP"; then

        echo "ERROR: Upload failed."
        FAILED=1
        continue
    fi

    if ! ssh "$REMOTE" \
        "mv -- '$REMOTE_TEMP' '$REMOTE_FINAL'"; then

        echo "ERROR: Could not finalize upload."
        FAILED=1
        continue
    fi

    echo "Plugin upload complete: $FILENAME"

done

exit "$FAILED"

Change:

REMOTE="[email protected]"

to your actual SSH server.

Then make the script executable:

sudo chmod 755 /usr/local/bin/upload_plugin

Test it from Terminal:

upload_plugin ~/Downloads/plugin.zip

If everything is configured correctly, the ZIP should appear in:

/root/plugins/

on your server.


Step 2: Create the Theme Upload Command on macOS

Create:

sudo nano /usr/local/bin/upload_theme

Paste:

#!/bin/bash

REMOTE="[email protected]"
REMOTE_DIR="/root/themes"

if [ "$#" -eq 0 ]; then
    echo "Usage: upload_theme theme.zip [theme2.zip ...]"
    exit 1
fi

FAILED=0

for FILE in "$@"; do

    if [ ! -f "$FILE" ]; then
        echo "ERROR: File not found: $FILE"
        FAILED=1
        continue
    fi

    case "$FILE" in
        *.zip|*.ZIP)
            ;;
        *)
            echo "ERROR: Not a ZIP file: $FILE"
            FAILED=1
            continue
            ;;
    esac

    FILENAME=$(basename "$FILE")

    REMOTE_TEMP="$REMOTE_DIR/.${FILENAME}.uploading"
    REMOTE_FINAL="$REMOTE_DIR/$FILENAME"

    echo
    echo "Uploading theme: $FILENAME"

    if ! rsync \
        -avP \
        "$FILE" \
        "$REMOTE:$REMOTE_TEMP"; then

        echo "ERROR: Upload failed."
        FAILED=1
        continue
    fi

    if ! ssh "$REMOTE" \
        "mv -- '$REMOTE_TEMP' '$REMOTE_FINAL'"; then

        echo "ERROR: Could not finalize upload."
        FAILED=1
        continue
    fi

    echo "Theme upload complete: $FILENAME"

done

exit "$FAILED"

Make it executable:

sudo chmod 755 /usr/local/bin/upload_theme

Test:

upload_theme ~/Downloads/theme.zip

Step 3: Create the Plugin Finder Quick Action

Open:

Applications → Automator

Choose:

New Document

Then choose:

Quick Action

At the top of the workflow configure:

Workflow receives current:
files or folders

in:
Finder

Search Automator actions for:

Run Shell Script

Drag Run Shell Script into the workflow.

Set:

Shell:
/bin/bash

Set:

Pass input:
as arguments

Paste:

#!/bin/bash

if /usr/local/bin/upload_plugin "$@"; then

    /usr/bin/osascript -e \
        'display notification "Plugin upload completed." with title "WordPress Plugin Upload"'

else

    /usr/bin/osascript -e \
        'display notification "Plugin upload failed." with title "WordPress Plugin Upload"'

    exit 1
fi

Save it as:

Upload Plugin to Server

You can now right-click a ZIP file in Finder and choose:

Quick Actions
    → Upload Plugin to Server

Step 4: Create the Theme Finder Quick Action

Create another Automator Quick Action.

Use the same settings:

Workflow receives:
files or folders

Application:
Finder

Shell:
/bin/bash

Pass input:
as arguments

Use:

#!/bin/bash

if /usr/local/bin/upload_theme "$@"; then

    /usr/bin/osascript -e \
        'display notification "Theme upload completed." with title "WordPress Theme Upload"'

else

    /usr/bin/osascript -e \
        'display notification "Theme upload failed." with title "WordPress Theme Upload"'

    exit 1
fi

Save it as:

Upload Theme to Server

You will now have two Finder Quick Actions:

Upload Plugin to Server
Upload Theme to Server

Both can also handle multiple selected ZIP files.


macOS SSH Keys

The Finder automation works best when SSH authentication does not require entering your server password.

If you do not already have an SSH key, create one:

ssh-keygen -t ed25519

Then install the public key on your server:

ssh-copy-id [email protected]

If ssh-copy-id is unavailable on your Mac, you can manually add the contents of:

~/.ssh/id_ed25519.pub

to:

~/.ssh/authorized_keys

on the server.

Test:

ssh [email protected]

Once that works without asking for the SSH account password, the Automator Quick Action should work without user interaction.


Windows Setup

Windows does not have Automator, but the same functionality can be created using:

PowerShell
+
Windows Explorer Send To

Modern Windows installations include the Microsoft OpenSSH client, which gives us both:

ssh.exe
scp.exe

Unlike macOS, Windows does not normally include rsync, so this example uses scp.

For uploading ZIP files, scp works perfectly well.


Step 1: Verify SSH on Windows

Open PowerShell and run:

ssh

You should see the OpenSSH usage information.

You can also verify:

Get-Command ssh
Get-Command scp

If Windows cannot find these commands, install OpenSSH Client through Windows Optional Features.


Step 2: Configure SSH Key Authentication on Windows

Create an SSH key:

ssh-keygen -t ed25519

The key is normally stored in:

C:\Users\YOUR_USERNAME\.ssh\

The public key will be:

id_ed25519.pub

Add that public key to the server’s:

~/.ssh/authorized_keys

Then test:

ssh [email protected]

The connection should work without requiring the SSH account password.


Step 3: Create the Windows Plugin Upload Script

Create a folder such as:

C:\Scripts

Create:

C:\Scripts\Upload-Plugin.ps1

Paste:

param(
    [Parameter(ValueFromRemainingArguments = $true)]
    [string[]]$Files
)

$Remote = "[email protected]"
$RemoteDir = "/root/plugins"

if (-not $Files -or $Files.Count -eq 0) {
    Write-Host "Usage: Upload-Plugin.ps1 plugin.zip"
    exit 1
}

$Failed = $false

foreach ($File in $Files) {

    if (-not (Test-Path -LiteralPath $File -PathType Leaf)) {
        Write-Host "ERROR: File not found: $File"
        $Failed = $true
        continue
    }

    if ([System.IO.Path]::GetExtension($File) -ne ".zip") {
        Write-Host "ERROR: Not a ZIP file: $File"
        $Failed = $true
        continue
    }

    $Filename = [System.IO.Path]::GetFileName($File)

    $RemoteTemp = "$RemoteDir/.$Filename.uploading"
    $RemoteFinal = "$RemoteDir/$Filename"

    Write-Host ""
    Write-Host "Uploading plugin: $Filename"

    & scp.exe $File "${Remote}:${RemoteTemp}"

    if ($LASTEXITCODE -ne 0) {
        Write-Host "ERROR: Upload failed."
        $Failed = $true
        continue
    }

    & ssh.exe $Remote "mv -- '$RemoteTemp' '$RemoteFinal'"

    if ($LASTEXITCODE -ne 0) {
        Write-Host "ERROR: Could not finalize upload."
        $Failed = $true
        continue
    }

    Write-Host "Plugin upload complete: $Filename"
}

if ($Failed) {
    exit 1
}

exit 0

Change:

$Remote = "[email protected]"

to your server.


Step 4: Create the Windows Theme Upload Script

Create:

C:\Scripts\Upload-Theme.ps1

Use:

param(
    [Parameter(ValueFromRemainingArguments = $true)]
    [string[]]$Files
)

$Remote = "[email protected]"
$RemoteDir = "/root/themes"

if (-not $Files -or $Files.Count -eq 0) {
    Write-Host "Usage: Upload-Theme.ps1 theme.zip"
    exit 1
}

$Failed = $false

foreach ($File in $Files) {

    if (-not (Test-Path -LiteralPath $File -PathType Leaf)) {
        Write-Host "ERROR: File not found: $File"
        $Failed = $true
        continue
    }

    if ([System.IO.Path]::GetExtension($File) -ne ".zip") {
        Write-Host "ERROR: Not a ZIP file: $File"
        $Failed = $true
        continue
    }

    $Filename = [System.IO.Path]::GetFileName($File)

    $RemoteTemp = "$RemoteDir/.$Filename.uploading"
    $RemoteFinal = "$RemoteDir/$Filename"

    Write-Host ""
    Write-Host "Uploading theme: $Filename"

    & scp.exe $File "${Remote}:${RemoteTemp}"

    if ($LASTEXITCODE -ne 0) {
        Write-Host "ERROR: Upload failed."
        $Failed = $true
        continue
    }

    & ssh.exe $Remote "mv -- '$RemoteTemp' '$RemoteFinal'"

    if ($LASTEXITCODE -ne 0) {
        Write-Host "ERROR: Could not finalize upload."
        $Failed = $true
        continue
    }

    Write-Host "Theme upload complete: $Filename"
}

if ($Failed) {
    exit 1
}

exit 0

Step 5: Create Windows Right-Click Actions

One of the easiest ways to add custom commands to Windows Explorer is through the user’s Send To folder.

Press:

Windows + R

Enter:

shell:sendto

Press Enter.

Windows Explorer will open your personal SendTo directory.


Create the Plugin Send To Command

Create a file named:

Upload Plugin to Server.cmd

Inside it put:

@echo off

powershell.exe -NoProfile -ExecutionPolicy Bypass ^
    -File "C:\Scripts\Upload-Plugin.ps1" %*

if errorlevel 1 (
    echo.
    echo Plugin upload failed.
    pause
)

Save it inside the SendTo folder.

Now you can right-click a ZIP file and choose:

Show more options
    → Send to
    → Upload Plugin to Server

Depending on your Windows version, Send to may appear directly in the classic context menu.


Create the Theme Send To Command

Create:

Upload Theme to Server.cmd

Use:

@echo off

powershell.exe -NoProfile -ExecutionPolicy Bypass ^
    -File "C:\Scripts\Upload-Theme.ps1" %*

if errorlevel 1 (
    echo.
    echo Theme upload failed.
    pause
)

You will now have:

Send to
    → Upload Plugin to Server
    → Upload Theme to Server

Supporting Multiple Selected Files on Windows

Both PowerShell scripts accept multiple filenames.

That means you can select:

gravityforms.zip
advanced-custom-fields-pro.zip
admin-columns-pro.zip

and send all three to:

Upload Plugin to Server

The files will be uploaded one at a time.

The same works with multiple theme ZIP files.


Why Plugin ZIP Filenames Should Not Be Trusted

Premium plugin downloads frequently include version numbers or vendor-specific filenames.

For example:

admin-columns-pro-6.7.1.zip

might contain:

admin-columns-pro/

and its WordPress plugin header may say:

Plugin Name: Admin Columns Pro

Because of this, the server-side plugin updater should not assume:

ZIP filename = installed plugin folder

A better updater:

  1. Extracts the ZIP.
  2. Locates the main PHP file containing:
Plugin Name:
  1. Determines the actual plugin directory.
  2. Looks for that directory on each WordPress site.
  3. If the folder name differs, compares the actual WordPress Plugin Name: header.
  4. Updates the matching existing installation.

This allows files such as:

plugin-pro-latest.zip
plugin-pro-6.4.7.zip
vendor-download-12345.zip

to still update the correct WordPress plugin.


Themes Work Slightly Differently

WordPress themes normally have:

style.css

in the root of the theme directory.

The theme updater can therefore inspect:

style.css

to identify a valid theme package.

A typical theme looks like:

generatepress/
    style.css
    functions.php
    assets/

The updater should only replace:

wp-content/themes/generatepress/

or, with Bedrock:

app/themes/generatepress/

if that theme already exists.

A missing theme should not automatically be installed unless that behavior is explicitly desired.


Traditional WordPress vs Bedrock

A traditional WordPress installation generally uses:

wp-content/plugins/
wp-content/themes/

For example:

/var/www/sites/example.com/www/wp-content/plugins/
/var/www/sites/example.com/www/wp-content/themes/

A Bedrock installation typically uses:

app/plugins/
app/themes/

For example:

/var/www/sites/example.com/web/app/plugins/
/var/www/sites/example.com/web/app/themes/

Your server updater should search both structures.


Clearing WordPress Cache After an Update

If a plugin or theme is updated, it is useful to run:

wp cache flush

for that WordPress installation.

When websites run under separate Linux accounts, WP-CLI should ideally run as the account that owns that website.

For example:

sudo -u site_example_com -H \
    wp cache flush \
    --path="/var/www/sites/example.com/www"

A Bedrock installation may require a different WP-CLI path depending on its directory layout.

The updater should remember every site that was successfully changed and flush each site only once.

For example, if three plugins are updated on:

example.com

there is no need to flush that site’s cache three times.

Flush it once after all plugin updates have finished.


Clearing a Filesystem Cache Directory

Some server configurations also have a directory such as:

/var/www/sites/example.com/redis_cache/

If you need to empty this directory after updates, delete its contents rather than deleting the directory itself.

For example:

find "/var/www/sites/example.com/redis_cache" \
    -mindepth 1 \
    -maxdepth 1 \
    -exec rm -rf -- {} +

This preserves the parent directory.

That is important when the directory has custom:

ownership
group permissions
SGID
ACLs

Deleting and recreating the entire directory could lose those settings.


Restarting PHP-FPM

If your server runs multiple PHP versions, you can restart all installed PHP-FPM services with:

systemctl list-units \
    --type=service \
    --all \
    "php*-fpm.service" \
    --no-legend |
awk '{print $1}' |
xargs -r -I{} systemctl restart {}

This works well on servers hosting sites across several PHP versions.

For example:

php8.2-fpm.service
php8.3-fpm.service
php8.4-fpm.service
php8.5-fpm.service

Restarting Nginx

After processing updates:

systemctl restart nginx

If your plugin or theme changes do not require Nginx to restart, this step can be omitted.

In many environments, restarting PHP-FPM and clearing caches is sufficient.


Recommended Server Directories

A simple layout is:

/root/
    plugins/
    themes/

Use:

/root/plugins/

for plugin ZIPs.

Use:

/root/themes/

for theme ZIPs.

Keep the watchers separate so that uploading a theme never triggers the plugin updater and vice versa.


Recommended Linux Commands

For convenience, you can expose your updater scripts using commands such as:

update_plugins
update_themes

For example:

ln -s \
    /usr/local/sbin/update_plugins_from_zips \
    /usr/local/sbin/update_plugins

and:

ln -s \
    /usr/local/sbin/update_themes_from_zips \
    /usr/local/sbin/update_themes

You can then manually test:

update_plugins --test

or:

update_themes --test

before allowing the automatic file watcher to process uploads.


Testing Before Going Live

Always test your updater scripts before relying on the automatic workflow.

A good plugin test is:

update_plugins --test

A good theme test is:

update_themes --test

The test mode should display:

which sites would be updated
which folder would be replaced
which files rsync would change
which ownership would be applied
which permissions would be applied

but should not:

modify files
delete ZIPs
flush caches
delete cache files
restart PHP
restart Nginx

Once the dry-run output looks correct, run the updater normally.


Security Notes

This workflow gives your workstation the ability to place files on your web server, so secure it appropriately.

SSH keys should be protected, and root SSH access may not be appropriate for every environment.

A more restrictive configuration could use a dedicated deployment user that only has permission to upload files to:

/root/plugins/

and:

/root/themes/

or equivalent deployment directories.

That account could then use tightly controlled sudo permissions for only the commands required by the updater.

You should also validate uploaded ZIP files before processing them.

For example:

unzip -tq plugin.zip

can verify that the ZIP itself is structurally valid before extracting it.


Final Workflow

Once configured, updating premium WordPress software becomes extremely simple.

On macOS:

Download ZIP
    ↓
Right-click
    ↓
Quick Actions
    ↓
Upload Plugin to Server

or:

Upload Theme to Server

On Windows:

Download ZIP
    ↓
Right-click
    ↓
Send to
    ↓
Upload Plugin to Server

or:

Upload Theme to Server

The server handles everything else.

For administrators managing a large collection of WordPress websites, this removes most of the repetitive work involved in distributing premium plugin and theme updates while still ensuring that software is only updated on sites where it is already installed.

Leave a Comment

You must be logged in to post a comment.