Skip to content

Instantly share code, notes, and snippets.

@matwerber1
Created June 23, 2025 08:34
Show Gist options
  • Save matwerber1/351bffaf64b989d2b0d36b2801fc021e to your computer and use it in GitHub Desktop.
Save matwerber1/351bffaf64b989d2b0d36b2801fc021e to your computer and use it in GitHub Desktop.
Terraform - Deploy Windows BitBucket Self-hosted Runner as a service using AWS SSM Run Command

Terraform to create an AWS Systems Manager (SSM) RunCommand that can be used to configure an EC2 Windows instance as a bitbucket pipeline runner.

The installation uses WinSW to wrap the runner process as a Windows service and configures it to restart on failure / reboots as described in https://jira.atlassian.com/browse/BCLOUD-21928, also based on https://support.atlassian.com/bitbucket-cloud/docs/set-up-runners-for-windows/

This is for a workspace-level runner.

A repository-level runner requires an extra parameter in the XML file and the SSM document.

Not all of the install scripts are included as they're pretty straightforward (e.g. choco install xyz, apart from the initial install of chocolately (see powershell-scripts/winEc2-choco-install-git.ps1) and disabling of pagefile+swapfile (see powershell-scripts/winEc2-disable-pagefile-and-swapfile.ps1).

provider "aws" {
region = "us-east-2"
}
locals {
ps_script_files = [
"winEc2-install-chocolatey.ps1",
"winEc2-choco-install-git.ps1",
"winEc2-choco-install-git-lfs.ps1",
"winEc2-choco-install-docker-desktop.ps1",
"winEc2-choco-install-dotnetfx.ps1",
"winEc2-choco-install-nodejs.ps1",
"winEc2-choco-install-temurin11.ps1",
"winEc2-choco-install-vs2019-workload-vctools.ps1",
"winEc2-install-rust.ps1",
"winEc2-disable-pagefile-and-swapfile.ps1",
]
}
resource "aws_ssm_document" "install_windows_bitbucket_runner_service" {
name = "Run-winEc2-deploy-bitbucket-agent"
document_type = "Command"
target_type = "/AWS::EC2::Instance"
content = jsonencode({
schemaVersion = "2.2"
parameters = {
BitBucketAccountUuid = {
type = "String"
description = "BitBucket Account UUID"
default = "{YOUR_BITBUCKET_ACCOUNT_UUID}" # this should *include* the outer braces {} in the string
}
BitBucketRunnerUuid = {
type = "String"
description = "BitBucket Runner UUID"
default = "<get this value from bitbucket UI>" # this should include the outer braces {} in the string
}
BitBucketOauthClientId = {
type = "String"
description = "BitBucket OAuth Client ID"
default = "<get this value from bitbucket UI>"
}
BitBucketOauthSecret = {
type = "String"
description = "BitBucket OAuth Secret specific to this runner UUID"
default = "<get this value from bitbucket UI>"
}
}
mainSteps = [
{
action = "aws:runPowerShellScript"
name = "RunScript"
precondition = {
StringEquals = [
"platformType",
"Windows",
]
}
inputs = {
runCommand = [
file("powershell-scripts/winEc2-deploy-bitbucket-agent.ps1"),
]
}
}
]
})
}
resource "aws_ssm_document" "scripts" {
for_each = { for file in local.ps_script_files : file => file }
name = "Run-${replace(each.key, ".ps1", "")}" # e.g. Run-BitbucketRunner-01
document_type = "Command"
target_type = "/AWS::EC2::Instance"
content = jsonencode({
schemaVersion = "2.2"
mainSteps = [
{
action = "aws:runPowerShellScript"
name = "RunScript"
precondition = {
StringEquals = [
"platformType",
"Windows",
]
}
inputs = {
runCommand = [
file("powershell-scripts/${each.value}"),
]
}
}
]
})
}
resource "aws_ssm_document" "bitbucket_runner_setup" {
name = "Bitbucket-Windows-Runner-Setup"
document_type = "Automation"
document_format = "YAML"
content = yamlencode({
schemaVersion = "0.3"
description = "Runs PowerShell setup scripts for Bitbucket Windows runners."
assumeRole = "{{ AutomationAssumeRole }}"
parameters = {
InstanceId = {
type = "String"
description = "Target instance ID"
}
AutomationAssumeRole = {
type = "String"
description = "Target instance ID"
default = aws_iam_role.ssm_automation_role.arn
}
}
mainSteps = [
for idx, script in local.ps_script_files : {
name = "${replace(replace(script, ".ps1", ""), "-", "")}"
action = "aws:runCommand"
inputs = {
DocumentName = "Run-${replace(script, ".ps1", "")}"
InstanceIds = ["{{ InstanceId }}"]
}
}
]
})
depends_on = [aws_ssm_document.scripts]
}
resource "aws_iam_role" "ssm_automation_role" {
name = "SSMAutomationRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "ssm.amazonaws.com"
}
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "ssm_automation_policy" {
role = aws_iam_role.ssm_automation_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonSSMAutomationRole"
}
data "aws_instances" "bitbucket_runners" {
filter {
name = "tag:bitbucket-windows-runner"
values = ["true"]
}
filter {
name = "instance-state-name"
values = ["running"]
}
}
Write-Output "choco install: git..."
choco install -y git
# === CONFIGURATION VARIABLES ===
$runnerDir = "C:\bitbucket-runner"
$accountUuid = "{{BitBucketAccountUuid}}"
$runnerUuid = "{{BitBucketRunnerUuid}}"
$oauthClientId = "{{BitBucketOauthClientId}}"
$oauthClientSecret = "{{BitBucketOauthSecret}}"
Set-Location "$runnerDir"
# === UNINSTALL SERVICE IF PREVIOUSLY INSTALLED ===
$serviceExists = Get-Service -Name "BitbucketRunnerWrapper" -ErrorAction SilentlyContinue
if ($serviceExists) {
Write-Host "BitbucketRunnerWrapper service already exists. Stopping and uninstalling..."
& "$runnerDir\runner.exe" stop
Start-Sleep -Seconds 2
& "$runnerDir\runner.exe" uninstall
Start-Sleep -Seconds 2
} else {
Write-Host "BitbucketRunnerWrapper service does not already exist."
}
# === CREATE DIRECTORY IF NOT EXISTS ===
Write-Output "Installing and configuring runner in $runnerDir"
if (-Not (Test-Path -Path $runnerDir)) {
New-Item -Path $runnerDir -ItemType Directory | Out-Null
}
# === DOWNLOAD RUNNER AGENT ===
Write-Output "Downloading runner zip"
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri https://product-downloads.atlassian.com/software/bitbucket/pipelines/atlassian-bitbucket-pipelines-runner-3.24.0.zip -OutFile .\atlassian-bitbucket-pipelines-runner.zip
Write-Output "Unzipping runner zip"
Expand-Archive .\atlassian-bitbucket-pipelines-runner.zip -DestinationPath . -Force
# === DOWNLOAD WinSW (USED TO WRAP RUNNER PROCESS AS WINDOWS SERVICE) ===
Write-Output "Downloading WinSW-x64 to wrap bitbucket runner as a Windows service"
$downloadUrl = "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe"
$destinationExe = Join-Path -Path $runnerDir -ChildPath "runner.exe"
Invoke-WebRequest -Uri $downloadUrl -OutFile $destinationExe
# === CREATE runner.xml ===
$xmlContent = @"
<service>
<id>BitbucketRunnerWrapper</id>
<name>Bitbucket Runner Wrapper (powered by WinSW)</name>
<description>Wrapper for JAVA based Bitbucket Runner</description>
<executable>java</executable>
<arguments>-jar -Dbitbucket.pipelines.runner.account.uuid=$accountUuid -Dbitbucket.pipelines.runner.uuid=$runnerUuid -Dbitbucket.pipelines.runner.environment=PRODUCTION -Dbitbucket.pipelines.runner.oauth.client.id=$oauthClientId -Dbitbucket.pipelines.runner.oauth.client.secret=$oauthClientSecret -Dbitbucket.pipelines.runner.directory.working=..\temp -Dbitbucket.pipelines.runner.runtime=windows-powershell -Dbitbucket.pipelines.runner.scheduled.state.update.initial.delay.seconds=0 -Dbitbucket.pipelines.runner.scheduled.state.update.period.seconds=30 -Dbitbucket.pipelines.runner.cleanup.previous.folders=false -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 ./bin/runner.jar</arguments>
<log mode="roll"></log>
<logpath>%BASE%\logs</logpath>
<stopparentprocessfirst>true</stopparentprocessfirst>
</service>
"@
Write-Output "Writing service config file to $runnerDir/runner.xml"
$xmlPath = Join-Path -Path $runnerDir -ChildPath "runner.xml"
Set-Content -Path $xmlPath -Value $xmlContent -Encoding UTF8
# === (re) INSTALL SERVICE ===
Write-Output "Installing service"
./runner.exe install runner.xml
# === Start the service ===
Write-Output "Starting service"
Start-Service -Name "BitbucketRunnerWrapper"
# === Set service startup type to Automatic to start again if instance rebooted ===
Write-Output "Configuring service to start after reboot"
Set-Service -Name "BitbucketRunnerWrapper" -StartupType Automatic
# === If service fails, attempt to restart every 5 seconds ===
Write-Output "Configuring service to attempt to restart on failure every 5 seconds"
sc.exe failure "BitbucketRunnerWrapper" reset= 0 actions= restart/5000
sc.exe failureflag "BitbucketRunnerWrapper" 1
Write-Output "Waiting 10 seconds then checking status..."
Start-Sleep -Seconds 2
$service = Get-Service -Name "BitbucketRunnerWrapper" -ErrorAction SilentlyContinue
if ($null -eq $service) {
Write-Host "Service not found."
exit 1
} elseif ($service.Status -ne 'Running') {
Write-Host "Service is not running. Current status: $($service.Status)"
exit 1
} else {
Write-Host "Service is running."
}
Write-Output "Done!"
Write-Output "πŸ”§ Disabling pagefile.sys and swapfile.sys per BitBucket recommendations..."
# Disable automatic pagefile management using modern PowerShell-compatible WMI
$cs = Get-WmiObject -Class Win32_ComputerSystem
$cs.AutomaticManagedPagefile = $false
$cs.Put()
Write-Output "βœ… Automatic pagefile management disabled."
# Remove all manually configured pagefiles
$drives = Get-WmiObject -Class Win32_PageFileSetting
foreach ($drive in $drives) {
$path = $drive.Name
Write-Output "β†’ Removing pagefile setting on $path"
$drive.Delete()
}
# Clear PagingFiles and disable swapfile.sys
$regKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management"
Write-Output "πŸ”§ Clearing registry setting for PagingFiles..."
Set-ItemProperty -Path $regKey -Name "PagingFiles" -Value " "
Write-Output "πŸ”§ Disabling swapfile.sys via SwapfileControl..."
Set-ItemProperty -Path $regKey -Name "SwapfileControl" -Value 0
Write-Output "βœ… Virtual memory disabled. A reboot is required to apply the changes."
Write-Output "Installing chocolatey..."
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
Write-Output "Adding chocolatey to path..."
$chocoBin = "C:\ProgramData\chocolatey\bin"
# Persist globally
$chocoBin = "C:\ProgramData\chocolatey\bin"
$machinePath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine)
if (-not ($machinePath -split ";" | Where-Object { $_ -eq $chocoBin })) {
[Environment]::SetEnvironmentVariable("Path", "$machinePath;$chocoBin", [EnvironmentVariableTarget]::Machine)
Write-Output "βœ… Chocolatey bin path added to system PATH."
} else {
Write-Output "ℹ️ Chocolatey bin path already in system PATH."
}
Write-Output "Allowing C:\ProgramData\chocolatey\bin\choco.exe to be executed from powershell"
Unblock-File "C:\ProgramData\chocolatey\bin\choco.exe"
Write-Output "πŸ” Restarting SSM Agent to pick up addition of choco to path..."
Restart-Service AmazonSSMAgent -Force
Start-Sleep -Seconds 5
Get-Service AmazonSSMAgent | Select-Object Status, StartType, DisplayName
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment