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.
- 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.
<#
.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
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-SitePath | string | — | The WordPress root folder, the one with wp-config.php in it. Required. |
-BackupRoot | string | — | Where the dated backup folders go. Created if it isn't there. Required. |
-Credential | PSCredential | — | The database user name and password. Required. |
-DatabaseName | string | — | Overrides DB_NAME from wp-config.php. |
-DatabaseHost | string | — | Overrides DB_HOST from wp-config.php. host:port works. |
-MysqldumpPath | string | mysqldump | Full path to mysqldump.exe if it isn't on your PATH. |
-KeepLast | int | 0 | After a good backup, keep only this many backup folders for the site. 0 keeps them all. |
-SkipFiles | switch | — | 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) -WhatIfA 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 14Database 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 -SkipFilesWhat you'll see
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
- 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_NAMEandDB_HOSTare pulled out with a regular expression, and ahost:portvalue is split into the two parts mysqldump wants. Anything you pass on the command line wins. - Keep the password off the command line. It's written to a temporary option file, passed with
--defaults-extra-file, and deleted in afinallyblock whether the dump worked or not. - 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-transactiongets a consistent snapshot of InnoDB tables without locking the site. - Zip the folder. .NET's
ZipFile.CreateFromDirectorypicks up everything, dotfiles like.htaccessincluded, and handles archives over 2 GB. - Prune only after a good run. Old folders are removed only when this backup succeeded, and only ones that match the
site-yyyyMMdd-HHmmsspattern, so a neighboring site with a similar name is safe. Each removal goes through-WhatIf.
Take it further
- Pair it with a search-replaceWP-CLI's command for swapping one string for another across a whole WordPress database, usually an old domain for a new one. It fixes serialized data as it goes.More: Looking After a WordPress Database: Backups, Cleanup and Search-Replace Done Safely. Restoring a backup onto a new domain is a job for
wp search-replace, and looking after a WordPress database covers doing that without breaking serialized settings. - Check it before you schedule it. Checking PowerShell syntax without running it is the same parse check this script went through.
- Grab the uploads over HTTPS instead. If you can't reach the files directly, the REST API will list every media item with its URL.
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.