Back Up a 7 Days to Die Dedicated Server to Amazon S3 with PowerShell
The world lands in S3 every morning as an archive that restores onto a fresh server, and the task’s result says whether that morning’s copy arrived.

On this page
TL;DR
A daily scheduled task runs a PowerShell script as SYSTEM. The script zips
Saves and serverconfig.xml, writes the archive to S3 with
Write-S3Object as STANDARD_IA, and keeps three archives locally and
thirty days in the bucket. Every part is stock: Compress-Archive, AWS
Tools for PowerShell, Task Scheduler.
SYSTEM has its own profile, so install the modules and write the credentials where it can reach them, not where your own session puts them.
I’ve played 7 Days to Die since it came out, and years ago I set up my own dedicated server, on the Windows Server 2019 VM it still runs on. For a long time I updated and managed it by hand. Then one day I realized I knew how to do it better, and started automating it.
The big part was updates. When it’s time to play, the server should already be up to date. There’s nothing worse than getting ready to play and hitting the update wait.
The goal is a server I can replace without a second thought, starting with a move to Windows Server 2025, and backups are the first step. Steam can reinstall the game on any machine, but it can’t give back the world: the map, the players’ bases and everything in their inventories. So every morning, ahead of the update window, a scheduled task zips that part and copies it to S3, where a new server can pick it up.
S3 because I already use it and like it. Nothing about the backup needs S3. The zip, the schedule, the local retention and the logging work the same with another drive or a share on a NAS or SAN, and Use a different destination covers what changes. The VM runs outside AWS, which is why credentials take a step of their own: there’s no instance role to lean on.
What to back up
Two things hold state. The game writes the world, the players and their
inventories under Saves, and the server’s settings live in
serverconfig.xml beside it. On this server both are under
C:\7DaysToDieServer. A server installed with the defaults keeps Saves
under %APPDATA%\7DaysToDie of the account that runs the game instead,
unless UserDataFolder in serverconfig.xml moves it. The script looks for
Saves beside serverconfig.xml and stops when it isn’t there; on a default
install, pass the full path to Saves as -SavesDir in step 5. Everything
else in the server directory is the Steam installation, which SteamCMD reproduces on demand, and it’s most of the disk.
The backup takes those two and nothing else, so the zip comes out at about
23 MB.
This server runs Navezgane, the shipped map. A random-gen world also keeps its
terrain in GeneratedWorlds, beside Saves, and a new server can’t rebuild
the world without it, so add that directory to the archive and the restore.
1. Install AWS Tools where SYSTEM can see them
The script reaches S3 through AWS Tools for PowerShell rather than the CLI.
-Scope AllUsers is the part that matters. A per-user install lands under
your own Documents folder, which SYSTEM cannot see, so the scheduled run fails
to import the modules while your own runs work.
On a fresh Server 2019 the gallery needs the NuGet provider first. The TLS check before it adds TLS 1.2 only when the session has neither it nor the OS default, so it never removes a protocol. On Server 2019 it changes nothing; installing AWS Tools for all users explains when it matters:
$protocol = [Net.ServicePointManager]::SecurityProtocol
$tls12 = [Net.SecurityProtocolType]::Tls12
if ($protocol -ne 'SystemDefault' -and -not ($protocol -band $tls12)) {
[Net.ServicePointManager]::SecurityProtocol = $protocol -bor $tls12
}
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
Install-Module -Name AWS.Tools.Installer -Repository PSGallery -Scope AllUsers -Force
Install-AWSToolsModule -Name AWS.Tools.S3, AWS.Tools.SecurityToken -Scope AllUsers -CleanUp -ForceConfirm they landed in the machine path rather than your profile:
Get-Module -ListAvailable AWS.Tools.S3 | Select-Object Name, Version, PathPath should start C:\Program Files\WindowsPowerShell\Modules\.
2. Give SYSTEM its own AWS credentials
The task runs as SYSTEM so it works whether or not anyone is signed in. SYSTEM
is a separate account with a separate profile, and the AWS Tools read
credentials from the profile of whoever is running them. Anything you run in
your own session writes to %USERPROFILE%\.aws\; SYSTEM never sees that.
A CloudFormation template creates both the bucket and a user who can reach only
that bucket. Save it as backup-stack.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Description: >-
An S3 bucket for a game server's world backups and an IAM user that can do
only what the backup and restore scripts do: list the bucket, write, read
and delete its own archives. Archives expire after RetentionDays.
Metadata:
AWSToolsMetrics:
AWSAgentToolkit: aws-cloudformation@2
Parameters:
RetentionDays:
Type: Number
Default: 30
MinValue: 1
Description: Days an archive stays in the bucket before S3 deletes it.
Resources:
BackupBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Metadata:
com.aws.cloudformation.Context:
why: Holds the server's backup archives; retained on stack deletion so deleting the stack never deletes backups.
must: Never public. The expiry rule keeps running when the server, and its own pruning, does not.
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
LifecycleConfiguration:
Rules:
- Id: ExpireArchives
Status: Enabled
ExpirationInDays: !Ref RetentionDays
AbortIncompleteMultipartUpload:
DaysAfterInitiation: 3
BackupBucketPolicy:
Type: AWS::S3::BucketPolicy
Metadata:
com.aws.cloudformation.Context:
why: Refuses any request that is not over HTTPS.
Properties:
Bucket: !Ref BackupBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: DenyInsecureTransport
Effect: Deny
Principal: '*'
Action: 's3:*'
Resource:
- !GetAtt BackupBucket.Arn
- !Sub '${BackupBucket.Arn}/*'
Condition:
Bool:
'aws:SecureTransport': 'false'
BackupUser:
Type: AWS::IAM::User
Metadata:
com.aws.cloudformation.Context:
why: The identity the server's backup task and a restore run as. Its access key is created after the stack, so the secret never passes through CloudFormation.
must: No console access and no permission outside this one bucket.
Properties:
Policies:
- PolicyName: BackupAndRestore
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ListTheBucket
Effect: Allow
Action: 's3:ListBucket'
Resource: !GetAtt BackupBucket.Arn
- Sid: WriteReadPruneArchives
Effect: Allow
Action:
- 's3:PutObject'
- 's3:GetObject'
- 's3:DeleteObject'
Resource: !Sub '${BackupBucket.Arn}/*'
Outputs:
BucketName:
Description: Goes on the first line of backup-config.txt.
Value: !Ref BackupBucket
UserName:
Description: Create its access key with New-IAMAccessKey -UserName <this>.
Value: !Ref BackupUserIt creates three things:
- A private bucket. All public access is blocked, requests over plain HTTP
are refused, and an archive expires after
RetentionDays. The expiry rule keeps running when the script doesn’t, and it also removes the fragments an interrupted upload leaves, which are billed until removed. CloudFormation leaves the bucket in place if you delete the stack. - A user with no console access and no access key. The key comes from a separate command, so the secret never passes through CloudFormation.
- A policy scoped to the one bucket.
ListBucketacts on the bucket and the object actions act on its contents, so the two resource lines differ. Swapping them gets youAccessDenied.
PutObject writes the archive and DeleteObject is for the script’s prune.
Reading each upload back to check it arrived is a listing, so ListBucket
covers that. GetObject is only for restoring, and it lets the same
credentials restore on a new machine. With it removed from the policy, a
download as SYSTEM failed with is not authorized to perform: s3:GetObject,
while listing the bucket still worked. The cost is that a stolen key can read
your old archives, which a write-only key could not.
Deploy it from your own session, with your own credentials. The backup user’s
policy can’t create a bucket or a user, and it shouldn’t. The stack creates an
IAM user, so it needs CAPABILITY_IAM. The commands come from the
AWS.Tools.CloudFormation and AWS.Tools.IdentityManagement modules, which
you install the way step 1 installs the others:
$stackName = 'game-server-backups'
$region = 'us-west-2'
$template = Get-Content .\backup-stack.yaml -Raw
New-CFNStack -StackName $stackName -TemplateBody $template -Capability CAPABILITY_IAM `
-Region $region
Wait-CFNStack -StackName $stackName -Region $region -Timeout 300 `
-Status CREATE_COMPLETE, ROLLBACK_COMPLETE
(Get-CFNStack -StackName $stackName -Region $region).Outputs |
Select-Object OutputKey, OutputValueThe bucket is created in the stack’s region, and the backup task calls the
region in SYSTEM’s config file below, so use the same one in both places.
Add -Parameter @{ ParameterKey = 'RetentionDays'; ParameterValue = '60' } to
New-CFNStack to change the 30-day default. The script prunes too, at its own
-S3KeepDays, which is 30 unless you pass another number, so add the same
number to the task’s arguments in step 5. Otherwise the script deletes archives
the bucket would have kept. The outputs are BucketName, which
step 3 uses, and UserName. I haven’t run these cmdlets. The stack this
template makes was created through the CloudFormation API with
CAPABILITY_IAM, and it reached CREATE_COMPLETE with both outputs and the
default of 30 days.
Create the user’s access key. The secret is shown once, so keep the output out of any log:
New-IAMAccessKey -UserName '<UserName output>'I didn’t run that command either. The key isn’t part of the stack, but deleting the test stack with its key still in place removed the user and the key together and left the bucket with its archives, so there is no separate step to undo it.
Then, from an elevated PowerShell, write that key where SYSTEM will look for it: two ini-format files, written by hand. You need both. The second carries the region, and without a region every call fails, however good the keys are.
$systemAws = 'C:\Windows\System32\config\systemprofile\.aws'
New-Item -ItemType Directory -Path $systemAws -Force | Out-Null
@"
[default]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
"@ | Set-Content -Path "$systemAws\credentials" -Encoding ascii
@"
[default]
region = us-west-2
"@ | Set-Content -Path "$systemAws\config" -Encoding asciiThe directory is hidden, so dir does not show it; Get-ChildItem -Force
does.
Two shortcuts look like they would do this and do not:
Set-AWSCredential -StoreAswrites to the SDK’s own encrypted store underAppData, not to these files. That store belongs to the Windows account that wrote it.Set-DefaultAWSRegionlasts only for the session that ran it, so the region is gone by the time the task runs.
If you would rather not write into SYSTEM’s profile, -ProfileLocation points a
call at a credentials file anywhere on disk. You then have to pass it on
every call, which suits a script you are writing more than one you are copying.
3. Name the bucket for the script
Write the BucketName output on the first line of backup-config.txt, beside
the script. The script reads it from there, so the script itself holds nothing
specific to your account. The examples in the rest of this article show
7dtd-server-backups where yours goes.
4. Save the script
Save this as C:\Scripts\backup-s3.ps1, next to backup-config.txt. Each
step that could leave you without a backup stops the run where it failed, and
the exit code says how far it got:
0: the backup is in the bucket and old copies are pruned.1: no backup could be confirmed in the bucket.2: the backup is in the bucket, but pruning failed.
Task Scheduler shows that number as Last Run Result, so you can spot a partial run without opening the log.
<#
.SYNOPSIS
Back up a 7 Days to Die dedicated server's saves to Amazon S3.
.DESCRIPTION
Zips Saves and serverconfig.xml, writes the zip to S3 as STANDARD_IA,
keeps three zips locally and thirty days in the bucket, keeps thirty days
of logs. One log per run under <ServerDir>\backup-logs.
S3 is reached through AWS Tools for PowerShell (AWS.Tools.S3 and
AWS.Tools.SecurityToken), installed with -Scope AllUsers so the account the
task runs as can import them. Runs as SYSTEM from Task Scheduler, so the
credentials and region it uses are SYSTEM's, from:
C:\Windows\System32\config\systemprofile\.aws\ (credentials and config).
Exit codes: 0 success, 1 the backup could not be confirmed in S3, 2 the
backup is in S3 but a prune step failed.
#>
[CmdletBinding()]
param(
[string]$ServerDir = 'C:\7DaysToDieServer',
[string]$SavesDir,
[string]$ConfigFile,
[string]$Region,
[int]$LocalKeep = 3,
[int]$S3KeepDays = 30,
[int]$LogKeepDays = 30
)
# Beside the script unless the caller names one.
if (-not $ConfigFile) {
$ConfigFile = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'backup-config.txt'
}
# Region only when asked for. Left out, the AWS Tools resolve it the way the
# CLI did: the profile's config, then AWS_REGION, then the SDK's default.
$aws = @{}
if ($Region) { $aws.Region = $Region }
$BackupDir = Join-Path $ServerDir 'backups'
$LogDir = Join-Path $ServerDir 'backup-logs'
$Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$LogFile = Join-Path $LogDir "backup-$Timestamp.log"
$Prefix = '7days-backups/'
# Anchored, and [^/] so it cannot reach into a nested key: a listing is
# recursive where the CLI's `s3 ls` was not, and -like's * spans a slash.
$KeyPattern = '^' + [regex]::Escape($Prefix) + '7dtd-backup-[^/]+\.zip$'
foreach ($dir in $BackupDir, $LogDir) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
}
function Write-Log {
param([string]$Message = '')
Add-Content -Path $LogFile -Value $Message -Encoding UTF8
Write-Host $Message
}
function Stop-Run {
param([string]$Message, [int]$Code = 1)
Write-Log "ERROR: $Message"
exit $Code
}
Write-Log '========================================'
Write-Log '7 Days to Die Server S3 Backup'
Write-Log "Started: $(Get-Date -Format 'ddd MM/dd/yyyy HH:mm:ss.ff')"
Write-Log '========================================'
Write-Log
# Which account ran matters more than anything else when credentials fail.
Write-Log ([Security.Principal.WindowsIdentity]::GetCurrent().Name)
if (-not (Test-Path $ConfigFile)) {
Stop-Run "Config file not found at $ConfigFile. Write the bucket name on its first line"
}
$Bucket = "$(Get-Content $ConfigFile -First 1)".Trim()
if (-not $Bucket) { Stop-Run 'S3 bucket name not configured' }
Write-Log "S3 Bucket: $Bucket"
Write-Log
try {
Import-Module AWS.Tools.S3, AWS.Tools.SecurityToken -ErrorAction Stop
} catch {
Stop-Run "AWS Tools for PowerShell not available to this account: $($_.Exception.Message). Install AWS.Tools.S3 and AWS.Tools.SecurityToken with -Scope AllUsers"
}
Write-Log 'Testing AWS credentials...'
try {
Get-STSCallerIdentity @aws -ErrorAction Stop | Out-Null
} catch {
Stop-Run "AWS credentials or region not usable by this account: $($_.Exception.Message)"
}
Write-Log 'AWS credentials OK'
Write-Log
$BackupName = "7dtd-backup-$Timestamp.zip"
$BackupPath = Join-Path $BackupDir $BackupName
$Key = "$Prefix$BackupName"
$Saves = if ($SavesDir) { $SavesDir } else { Join-Path $ServerDir 'Saves' }
$Config = Join-Path $ServerDir 'serverconfig.xml'
Write-Log 'Creating backup archive...'
Write-Log "Destination: $BackupPath"
Write-Log
if (-not (Test-Path $Saves)) {
Stop-Run "Saves directory not found: $Saves"
}
Write-Log "Compressing: $Saves"
$sources = @($Saves, $Config)
try {
Compress-Archive -Path $sources -DestinationPath $BackupPath -CompressionLevel Optimal -Force -ErrorAction Stop
} catch {
Stop-Run "Failed to create backup archive: $($_.Exception.Message)"
}
$localSize = (Get-Item $BackupPath).Length
Write-Log "Backup created: $([math]::Round($localSize / 1MB)) MB"
Write-Log
Write-Log 'Uploading to S3...'
Write-Log "S3 path: s3://$Bucket/$Key"
try {
Write-S3Object -BucketName $Bucket -Key $Key -File $BackupPath `
-StorageClass STANDARD_IA @aws -ErrorAction Stop
} catch {
Stop-Run "Failed to upload backup to S3: $($_.Exception.Message)"
}
# Read it back: the cmdlet returning is not the same as the bytes being there.
try {
$remote = Get-S3Object -BucketName $Bucket -Key $Key @aws -ErrorAction Stop
} catch {
Stop-Run "Upload returned but the object could not be read back: $($_.Exception.Message)"
}
if ($null -eq $remote) {
Stop-Run "Upload returned but the object is not in the bucket: $Key"
}
if ($remote.Size -ne $localSize) {
Stop-Run "Upload size mismatch: local $localSize bytes, S3 $($remote.Size) bytes"
}
Write-Log "Upload successful: $($remote.Size) bytes, $($remote.StorageClass)"
Write-Log
# From here the backup is in the bucket. A failure below is reported, not
# fatal: exit 2 says "backed up, but look at the log".
$pruneFailed = $false
Write-Log 'Cleaning up old local backups...'
Get-ChildItem $BackupDir -Filter '7dtd-backup-*.zip' |
Sort-Object LastWriteTime -Descending |
Select-Object -Skip $LocalKeep |
ForEach-Object {
$file = $_
Write-Log "Deleting old backup: $($file.Name)"
try {
Remove-Item $file.FullName -ErrorAction Stop
} catch {
Write-Log "WARNING: could not delete $($file.Name): $($_.Exception.Message)"
$pruneFailed = $true
}
}
Write-Log "Cleaning up old S3 backups (older than $S3KeepDays days)..."
# Both sides in UTC. LastModified's Kind isn't guaranteed, and comparing a
# local time against a UTC one shifts the retention boundary by the
# machine's offset.
$cutoff = [datetime]::UtcNow.AddDays(-$S3KeepDays)
try {
$old = Get-S3Object -BucketName $Bucket -Prefix $Prefix @aws -ErrorAction Stop |
Where-Object {
$_.Key -match $KeyPattern -and
$_.LastModified.ToUniversalTime() -lt $cutoff
}
} catch {
Write-Log "WARNING: could not list S3 backups: $($_.Exception.Message)"
$pruneFailed = $true
$old = @()
}
foreach ($object in $old) {
Write-Log "Deleting old S3 backup: $($object.Key)"
try {
Remove-S3Object -BucketName $Bucket -Key $object.Key @aws -Force -ErrorAction Stop | Out-Null
} catch {
Write-Log "WARNING: could not delete $($object.Key): $($_.Exception.Message)"
$pruneFailed = $true
}
}
Get-ChildItem $LogDir -Filter 'backup-*.log' |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$LogKeepDays) -and $_.FullName -ne $LogFile } |
Remove-Item -ErrorAction SilentlyContinue
Write-Log
Write-Log '========================================'
if ($pruneFailed) {
Write-Log "Backup completed with warnings: $(Get-Date -Format 'ddd MM/dd/yyyy HH:mm:ss.ff')"
Write-Log '========================================'
exit 2
}
Write-Log "Backup completed: $(Get-Date -Format 'ddd MM/dd/yyyy HH:mm:ss.ff')"
Write-Log '========================================'
exit 0A few lines look odd until you know why they are there:
- The log function uses
Write-Host, notWrite-Output, so log lines do not leak into whatever the script returns. - After the upload, the script fetches the object’s details and compares its size with the local file. That confirms an object with that key is in the bucket. The upload cmdlet returning without an error does not prove that on its own. It checks length, not content; S3 already verifies the transfer’s checksum.
5. Register the task
From an elevated PowerShell:
Unblock-File C:\Scripts\backup-s3.ps1
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy RemoteSigned -File "C:\Scripts\backup-s3.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 11:00
Register-ScheduledTask -TaskName '7DaysToDieServerS3Backup' `
-Action $action -Trigger $trigger -User 'SYSTEM' -RunLevel HighestA script that arrives by download or inside a zip carries the mark of the
web. RemoteSigned refuses to run it until Unblock-File clears the mark.
Bypass would also work, but it would hide the next blocked file too. Steps
1 and 2 exist because of -User 'SYSTEM'. The VM’s clock is UTC, so
11:00 is early morning Pacific, ahead of the server’s own update window. Pick
an hour when nobody is playing.
Start the first run from the scheduler, not by hand. A run from your own session uses your credentials, so it tests the script but not the task:
Start-ScheduledTask 7DaysToDieServerS3Backup
Start-Sleep 30
(Get-ScheduledTask 7DaysToDieServerS3Backup | Get-ScheduledTaskInfo).LastTaskResult0, and a new log under C:\7DaysToDieServer\backup-logs whose identity
line reads NT AUTHORITY\SYSTEM.
Expected result
Each run writes one log. This one is from a morning when both prunes had work to do: a fourth archive had built up on disk, and one in the bucket had reached thirty days.
========================================
7 Days to Die Server S3 Backup
Started: Fri 09/25/2026 14:28:39.98
========================================
NT AUTHORITY\SYSTEM
S3 Bucket: 7dtd-server-backups
Testing AWS credentials...
AWS credentials OK
Creating backup archive...
Destination: C:\7DaysToDieServer\backups\7dtd-backup-20260925-142839.zip
Compressing: C:\7DaysToDieServer\Saves
Backup created: 23 MB
Uploading to S3...
S3 path: s3://7dtd-server-backups/7days-backups/7dtd-backup-20260925-142839.zip
Upload successful: 23917749 bytes, STANDARD_IA
Cleaning up old local backups...
Deleting old backup: 7dtd-backup-20260924-145409.zip
Cleaning up old S3 backups (older than 30 days)...
Deleting old S3 backup: 7days-backups/7dtd-backup-20260826-110000.zip
========================================
Backup completed: Fri 09/25/2026 14:28:45.92
========================================That’s six seconds, start to finish. The account name near the top is there because when a run fails on credentials, the first thing to find out is which account ran it.
To list what’s in the bucket:
Get-S3Object -BucketName 7dtd-server-backups -Prefix 7days-backups/ |
Select-Object Key, Size, LastModifiedExpect one archive for each morning the machine was on, and about thirty days of them.
Restore a backup
Each archive is a plain zip holding Saves and serverconfig.xml, so a
restore is a download and an Expand-Archive. Run this in your own session
if its credentials can read the bucket, and you want to be watching. The backup
user’s credentials in SYSTEM’s profile can read it too, so a scheduled task
that runs as SYSTEM can do the download on a machine that has only those.
Stop the server.
Move the current saves out of the way, so the restored tree stands alone and the old one is still there if you need it:
PowerShell$saves = 'C:\7DaysToDieServer\Saves' # or the path you passed as -SavesDir Rename-Item $saves Saves.before-restoreDownload the archive you want and expand it into a temporary directory:
PowerShell$name = '7dtd-backup-20260925-142839.zip' $zip = Join-Path $env:TEMP $name $dir = Join-Path $env:TEMP '7dtd-restore' Read-S3Object -BucketName 7dtd-server-backups -Key "7days-backups/$name" -File $zip Expand-Archive $zip -DestinationPath $dir -ForceMove
Savesinto place:PowerShellMove-Item (Join-Path $dir 'Saves') $savesOnly if you want the old settings back, copy the old
serverconfig.xmlover the current one:PowerShellCopy-Item (Join-Path $dir 'serverconfig.xml') C:\7DaysToDieServer\ -ForceStart the server.
Do a practice restore on a scratch machine before you need one for real. Until you’ve done that, you don’t know that the backup works.
Use a different destination
S3 appears in steps 1 to 3, the script’s setup checks, its upload and its S3 prune. Everything else works with any destination. To write to another drive or a share instead:
Skip steps 1 to 3. In the script, delete everything between the line that logs the account name and
$BackupName: thebackup-config.txtcheck, the module import and the credentials test. Left in, the missing config file stops every run before the copy.Replace everything from
Uploading to S3...throughUpload successfulwith a copy, checked the same way:PowerShell$Destination = '\\nas\backups\7dtd' try { Copy-Item $BackupPath -Destination $Destination -ErrorAction Stop $remote = Get-Item (Join-Path $Destination $BackupName) -ErrorAction Stop } catch { Stop-Run "Failed to copy backup to ${Destination}: $($_.Exception.Message)" } if ($remote.Length -ne $localSize) { Stop-Run "Copy size mismatch: local $localSize bytes, destination $($remote.Length) bytes" } Write-Log "Copy successful: $($remote.Length) bytes"Replace the S3 prune with a date-based
Get-ChildItemprune on the destination, like the one the script uses for its log files.
A share needs one more decision, because the task runs as SYSTEM. On a
domain, SYSTEM reaches the network as the computer account (SERVER$), so
grant that account write access on the share. Outside a domain, SYSTEM has
no identity a NAS will accept. Run the task as a local user that can write to
the share instead.
A second disk in the same machine protects you from a failed drive. It doesn’t help if you lose the whole machine.
Why it is built this way
A zip a day, not a sync. A sync keeps one current copy of each file, so a save that corrupts on Tuesday overwrites Monday’s good copy on Tuesday night. A dated zip each day gives you thirty separate restore points without setting up versioning. At 23 MB a day, a whole month is under a gigabyte.
STANDARD_IA, because the retention matches. Infrequent Access charges
for at least 30 days of storage per object. Objects pruned at exactly 30 days
pay that minimum and nothing more, at a lower per-gigabyte rate than
Standard. A restore reads one object once, so the retrieval charge stays
small.
Retention in two places. The script prunes the bucket at 30 days, and so does the lifecycle rule. On 2026-09-10 the VM was switched off, and it stayed off for twelve days. The script didn’t run, so its prune didn’t either, but the lifecycle rule kept deleting. When the machine came back, the bucket held only the archives from the last thirty days, about twelve short of thirty. If retention lived only in the script, old archives would pile up whenever the script stopped running.
Three exit codes, not two. Once the upload succeeds, the backup exists, and a later failure shouldn’t be reported the same way as an earlier one. Exit 2 means “backed up, check the log”. Task Scheduler still shows it as a non-zero result, so you notice it.
What the batch file it replaced got wrong
A batch file, backup-s3.bat, did this job from 2026-02-06 to 2026-09-22.
It called PowerShell only for Compress-Archive and a date comparison, and
it never missed a morning the machine was on. If you run something like it,
it probably has the same three defects:
set TIMESTAMP=%date:~-4,4%%date:~-10,2%%date:~-7,2%-%time:~0,2%%time:~3,2%%time:~6,2%- The timestamp cut
%date%up by character position. That only works with the en-US format,Tue 09/22/2026. With any other system locale, the filenames come out scrambled. - The S3 prune ran
aws s3 rmin a loop without checking any results, and the file always ended withexit /b 0. If thes3:DeleteObjectpermission had been missing, nobody would have noticed, because the lifecycle rule would have done the deleting instead. - Each log carried ninety lines of the CLI’s progress bar around the one line that mattered.
None of the three ever caused a failure. The AWS CLI went too, the day after
the switch. aws s3 cp reports failure only through an exit code, which has
to be checked after every call. A script that misses one check reports
success for an upload that didn’t happen. Write-S3Object throws an error
instead, and one try/catch handles it.
Common failures
AWS Tools for PowerShell not available, but the module imports fine for you
The modules were installed per-user instead of with -Scope AllUsers, so
they’re under your Documents folder, where SYSTEM can’t see them.
Get-Module -ListAvailable AWS.Tools.S3 shows the path.
AWS credentials or region not usable by this account, but the same call works for you
The task isn’t running as you, so it doesn’t use your credentials. Go back to
step 2. The account name at the top of the log shows who ran it. The message
mentions the region because a missing region fails at the same point as a
missing key. It’s easy to spend an hour in the credentials file when the
problem is in config.
Task arguments come out mangled
On this server, schtasks /Change /TR with a quoted path inside the string
produced -File " C:\Scripts\backup-s3.ps1\, with a leading space and a stray
backslash. New-ScheduledTaskAction sets Execute and Arguments as
separate values, so the quoting survives. Use Set-ScheduledTask -Action to
apply it to an existing task.
After an outage, the result reads The operator or administrator has refused the request
The machine was off at 11:00. On boot, the scheduler recorded event 153, “Missed task start rejected”, because the task’s “run as soon as possible after a scheduled start is missed” setting is off. Nothing failed, and the next 11:00 run goes ahead normally. Turn the setting on if you want a catch-up run, at the cost of a backup taken while the server is still starting.
Compress-Archive fails on one large file
Compress-Archive can’t add a single file over 2 GB. The limit comes from
the .NET API underneath it, in both Windows PowerShell 5.1 and PowerShell 7.
Nothing under Saves comes near that on this server. If one of your files
does, use tar.exe, which ships with Windows Server 2019 and later and has
no such limit.
Saves are captured mid-write
The server keeps running and writing while the archive is taken, so a restored save can be a few minutes stale. If that matters for your world, stop the server before the backup, or accept the gap.
Tested with
- OS
- Windows Server 2019 (10.0.17763), VM
- PowerShell
- Windows PowerShell 5.1
- AWS Tools
- AWS.Tools.S3 and AWS.Tools.SecurityToken 5.0.304
- Task
- daily 11:00 UTC as SYSTEM; batch 2026-02-06 to 2026-09-22, PowerShell from 2026-09-22
- Saves
- about 23 MB zipped
- Date
- September 2026