Automated Unreal Engine Build Server on AWS Part 3: Build

This is the final part of the series, continuing from creating a GitHub self-hosted runner base AMI and creating an AMI with a specific version of Unreal Engine. Everything now comes together: a published GitHub Release triggers a Spot Instance, which registers itself as a self-hosted runner, builds the project, uploads artefacts to S3, and tears itself down.
As always, the full code lives at: sawnoff-studios/unreal-build-server-image
Contents
This post covers how to use an EC2 Spot Instance GitHub Actions self-hosted runner:
- The End-to-End Flow – overall end-to-end flow
- Spot Instance CloudFormation stack – CloudFormation stack used to create the Spot Instance
- The UserData Script - UserData script used by the Spot Instance on startup
- Spot Instance IAM Policy – IAM policy used by the Spot Instance to access AWS resources
- The GitHub Actions Workflow - workflow file used to configure the GitHub Actions self-hosted runner
- Choosing an instance type – choosing an instance type suitable for building an Unreal Engine project
The End-to-End Flow
The complete pipeline works like this:
- A GitHub Release is published
- Job 1 finds the latest AMI tagged with the target Unreal Engine version and deploys a CloudFormation stack containing a Spot Fleet
- The Spot Instance boots, its UserData invokes the runner token Lambda, and registers itself with GitHub
- Job 2 runs on the self-hosted runner: builds, cooks, packages, zips, and uploads the game
- Job 3 deletes the CloudFormation stack – and the runner deregisters itself during Windows shutdown
Spot Instance CloudFormation stack
Setting up the stack itself is fairly straightforward — the challenging part was the UserData section: retrieving the runner token via Lambda, registering with GitHub on start-up, and deregistering on shutdown.
Parameters:
AmiId:
Type: AWS::EC2::Image::Id
Description: ID of the AMI to use for the instance
ConstraintDescription: Must be a valid AMI ID in the selected region
InstanceType:
Type: String
Description: EC2 instance type
IamFleetRoleArn:
Type: String
Description: ARN of the IAM role for the EC2 spot fleet
IamInstanceProfileArn:
Type: String
Description: ARN of the IAM instance profile for the EC2 instance
RunnerTokenLambdaArn:
Type: String
Description: ARN of the Lambda function to retrieve the runner token
RunnerGitHubOrganisation:
Type: String
Description: Organisation name for the GitHub runner
Resources:
RunnerSpotFleet:
Type: AWS::EC2::SpotFleet
DeletionPolicy: Delete
Properties:
SpotFleetRequestConfigData:
Type: request
TargetCapacity: 1
IamFleetRole: !Ref IamFleetRoleArn
TerminateInstancesWithExpiration: true
LaunchTemplateConfigs:
- LaunchTemplateSpecification:
LaunchTemplateId: !Ref RunnerLaunchTemplate
Version: !GetAtt RunnerLaunchTemplate.DefaultVersionNumber
RunnerLaunchTemplate:
Type: AWS::EC2::LaunchTemplate
DeletionPolicy: Delete
Properties:
LaunchTemplateData:
ImageId: !Ref AmiId
InstanceType: !Ref InstanceType
IamInstanceProfile:
Arn: !Ref IamInstanceProfileArn
KeyName: unreal-engine-server
InstanceMarketOptions:
MarketType: spot
SpotOptions:
SpotInstanceType: one-time
InstanceInterruptionBehavior: terminate
BlockDeviceMappings:
- DeviceName: /dev/sda1
Ebs:
VolumeSize: 250
VolumeType: gp3
DeleteOnTermination: true
UserData:
Fn::Base64: !Sub |
<powershell>
# (See breakdown below)
</powershell>Note the one-time Spot Instance type with InstanceInterruptionBehavior: terminate – this build server is disposable by design.
The UserData Script
The UserData is the heart of the solution, split into four parts:
- Invoking the Lambda and configuring the GitHub Runner with the token
- Setting the Windows service account to
NT AUTHORITY\SYSTEM - Creating a PowerShell script that deregisters the runner on shutdown
- Updating the Windows Registry so Group Policy runs that script at shutdown
UserData:
Fn::Base64: !Sub |
<powershell>
# Part 1: Invoke the runner token Lambda and configure the runner
$runnerDir="C:\actions-runner"
cd $runnerDir
$payload = "{}"
$response = aws lambda invoke `
--function-name ${RunnerTokenLambdaArn} `
--payload $payload `
C:\token.json
$tokenObject = Get-Content C:\token.json | ConvertFrom-Json
$tokenBody = $tokenObject.body | ConvertFrom-Json
$token = $tokenBody.token
.\config.cmd `
--unattended `
--url ${RunnerGitHubOrganisation} `
--token $token `
--name unreal-engine-56 `
--labels "unreal-engine-56" `
--runnergroup "Unreal Engine" `
--replace `
--runasservice
# Part 2: Update the service account
$service = (Get-Service actions.runner.*).name
Invoke-Command -ScriptBlock {& sc.exe config $service obj= "NT AUTHORITY\SYSTEM" type= own }
# Part 3: Create the shutdown script
$scriptPath = 'C:\Scripts\Remove-GitHubRunner.ps1'
New-Item -ItemType Directory -Path 'C:\Scripts' -Force | Out-Null
@'
$ErrorActionPreference = "Continue"
"Running shutdown script..." | Out-File -FilePath C:\Remove-GitHubRunner.txt
$tokenObject = Get-Content C:\token.json | ConvertFrom-Json
$tokenBody = $tokenObject.body | ConvertFrom-Json
$token = $tokenBody.token
C:\actions-runner\config.cmd remove --token $token
"Finished running shutdown script..." | Out-File -FilePath C:\Remove-GitHubRunner.txt -Append
'@ | Out-File -FilePath $scriptPath -Encoding UTF8 -Force
# Part 4: Configure Group Policy shutdown scripts via the registry
# With help from https://stackoverflow.com/questions/79837954/how-to-enable-shutdown-powershell-script-on-windows-2022-without-using-gui
$gpFolder = "C:\Windows\System32\GroupPolicy"
$gpShutdownFolder = "$gpFolder\Machine\Scripts\Shutdown"
$null = New-Item -ItemType Directory -Force -Path $gpShutdownFolder
Copy-Item -Path $scriptPath -Destination $gpShutdownFolder -Force
# Create gpt.ini file
@"
[General]
gPCMachineExtensionNames=[{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B6664F-4972-11D1-A7CA-0000F87571E3}]
Version=1
"@ | Out-File -FilePath "$gpFolder\gpt.ini" -Encoding utf8
# Create psscripts.ini file
@"
[Shutdown]
0CmdLine=Remove-GitHubRunner.ps1
0Parameters=
"@ | Out-File -FilePath "$gpFolder\Machine\Scripts\psscripts.ini" -Encoding Unicode
# Config registry key
$configKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\Scripts\Shutdown\0"
New-Item -Path "$configKey\0" -Force | Out-Null
# These registry values match what gpedit.msc writes
New-ItemProperty -Force -Path $configKey -Name "DisplayName" -Value "Local Group Policy"
New-ItemProperty -Force -Path $configKey -Name "FileSysPath" -Value "C:\Windows\System32\GroupPolicy\Machine"
New-ItemProperty -Force -Path $configKey -Name "GPO-ID" -Value "LocalGPO"
New-ItemProperty -Force -Path $configKey -Name "GPOName" -Value "Local Group Policy"
New-ItemProperty -Force -Path $configKey -Name "PSScriptOrder" -PropertyType DWord -Value 1
New-ItemProperty -Force -Path $configKey -Name "SOM-ID" -Value "Local"
New-ItemProperty -Force -Path "$configKey\0" -Name "Script" -Value Remove-GitHubRunner.ps1
New-ItemProperty -Force -Path "$configKey\0" -Name "Parameters" -Value ""
New-ItemProperty -Force -Path "$configKey\0" -Name "IsPowershell" -PropertyType DWord -Value 1
New-ItemProperty -Force -Path "$configKey\0" -Name "ExecTime" -PropertyType QWord -Value 0
# State registry key
$stateKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Scripts\Shutdown\0"
New-Item -Path "$stateKey\0" -Force | Out-Null
New-ItemProperty -Force -Path $stateKey -Name "DisplayName" -Value "Local Group Policy"
New-ItemProperty -Force -Path $stateKey -Name "FileSysPath" -Value "C:\Windows\System32\GroupPolicy\Machine"
New-ItemProperty -Force -Path $stateKey -Name "GPO-ID" -Value "LocalGPO"
New-ItemProperty -Force -Path $stateKey -Name "GPOName" -Value "Local Group Policy"
New-ItemProperty -Force -Path $stateKey -Name "PSScriptOrder" -PropertyType DWord -Value 1
New-ItemProperty -Force -Path $stateKey -Name "SOM-ID" -Value "Local"
New-ItemProperty -Force -Path "$stateKey\0" -Name "Script" -Value Remove-GitHubRunner.ps1
New-ItemProperty -Force -Path "$stateKey\0" -Name "Parameters" -Value ""
New-ItemProperty -Force -Path "$stateKey\0" -Name "IsPowerShell" -PropertyType DWord -Value 1
New-ItemProperty -Force -Path "$stateKey\0" -Name "ExecTime" -PropertyType QWord -Value 0
gpupdate /force
</powershell>Why the shutdown script matters: without deregistration, terminated runners accumulate in GitHub’s runner list as offline ghosts. This is the final piece that makes the whole ephemeral runner pattern self-cleaning - and it’s the part nobody else seems to have documented properly for headless Windows servers. Normally you’d configure this via gpedit.msc, but with no UI, the registry entries have to be written by hand to mimic exactly what the Group Policy editor produces.
Spot Instance IAM Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageCloudFormation",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:DescribeEvents",
"cloudformation:DeleteStack",
"cloudformation:CreateChangeSet",
"cloudformation:DescribeChangeSet",
"cloudformation:ExecuteChangeSet",
"cloudformation:DeleteChangeSet",
"cloudformation:DescribeStacks"
],
"Resource": "*"
},
{
"Sid": "CreateCloudFrontInvalidation",
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::[AccountID]:distribution/[DistributionID]"
},
{
"Sid": "StartSpotInstance",
"Effect": "Allow",
"Action": [
"ec2:DescribeImages",
"ec2:DeleteLaunchTemplate",
"ec2:CreateLaunchTemplate",
"ec2:DescribeInstances",
"ec2:StartInstances",
"ec2:DescribeLaunchTemplates",
"ec2:CancelSpotFleetRequests",
"ec2:DescribeLaunchTemplateVersions",
"ec2:RunInstances",
"ec2:RequestSpotFleet",
"ec2:StopInstances",
"ec2:DescribeSpotFleetRequests"
],
"Resource": "*"
},
{
"Sid": "CreateServiceLinkedRole",
"Effect": "Allow",
"Action": "iam:CreateServiceLinkedRole",
"Resource": "arn:aws:iam::*:role/aws-service-role/://amazonaws.com",
"Condition": {
"StringLike": {
"iam:AWSServiceName": "spotfleet.amazonaws.com"
}
}
},
{
"Sid": "SpotFleetPassRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*",
"Condition": {
"StringEquals": {
"iam:PassedToService": [
"ec2.amazonaws.com",
"ec2.amazonaws.com.cn"
]
}
}
},
{
"Sid": "InvokeGitHubRunnerTokenLambda",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:[Region]:[AccountID]:function:github-runner-token"
},
{
"Sid": "DeleteHtmlDownloadPage",
"Effect": "Allow",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::downloads.sawnoffstudios.com/*.html"
}
]
}A note on scoping: the S3 and CloudFront statements are scoped to specific resources, but the EC2 and CloudFormation statements use Resource: “*” out of necessity – those APIs don’t support resource-level permissions cleanly. This is a reasonable trade-off given the constrained IAM role the workflow assumes.
The GitHub Actions Workflow
Three jobs chained together, triggered by a published release:
Job 1: Start the EC2 Instance
name: Build Project
on:
release:
types: [published]
env:
PROJECT_NAME: "GraveyardGunner"
UNREAL_ENGINE_VERSION: "5.6"
BUCKET_NAME: "downloads.sawnoffstudios.com"
INSTANCE_TYPE: "z1d.6xlarge"
RUNNER_GITHUB_ORGANISATION: "https://github.com/sawnoff-studios"
permissions:
id-token: write
contents: read
jobs:
StartEC2Instance:
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.IAM_ROLE_ARN }}
role-session-name: ${{ env.PROJECT_NAME }}Build-${{ github.sha }}
aws-region: ap-southeast-2
- name: Clone repository
uses: actions/checkout@v7
with:
lfs: false
- name: Get AMI ID
run: |
AMI_ID=$(aws ec2 describe-images --owners self --filters "Name=tag:UnrealEngineVersion,Values=${{ env.UNREAL_ENGINE_VERSION }}" --query "Images | sort_by(@, &CreationDate) | [-1].ImageId" --output text)
echo "Using AMI ID $AMI_ID"
echo "AMI_ID=$AMI_ID" >> $GITHUB_ENV
- name: Deploy CloudFormation Stack
uses: aws-actions/aws-cloudformation-github-deploy@v2
with:
name: ${{ env.PROJECT_NAME }}BuildStack-${{ github.sha }}
template: ${{ github.workspace }}/build-infra.yml
parameter-overrides: "AmiId=${{ env.AMI_ID }},InstanceType=${{ env.INSTANCE_TYPE }},IamFleetRoleArn=${{ secrets.IAM_FLEET_ROLE_ARN }},IamInstanceProfileArn=${{ secrets.IAM_INSTANCE_PROFILE_ARN }},RunnerTokenLambdaArn=${{ secrets.RUNNER_TOKEN_LAMBDA_ARN }},RunnerGitHubOrganisation=${{ env.RUNNER_GITHUB_ORGANISATION }}"Note the AMI lookup filters by the UnrealEngineVersion tag set back in Part 2 and grabs the newest match — the version pinning from the previous post pays off here.
Job 2: Build the Project
BuildProject:
runs-on: [self-hosted, unreal-engine-56]
timeout-minutes: 120
needs: StartEC2Instance
steps:
- name: Clone repository
uses: actions/checkout@v7
with:
lfs: true
- name: Update version number
shell: powershell
run: |
.\update-version-number.ps1
- name: Build project
shell: powershell
run: |
# Build the project using Unreal Engine's command line tools
& "C:\Program Files\Epic Games\UE_${{ env.UNREAL_ENGINE_VERSION }}\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun -project="${{ github.workspace }}\${{ env.PROJECT_NAME }}.uproject" -noP4 -platform=Win64 -clientconfig=Shipping -serverconfig=Shipping -cook -allmaps -build -stage -pak -archive -nodebuginfo -nocompileuat -archivedirectory="${{ github.workspace }}\Output"
- name: Upload log file on failure
if: failure()
uses: actions/upload-artefact@v4
with:
name: unreal-build-log
path: C:\Windows\ServiceProfiles\NetworkService\AppData\Roaming\Unreal Engine\AutomationTool\Logs\C+Program+Files+Epic+Games+UE_${{ env.UNREAL_ENGINE_VERSION }}\Log.txt
retention-days: 5
- name: Zip files for upload
shell: powershell
run: |
$7ZipExePath = "C:\Program Files\7-Zip\7z.exe"
$ZipFilePath = "${{ github.workspace }}\${{ env.PROJECT_NAME }}-${{ github.ref_name }}.zip"
$ProjectFilesPath = "${{ github.workspace }}\Output\Windows\*"
& $7ZipExePath a -tzip -mx=1 -mmt=on $ZipFilePath $ProjectFilesPath
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.IAM_ROLE }}
role-session-name: ${{ env.PROJECT_NAME }}Build-${{ github.sha }}
aws-region: ap-southeast-2
- name: Copy files to S3 bucket
shell: powershell
run: |
aws s3 cp ${{ github.workspace }}/${{ env.PROJECT_NAME }}-${{ github.ref_name }}.zip s3://${{ env.BUCKET_NAME }}/${{ env.PROJECT_NAME }}-${{ github.ref_name }}.zip
- name: Generate download template
shell: powershell
run: |
$downloadTemplate = Get-Content -Path ${{ github.workspace }}\${{ env.PROJECT_NAME }}-Latest.html
$downloadTemplate = $downloadTemplate -replace '\{DOWNLOAD_URL}', '${{ env.BUCKET_NAME }}/${{ env.PROJECT_NAME }}-${{ github.ref_name }}.zip'
$downloadTemplate | Out-File -FilePath ${{ github.workspace }}\${{ env.PROJECT_NAME }}-Latest.html
- name: Upload download template
shell: powershell
run: |
aws s3 rm s3://${{ env.BUCKET_NAME }}/${{ env.PROJECT_NAME }}-Latest.html
aws s3 cp ${{ github.workspace }}/${{ env.PROJECT_NAME }}-Latest.html s3://${{ env.BUCKET_NAME }}/${{ env.PROJECT_NAME }}-Latest.html
- name: Invalidate CloudFront cache
shell: powershell
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }} --paths "/${{ env.PROJECT_NAME }}-Latest.html"Several details worth highlighting:
timeout-minutes: 120protects against a hung build burning Spot Instance hours indefinitely- The failure condition uploads the UAT log as a GitHub artefact – when a build breaks, you’ll want that log immediately
-mx=1trades compression ratio for speed; the artefacts are gigabytes, and minimal compression is the right call for distribution- The download template keeps a stable
-Latest.htmlURL pointing at the newest build, invalidated via CloudFront
StopEC2Instance:
runs-on: ubuntu-latest
needs: BuildProject
if: always()
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.IAM_ROLE_ARN }}
role-session-name: ${{ env.PROJECT_NAME }}Build-${{ github.sha }}
aws-region: ap-southeast-2
- name: Delete CloudFormation Stack
run: |
aws cloudformation delete-stack --stack-name ${{ env.PROJECT_NAME }}BuildStack-${{ github.sha }}if: always() is not optional here. Without it, a failed build would skip teardown and leave a z1d.6xlarge Spot Instance running at your expense until you notice. The stack name includes github.sha precisely so it’s unique per run and can’t collide.
Choosing an instance type
As recommended in the Unreal Engine knowledge base, I experimented with which type of EC2 instance to use:
“For our compilers we’ve found the r5.8xlarge AWS instance is good for our needs in most cases. The r5 instance types gave us a great blend on the number of cores while still giving us enough memory for stability. Depending on the size of your project, the C5 instances could be a good fit as well, but there could be some virtual memory woes to look out for with them. That varies from project to project, so testing would help there. For cooking we use the z1d.6xlarge instance. The z1d instances give us the fastest core clock speeds, good memory, and high I/O capable storage. C5 instances didn’t have enough memory for our needs, but they could work depending on your project. R5 instances didn’t have the clock speed we needed. Definitely profile locally and see!”
Here’s what I found benchmarking Graveyard Gunner across the z1d family:
| Instance | Build Time | Notes |
|---|---|---|
| z1d.2xlarge | 55m 25s | Cheapest per hour, longest build |
| z1d.3xlarge | 44m 39s | Middle ground |
| z1d.6xlarge | 34m 42s | Chosen - best time/cost balance |
| z1d.12xlarge | ~35 min | No gain over 6xlarge |
The z1d.12xlarge matched the z1d.6xlarge - Unreal’s build pipeline stops scaling somewhere before 48 cores for this project, so the extra spend buys nothing. The z1d family wins on clock speed, which matters because single-core performance tends to dominate UAT stages like shader compilation.
Cost: a z1d.6xlarge Windows Spot Instance running for ~30 minutes in ap-southeast-2 costs around $2–3 AUD per build, depending on Spot availability. Compare that to the hours of local machine time it replaces.
Wrapping Up
When I started this journey, I severely underestimated how much work it would take to get a fully functioning AMI that builds Unreal Engine projects on demand.
Getting it all working took a great deal of trial and error – particularly the Image Builder components and their command-line arguments. Once I had a working pipeline for one Unreal Engine version, I parameterised the template to support multiple versions and pulled the base image creation into its own pipeline, since the tooling churns rarely while the engine versions do.
Was it overkill for a solo hobby project? Probably. Could you just build locally and upload a zip? Absolutely. But now I press a button, a build server materialises, does the work, uploads the release, and vanishes – and I’ve learned a lot about EC2 Image Builder, Spot Fleets, Lambda, and Windows internals along the way.
Hopefully this series helps someone else set up their own GitHub self-hosted runner for building Unreal Engine projects. If you do, I’d love to hear about it.