TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Reliable Zip Backups in PowerShell: Atomic, Validated, and Fast with .NET ZipFile

You dont need external tools to create dependable, repeatable zip backups on Windows or cross-platform with PowerShell 7. By leaning on .NETs System.IO.Compression.ZipFile, validating paths up front, writing to a temporary file, and renaming atomically, you can produce smaller backups, faster restores, safer archives, and predictable runs.

Quick, reliable zip backups without external tools

Heres a minimal, production-friendly pattern that validates input, names archives with timestamps, writes to a .part file, and atomically moves it into place when complete:

$src = 'C:\data\reports'
$outDir = 'C:\backups'
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$zip = Join-Path -Path $outDir -ChildPath ("reports_{0}.zip" -f $stamp)
$temp = "$zip.part"

if (-not (Test-Path -Path $src -PathType Container)) { throw ("Missing source: {0}" -f $src) }
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue

if (Test-Path -Path $temp) { Remove-Item -Path $temp -Force }
[IO.Compression.ZipFile]::CreateFromDirectory($src, $temp, [IO.Compression.CompressionLevel]::Optimal, $false)
Move-Item -Path $temp -Destination $zip -Force
$sizeMB = [math]::Round((Get-Item -Path $zip).Length/1MB,2)
Write-Host ("Saved -> {0}  Size={1} MB" -f $zip, $sizeMB)

Why this is reliable:

  • Validation first: fail fast if the source doesnt exist.
  • Timestamped names: predictable, sortable filenames.
  • Atomic rename: create to .part, then Move-Item to finalize; readers never see half-written files.
  • Compression level: use Optimal for good size/performance trade-offs.

What you get: smaller backups, faster restores, safer archives, predictable runs.

Production-grade function: New-ZipBackup

Wrap the pattern in a reusable function with verification and retention. It returns a status object you can log or ship to your monitoring.

Features

  • Path validation and automatic output directory creation
  • Atomic writes via .part temp file and in-place rename
  • Optional archive verification (open and read a few bytes of each entry)
  • Compression level toggle: Optimal, Fastest, NoCompression
  • Retention: keep the last N backups per prefix
function New-ZipBackup {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [string]$Source,

        [Parameter(Mandatory)]
        [string]$OutputDir,

        [string]$Prefix = (Split-Path -Path $Source -Leaf),
        [ValidateSet('Optimal','Fastest','NoCompression')]
        [string]$Compression = 'Optimal',

        [int]$Keep = 7,
        [switch]$Verify,
        [switch]$IncludeBaseDirectory
    )

    $ErrorActionPreference = 'Stop'
    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null

    switch ($Compression) {
        'Optimal'       { $level = [IO.Compression.CompressionLevel]::Optimal }
        'Fastest'       { $level = [IO.Compression.CompressionLevel]::Fastest }
        'NoCompression' { $level = [IO.Compression.CompressionLevel]::NoCompression }
    }

    Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue

    $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
    $zip = Join-Path $OutputDir ("{0}_{1}.zip" -f $Prefix, $stamp)
    $temp = "$zip.part"

    if (Test-Path $temp) { Remove-Item $temp -Force }

    [IO.Compression.ZipFile]::CreateFromDirectory($Source, $temp, $level, $IncludeBaseDirectory.IsPresent)

    Move-Item -Path $temp -Destination $zip -Force

    if ($Verify) {
        $fs = [IO.File]::OpenRead($zip)
        try {
            $archive = New-Object IO.Compression.ZipArchive($fs, [IO.Compression.ZipArchiveMode]::Read, $false)
            foreach ($entry in $archive.Entries) {
                if ($entry.Length -gt 0) {
                    $s = $entry.Open()
                    $buffer = New-Object byte[] ([math]::Min(4096, [int]$entry.Length))
                    [void]$s.Read($buffer, 0, $buffer.Length)
                    $s.Dispose()
                }
            }
        }
        finally {
            $fs.Dispose()
        }
    }

    if ($Keep -gt 0) {
        Get-ChildItem -Path $OutputDir -Filter ("{0}_*.zip" -f $Prefix) |
            Sort-Object LastWriteTime -Descending |
            Select-Object -Skip $Keep |
            Remove-Item -Force -ErrorAction SilentlyContinue
    }

    $sizeMB = [math]::Round((Get-Item -Path $zip).Length / 1MB, 2)

    [pscustomobject]@{
        Path        = $zip
        SizeMB      = $sizeMB
        Entries     = (& {
            $fs = [IO.File]::OpenRead($zip)
            try { (New-Object IO.Compression.ZipArchive($fs, [IO.Compression.ZipArchiveMode]::Read, $false)).Entries.Count }
            finally { $fs.Dispose() }
        })
        Compression = $Compression
        Verified    = $Verify.IsPresent
        CreatedAt   = Get-Date
    }
}

Usage examples:

# Nightly snapshot with verification and 14-file retention
New-ZipBackup -Source 'C:\data\reports' -OutputDir 'C:\backups' -Verify -Keep 14

# Faster backups when space is cheap
New-ZipBackup -Source 'D:\logs' -OutputDir 'E:\bk' -Compression Fastest -Keep 3

# Include the top-level folder inside the zip
New-ZipBackup -Source 'C:\projects\app' -OutputDir 'C:\bk' -IncludeBaseDirectory

Verifying archives

The verification path reopens the zip and tries reading a small portion of each entry. This catches truncated central directories or incomplete writes. For deeper validation, you can hash files pre- and post-restore, but the lightweight read check is usually enough for CI, scheduled tasks, and nightly jobs.

Retention and naming

The timestamped name ensures natural ordering. The retention block keeps the newest N files per prefix and removes older ones, preventing slow creep of storage usage.

Advanced techniques and operations

Custom exclusions (no external tools)

CreateFromDirectory zips everything under a source path. For exclusions (e.g., node_modules, temp), build the archive manually with ZipArchive and filter the file list:

$src  = 'C:\data\reports'
$out  = 'C:\backups\reports_custom.zip'
$temp = "$out.part"
Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue
if (Test-Path $temp) { Remove-Item $temp -Force }

$fs = [IO.File]::Open($temp, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
try {
    $archive = [IO.Compression.ZipArchive]::new($fs, [IO.Compression.ZipArchiveMode]::Create, $true)
    Get-ChildItem -Path $src -Recurse -File |
      Where-Object { $_.FullName -notmatch '\\node_modules\\|\\temp\\|\.bak$' } |
      ForEach-Object {
        $rel = [IO.Path]::GetRelativePath($src, $_.FullName)
        $entry = $archive.CreateEntry($rel, [IO.Compression.CompressionLevel]::Optimal)
        $in = [IO.File]::OpenRead($_.FullName)
        $outStream = $entry.Open()
        try { $in.CopyTo($outStream) } finally { $in.Dispose(); $outStream.Dispose() }
      }
}
finally {
    if ($archive) { $archive.Dispose() }
    $fs.Dispose()
}
Move-Item $temp $out -Force

This preserves our atomic write pattern while giving you full control over which files enter the archive.

Restoring quickly

Restores are straightforward:

Add-Type -AssemblyName 'System.IO.Compression.FileSystem'
$zip  = 'C:\backups\reports_20250118-221530.zip'
$dest = 'C:\restore\reports'
New-Item -ItemType Directory -Path $dest -Force | Out-Null
[IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest)

On PowerShell 7/.NET 6+, theres an overload that can overwrite existing files: ExtractToDirectory($zip, $dest, $true).

Scheduling and automation

Run the job nightly with Task Scheduler. For Windows PowerShell, use powershell.exe; for PowerShell 7+, use pwsh.exe:

$action  = New-ScheduledTaskAction -Execute 'pwsh.exe' -Argument '-NoProfile -File C:\ops\zip-backup.ps1'
$trigger = New-ScheduledTaskTrigger -Daily -At 02:00
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName 'ReportsZipBackup' -Action $action -Trigger $trigger -Settings $settings -Description 'Nightly zip backup for reports'

Performance tips

  • Compression level: Fastest reduces CPU for large log sets; Optimal is good for source code and documents.
  • Local temp: Write the .part to the same volume as the final zip to keep the rename atomic and fast.
  • Exclude churn: Exclude caches (node_modules, bin, obj, temp files) to shrink archives and speed up both backup and restore.
  • Parallelism: Run multiple independent sources in parallel jobs, but avoid saturating a slow disk.

Security and reliability considerations

  • Encrypt at rest: ZipFile doesnt provide password-protected archives. Use volume encryption (BitLocker), EFS, or copy resulting zips into an encrypted store.
  • Permissions: Tighten ACLs on the backup directory so only your service account can write/read.
  • Atomicity: The rename is atomic within the same volume. Dont write the .part on a different drive than the final destination.
  • Path length: On older Windows, enable long path support or avoid extremely long subpaths. PowerShell 7/.NET 6 handles long paths better.
  • Monitoring: Capture the functions returned object and log size, entry count, and success to your observability stack.

Checklist you can adopt today

  1. Validate your source directory and pre-create the output path.
  2. Name files with yyyyMMdd-HHmmss timestamps and a stable prefix.
  3. Write to .part first, then Move-Item to the final name.
  4. Optionally verify by reopening the zip and sampling entries.
  5. Implement retention so old backups dont silently fill disks.
  6. Exclude ephemeral files to reduce size and improve restore speed.
  7. Schedule the job and monitor results for predictable, repeatable runs.

With these patterns, youll build dependable zip backups entirely in PowerShell, without external tools, and with the right operational guarantees for CI/CD and nightly jobs.

If you want to go deeper into patterns like atomic file ops, robust error handling, and advanced scripting, check out the PowerShell Advanced CookBook: https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

#PowerShell #Scripting #Compression #Backup #FileIO #Automation #PowerShellCookbook

← All Posts Home →