Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Backing Up a WordPress Site from Windows with PowerShell

One script that dumps a WordPress database and zips the site files into a dated folder, with the password kept off the command line and a -WhatIf that really works.

AT A GLANCEBackup-WordPressSite.ps1
What it does
Reads the database name and host from wp-config.php, dumps the database with mysqldump, zips the whole WordPress folder, and puts both in a dated folder. Can prune old backup folders afterward.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • mysqldump from the MySQL or MariaDB client tools
  • No modules
Permissions
Read access to the site folder, write access to the backup folder, and a database user that can SELECT and LOCK TABLES on the WordPress database.
Runs on
Windows 10/11, Windows Server 2016+
Tested
Parse-checked and run in Windows PowerShell 5.1 and PowerShell 7 against a test site, with mysqldump mocked

Part 3 of the thread The WordPress toolbox

My old WordPress notes had a backup function tucked at the bottom of a long page of PowerShell. It copied the site folder and then ran mysqldump through cmd with the database password typed right into the command line, which means anyone who could list processes on that box could read it.

This is that function grown up into a proper script. It reads the database name and host out of wp-config.php, takes the password as a credential, and hands it to mysqldump through a temporary option file that's deleted as soon as the dump finishes. The files go into a zip next to the dump, in a folder stamped with the date and time.

It's for sites where you can reach the files and the database from a Windows machine: a local dev copy, a box on your own network, or a host that lets you connect to MySQL remotely. If you only have SSH, wp db export on the server does the database half in one line.

Backup-WordPressSite.ps1Download
<#
.SYNOPSIS
    Backs up a WordPress site: a zip of the files and a SQL dump of the database.
.DESCRIPTION
    Creates a dated folder under BackupRoot, dumps the database with mysqldump, and zips the
    WordPress folder next to it. The database name and host are read from wp-config.php unless
    you pass them. The database password comes from a PSCredential and reaches mysqldump through
    a temporary option file, so it never appears on a command line. Supports -WhatIf.
.PARAMETER SitePath
    The WordPress root folder, the one with wp-config.php in it.
.PARAMETER BackupRoot
    The folder the dated backup folders go into. Created if it doesn't exist.
.PARAMETER Credential
    Database user name and password.
.PARAMETER DatabaseName
    Overrides DB_NAME from wp-config.php.
.PARAMETER DatabaseHost
    Overrides DB_HOST from wp-config.php. A host:port value is split for you.
.PARAMETER MysqldumpPath
    Path to mysqldump.exe if it isn't on your PATH.
.PARAMETER KeepLast
    After a successful backup, remove older backup folders for this site so only this many remain. 0 keeps everything.
.PARAMETER SkipFiles
    Dump the database only.
.EXAMPLE
    .\Backup-WordPressSite.ps1 -SitePath C:\Sites\example -BackupRoot \\NAS01\Backups\example -Credential (Get-Credential) -WhatIf
.EXAMPLE
    .\Backup-WordPressSite.ps1 -SitePath C:\Sites\example -BackupRoot \\NAS01\Backups\example -Credential $dbCred -KeepLast 14
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -LiteralPath (Join-Path $_ 'wp-config.php') -PathType Leaf })]
    [string]$SitePath,

    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$BackupRoot,

    [Parameter(Mandatory)]
    [System.Management.Automation.PSCredential]$Credential,

    [ValidatePattern('^[A-Za-z0-9_$-]+$')]
    [string]$DatabaseName,

    [ValidateNotNullOrEmpty()]
    [string]$DatabaseHost,

    [ValidateNotNullOrEmpty()]
    [string]$MysqldumpPath = 'mysqldump',

    [ValidateRange(0, 365)]
    [int]$KeepLast = 0,

    [switch]$SkipFiles
)

$ErrorActionPreference = 'Stop'

function Get-WpConfigValue {
    param([string]$Text, [string]$Name)
    $pattern = "define\(\s*['""]$Name['""]\s*,\s*['""]([^'""]*)['""]\s*\)"
    $match = [regex]::Match($Text, $pattern)
    if ($match.Success) { $match.Groups[1].Value }
}

$SitePath = (Resolve-Path -LiteralPath $SitePath).ProviderPath
$config = Get-Content -LiteralPath (Join-Path $SitePath 'wp-config.php') -Raw
if (-not $DatabaseName) { $DatabaseName = Get-WpConfigValue -Text $config -Name 'DB_NAME' }
if (-not $DatabaseHost) { $DatabaseHost = Get-WpConfigValue -Text $config -Name 'DB_HOST' }
if (-not $DatabaseName) { throw 'No DB_NAME found in wp-config.php. Pass -DatabaseName.' }
if (-not $DatabaseHost) { $DatabaseHost = 'localhost' }

$port = 3306
if ($DatabaseHost -match '^(?<h>[^:]+):(?<p>\d+)$') {
    $DatabaseHost = $Matches.h
    $port = [int]$Matches.p
}

$siteName = Split-Path -Path $SitePath -Leaf
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$target = Join-Path $BackupRoot "$siteName-$stamp"

$result = [pscustomobject]@{
    Site         = $siteName
    Database     = $DatabaseName
    BackupFolder = $target
    DatabaseDump = $null
    FilesZip     = $null
    SizeMB       = 0
    Removed      = 0
    Error        = $null
}

if (-not $PSCmdlet.ShouldProcess($target, "Back up $siteName (database $DatabaseName on $DatabaseHost)")) {
    return $result
}

if (-not (Get-Command -Name $MysqldumpPath -ErrorAction SilentlyContinue)) {
    throw "Can't find mysqldump at '$MysqldumpPath'. Install the MySQL or MariaDB client tools, or pass -MysqldumpPath."
}

$optionFile = $null
try {
    New-Item -ItemType Directory -Path $target -Force | Out-Null

    # The password goes in a temporary option file instead of on the command line,
    # where anyone who can list processes could read it.
    $optionFile = [System.IO.Path]::GetTempFileName()
    $plain = $Credential.GetNetworkCredential().Password
    if ($plain.Contains('"')) { throw 'The database password contains a double quote, which the option file can''t hold. Change the password or use a login path.' }
    $plain = $plain.Replace('\', '\\')
    [System.IO.File]::WriteAllText($optionFile, "[client]`nuser=$($Credential.UserName)`npassword=""$plain""`n")
    $plain = $null

    $dumpFile = Join-Path $target "$DatabaseName.sql"
    $dumpArgs = @(
        "--defaults-extra-file=$optionFile"
        "--host=$DatabaseHost"
        "--port=$port"
        '--single-transaction'
        '--quick'
        '--default-character-set=utf8mb4'
        "--result-file=$dumpFile"
        $DatabaseName
    )
    Write-Verbose "Dumping $DatabaseName from ${DatabaseHost}:$port"
    & $MysqldumpPath @dumpArgs
    if ($LASTEXITCODE -ne 0) { throw "mysqldump exited with code $LASTEXITCODE." }
    $result.DatabaseDump = $dumpFile

    if (-not $SkipFiles) {
        Add-Type -AssemblyName System.IO.Compression.FileSystem
        $zipFile = Join-Path $target "$siteName-files.zip"
        Write-Verbose "Zipping $SitePath"
        [System.IO.Compression.ZipFile]::CreateFromDirectory($SitePath, $zipFile, [System.IO.Compression.CompressionLevel]::Optimal, $false)
        $result.FilesZip = $zipFile
    }

    $bytes = (Get-ChildItem -LiteralPath $target -File | Measure-Object -Property Length -Sum).Sum
    $result.SizeMB = [math]::Round($bytes / 1MB, 1)
}
catch {
    $result.Error = $_.Exception.Message
}
finally {
    if ($optionFile -and (Test-Path -LiteralPath $optionFile)) { Remove-Item -LiteralPath $optionFile -Force -Confirm:$false }
}

if (-not $result.Error -and $KeepLast -gt 0) {
    $pattern = '^' + [regex]::Escape($siteName) + '-\d{8}-\d{6}$'
    $old = @(Get-ChildItem -LiteralPath $BackupRoot -Directory | Where-Object Name -match $pattern | Sort-Object Name -Descending | Select-Object -Skip $KeepLast)
    foreach ($folder in $old) {
        if ($PSCmdlet.ShouldProcess($folder.FullName, 'Remove old backup')) {
            try {
                Remove-Item -LiteralPath $folder.FullName -Recurse -Force -Confirm:$false
                $result.Removed++
            }
            catch {
                Write-Warning "Couldn't remove $($folder.Name): $($_.Exception.Message)"
            }
        }
    }
}

$result

Parameters

ParameterTypeDefaultWhat it's for
-SitePathstring—The WordPress root folder, the one with wp-config.php in it. Required.
-BackupRootstring—Where the dated backup folders go. Created if it isn't there. Required.
-CredentialPSCredential—The database user name and password. Required.
-DatabaseNamestring—Overrides DB_NAME from wp-config.php.
-DatabaseHoststring—Overrides DB_HOST from wp-config.php. host:port works.
-MysqldumpPathstringmysqldumpFull path to mysqldump.exe if it isn't on your PATH.
-KeepLastint0After a good backup, keep only this many backup folders for the site. 0 keeps them all.
-SkipFilesswitch—Dump the database only.

Run it

See what it would do without writing anything.

.\Backup-WordPressSite.ps1 -SitePath C:\Sites\example -BackupRoot \\NAS01\Backups\example -Credential (Get-Credential) -WhatIf

A normal run that keeps the last two weeks of nightly backups.

.\Backup-WordPressSite.ps1 -SitePath C:\Sites\example -BackupRoot \\NAS01\Backups\example -Credential $dbCred -KeepLast 14

Database only, from a server that isn't named in wp-config.php.

.\Backup-WordPressSite.ps1 -SitePath C:\Sites\example -BackupRoot D:\Backups -Credential $dbCred -DatabaseHost db01.example.com -SkipFiles

What you'll see

Example outputvalues are illustrative
Site         : example
Database     : example_wp
BackupFolder : \\NAS01\Backups\example\example-20260930-020001
DatabaseDump : \\NAS01\Backups\example\example-20260930-020001\example_wp.sql
FilesZip     : \\NAS01\Backups\example\example-20260930-020001\example-files.zip
SizeMB       : 412.6
Removed      : 1
Error        :

How it works

  1. Read wp-config.phpThe file at the top of every WordPress install. It holds the database login, the secret keys and switches like debug mode, so guard it.More: The Dev Toolbox for a WordPress Build. DB_NAME and DB_HOST are pulled out with a regular expression, and a host:port value is split into the two parts mysqldump wants. Anything you pass on the command line wins.
  2. Keep the password off the command line. It's written to a temporary option file, passed with --defaults-extra-file, and deleted in a finally block whether the dump worked or not.
  3. Dump with --result-file. Redirecting mysqldump with > in Windows PowerShell 5.1 writes UTF-16, which MySQL won't import cleanly. Letting mysqldump write the file itself avoids that. --single-transaction gets a consistent snapshot of InnoDB tables without locking the site.
  4. Zip the folder. .NET's ZipFile.CreateFromDirectory picks up everything, dotfiles like .htaccess included, and handles archives over 2 GB.
  5. Prune only after a good run. Old folders are removed only when this backup succeeded, and only ones that match the site-yyyyMMdd-HHmmss pattern, so a neighboring site with a similar name is safe. Each removal goes through -WhatIf.

Take it further

Things that'll trip you up

  • A backup you haven't restored is a guess. Every so often, load the .sql into a scratch database and unzip the files into a test folder. That's the only way to know the backup is any good.
  • The zip holds wp-config.php. That file has the database password and the site's secret keys in it. Keep the backup folder somewhere only you can read, and don't leave copies in a shared cloud folder.
  • Double quotes in the password. The MySQL option file can't hold a password with a double quote in it, so the script stops rather than guess. Backslashes are escaped for you.
  • Scheduling it. A scheduled task can't answer Get-Credential. Store the credential for the account the task runs as (Export-Clixml works per user and per machine) or use the SecretManagement module, and load it before calling the script.