Automated Unreal Engine Build Server on AWS Part 3: Build

·11 min read
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

The complete pipeline works like this:

  1. A GitHub Release is published
  2. Job 1 finds the latest AMI tagged with the target Unreal Engine version and deploys a CloudFormation stack containing a Spot Fleet
  3. The Spot Instance boots, its UserData invokes the runner token Lambda, and registers itself with GitHub
  4. Job 2 runs on the self-hosted runner: builds, cooks, packages, zips, and uploads the game
  5. 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:

  1. Invoking the Lambda and configuring the GitHub Runner with the token
  2. Setting the Windows service account to NT AUTHORITY\SYSTEM
  3. Creating a PowerShell script that deregisters the runner on shutdown
  4. 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: 120 protects 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=1 trades compression ratio for speed; the artefacts are gigabytes, and minimal compression is the right call for distribution
  • The download template keeps a stable -Latest.html URL 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:

InstanceBuild TimeNotes
z1d.2xlarge55m 25sCheapest per hour, longest build
z1d.3xlarge44m 39sMiddle ground
z1d.6xlarge34m 42sChosen - best time/cost balance
z1d.12xlarge~35 minNo 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.