Using AWS Spot Instances as CI/CD Runners
CI/CD pipelines in many organizations have far exceeded just go test and go build. Terraform plans that must pull state from hundreds of resources, SonarQube analysis consuming 4 vCPUs for 10 minutes, Java Gradle builds with large dependency graphs, multi-stage Docker builds with thick layer caches — all of these turn the runner from a “small computer occasionally used” into a serious infrastructure burden. And the problem is consistent: always-on self-hosted runners are over-provisioned for peak load but idle most of the time, or shared runners create long queues that frustrate developers waiting for feedback. AWS Spot Instances offer an elegant way out: top-tier compute, 70–90% cheaper than On-Demand, used only when needed, and destroyed the moment the job finishes. This article discusses how it works, the complete implementation, and the trade-offs to understand before adopting it.
Why CI/CD Is the Perfect Spot Workload
Spot Instances have one non-negotiable characteristic: AWS can take them back at any time with 2 minutes’ notice. For production servers, this is a disqualifier. For CI/CD runners, it’s almost irrelevant — if understood correctly.
Notice the characteristics of CI/CD jobs that make them naturally suitable:
CI/CD JOB vs PRODUCTION WORKLOAD
CI/CD Job:
✓ Stateless — doesn't store state outside of artifacts
✓ Ephemeral — designed to die after finishing
✓ Retriable — if it fails, just run it again
✓ Short duration — mostly 5–30 minutes
✓ Schedulable — no user waiting directly
✓ Idempotent — same result if rerun from scratch
Production Server:
✗ Stateful — stores in-memory state, active connections
✗ Long-running — uptime of weeks to months
✗ Can't be interrupted arbitrarily — users are being served
✗ Complex recovery — restarts can affect users
This combination makes CI/CD runners an almost perfect Spot Instance candidate — even more suitable than batch processing, because the retry infrastructure (GitHub Actions, GitLab CI) is already built in.
How Much Can You Potentially Save
These numbers aren’t theory. Spot pricing varies per region and instance type, but the discount compared to On-Demand is consistently large.
| Instance Type | vCPU | RAM | On-Demand/hour | Spot/hour | Savings |
|---|---|---|---|---|---|
| c6i.2xlarge | 8 | 16 GB | $0.34 | ~$0.09 | ~73% |
| c6i.4xlarge | 16 | 32 GB | $0.68 | ~$0.18 | ~74% |
| m6i.4xlarge | 16 | 64 GB | $0.768 | ~$0.19 | ~75% |
| c6i.8xlarge | 32 | 64 GB | $1.36 | ~$0.32 | ~76% |
One heavy job requiring c6i.4xlarge for 20 minutes:
- On-Demand:
$0.68 × (20/60)= $0.23 per job - Spot:
$0.18 × (20/60)= $0.06 per job
In a team with 200 heavy jobs per day, the savings reach about $34/day or ~$1,000/month — from just one type of job. Larger teams with more pipelines can save far more.
How the Spot Market Works
Understanding the Spot Market mechanism helps design the right mitigation strategy and avoid wrong configuration decisions.
flowchart TD
subgraph SpotMarket["AWS Spot Market"]
POOL["Spot Instance Pool<br/>(AWS unused capacity)"]
PRICE["Spot Price<br/>(fluctuates per AZ per instance type)"]
end
subgraph Request["Request from CI/CD"]
REQ["Spot Instance Request<br/>+ optional Max Price"]
end
subgraph Outcomes["Possible Outcomes"]
OK["Instance runs<br/>✓ Spot price < max price<br/>✓ Capacity available"]
INTERRUPT["Instance interrupted<br/>2 minutes before terminate<br/>⚠ Capacity needed back by AWS"]
NOFULFILL["Request not fulfilled<br/>✗ No capacity<br/>in the chosen AZ"]
end
Request -->|Submit| SpotMarket
SpotMarket --> OK
SpotMarket --> INTERRUPT
SpotMarket --> NOFULFILL
OK -->|No mitigation needed| DONE1["Job runs normally"]
INTERRUPT -->|Mitigation| RETRY["Job retried<br/>on a new instance"]
NOFULFILL -->|Mitigation| FALLBACK["Fallback to another AZ<br/>or On-Demand"]
style OK fill:#e8f5e9,stroke:#43a047
style INTERRUPT fill:#fff3e0,stroke:#fb8c00
style NOFULFILL fill:#ffebee,stroke:#e53935There are two things to understand about Spot interruptions:
First, interruptions aren’t a daily occurrence. AWS publishes data showing the average interruption rate for most instance types in popular regions is below 5% per month. Popular instance types (c5, c6i, m5) in availability zones with large capacity are even lower.
Second, you can choose instance types that are less at risk of interruption. AWS provides the Spot Instance Advisor showing historical interruption frequency per instance type. For CI/CD runners, choose instance types labeled “< 5% interruption rate”.
Ephemeral Runner Architecture
The cleanest pattern for Spot-based CI/CD runners is a fully ephemeral architecture: no runner “standby”, every job creates a new instance, and the instance destroys itself after the job finishes.
sequenceDiagram
participant Dev as Developer
participant GH as GitHub
participant WF as Workflow (ubuntu-latest)
participant AWS as AWS EC2
participant SPOT as Spot Instance (Runner)
Dev->>GH: git push / PR
GH->>WF: Trigger workflow
WF->>AWS: ec2 run-instances<br/>(Spot, custom AMI)
AWS-->>WF: Instance ID
Note over AWS,SPOT: ~60-90 seconds cold start<br/>(faster with a custom AMI)
SPOT->>SPOT: User data script runs:<br/>1. Install tools (if not using a custom AMI)<br/>2. Register as a GitHub runner<br/>3. Start listening for jobs
GH->>SPOT: Send job to the runner
SPOT->>SPOT: Run the CI job<br/>(build, test, scan, etc.)
SPOT-->>GH: Upload artifacts, report status
SPOT->>AWS: ec2 terminate-instances<br/>(self-terminate)
Note over SPOT: Runner --ephemeral:<br/>automatically unregisters from GitHub
AWS-->>SPOT: Instance terminatedThe beauty of this architecture is in two small but very important details: the --ephemeral flag on the runner configuration ensures the runner automatically unregisters from GitHub after one job, and self-termination at the end of the user data script ensures no instance is forgotten to be shut down and keeps being billed.
Setting Up the Infrastructure
IAM Role for the EC2 Runner
The instance needs permission to destroy itself. This is the minimal policy needed.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSelfTerminate",
"Effect": "Allow",
"Action": "ec2:TerminateInstances",
"Resource": "*",
"Condition": {
"StringEquals": {
"ec2:ResourceTag/Purpose": "ci-runner"
}
}
},
{
"Sid": "AllowDescribeForSelfIdentification",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeTags"
],
"Resource": "*"
}
]
}
The ec2:ResourceTag/Purpose: ci-runner condition ensures the instance can only terminate other instances also labeled as runners — preventing bugs or exploits that could accidentally terminate other instances.
User Data Script (Bootstrap)
This script runs when the instance first boots. For the approach without a custom AMI, this is longer because all dependencies must be installed.
#!/bin/bash
set -euo pipefail
# Redirect output to a log for debugging
exec > >(tee /var/log/runner-bootstrap.log) 2>&1
echo "Bootstrap started: $(date)"
# --- Variables from environment or SSM Parameter Store ---
GITHUB_ORG="${GITHUB_ORG}"
GITHUB_REPO="${GITHUB_REPO}"
RUNNER_TOKEN="${RUNNER_TOKEN}" # Better fetched from AWS SSM, not injected directly
RUNNER_VERSION="2.316.0"
RUNNER_LABELS="spot,large,ci"
AWS_REGION="ap-southeast-1"
# --- Install dependencies ---
yum update -y -q
yum install -y -q docker git jq curl unzip
# Install AWS CLI v2 if not already present
if ! command -v aws &> /dev/null; then
curl -sL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
/tmp/aws/install
fi
# Start Docker
systemctl enable docker
systemctl start docker
usermod -aG docker ec2-user
# --- Install and configure the GitHub Actions Runner ---
RUNNER_DIR="/opt/actions-runner"
mkdir -p ${RUNNER_DIR}
cd ${RUNNER_DIR}
curl -sL -o runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
tar xzf runner.tar.gz
rm runner.tar.gz
# Fetch the runner token from SSM Parameter Store (safer than direct injection)
# RUNNER_TOKEN=$(aws ssm get-parameter \
# --name "/ci/github-runner-token" \
# --with-decryption \
# --region ${AWS_REGION} \
# --query "Parameter.Value" \
# --output text)
# Register the runner to GitHub
# --ephemeral: the runner accepts only one job then unregisters automatically
./config.sh \
--url "https://github.com/${GITHUB_ORG}/${GITHUB_REPO}" \
--token "${RUNNER_TOKEN}" \
--name "spot-runner-$(hostname)-$(date +%s)" \
--labels "${RUNNER_LABELS}" \
--unattended \
--ephemeral
echo "Runner successfully registered: $(date)"
# --- Run the runner ---
# Run as a service so it can be managed with systemd
./svc.sh install
./svc.sh start
# Wait until the runner finishes receiving and running the job
# Polling: check whether the runner process is still running
while systemctl is-active --quiet actions.runner.*; do
sleep 10
done
echo "Job finished, starting self-terminate: $(date)"
# --- Self-terminate ---
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
aws ec2 terminate-instances \
--instance-ids "${INSTANCE_ID}" \
--region "${AWS_REGION}"
Don’t injectRUNNER_TOKENdirectly into the user data script as plaintext if it can be avoided. User data scripts can be read by anyone withDescribeInstancesaccess. Use AWS SSM Parameter Store (with KMS encryption) to store the token and fetch it at bootstrap with the right IAM role.
Custom AMI for Faster Startup
One weakness of the vanilla approach is the long startup time if all tools are installed at boot. For a Java + Terraform + SonarQube pipeline, it can take 3–5 minutes just for setup.
The solution is a custom AMI with all tools preinstalled.
# Script to build a custom AMI (run on the EC2 that will be snapshotted)
#!/bin/bash
set -euo pipefail
# Java 21 (for Gradle/Maven builds)
yum install -y java-21-amazon-corretto-headless
# Gradle
GRADLE_VERSION="8.7"
curl -sL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip
unzip -q /tmp/gradle.zip -d /opt
ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle
# Terraform
TERRAFORM_VERSION="1.8.4"
curl -sL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \
-o /tmp/terraform.zip
unzip -q /tmp/terraform.zip -d /usr/local/bin
chmod +x /usr/local/bin/terraform
# SonarQube Scanner
SONAR_VERSION="6.1.0.4477"
curl -sL "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_VERSION}-linux-x64.zip" \
-o /tmp/sonar.zip
unzip -q /tmp/sonar.zip -d /opt
ln -s /opt/sonar-scanner-${SONAR_VERSION}-linux-x64/bin/sonar-scanner /usr/local/bin/sonar-scanner
# Docker (already installed, make sure it's updated to the latest version)
yum update -y docker
systemctl enable docker
# GitHub Actions Runner (preinstall but don't register yet)
RUNNER_VERSION="2.316.0"
mkdir -p /opt/actions-runner
cd /opt/actions-runner
curl -sL -o runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
tar xzf runner.tar.gz && rm runner.tar.gz
# After this script finishes:
# 1. Create an AMI from this instance via the AWS Console or CLI
# 2. Use this AMI ID in the GitHub Actions workflow
# aws ec2 create-image --instance-id i-xxxx --name "ci-runner-v1.0" --no-reboot
With a custom AMI, the user data script at boot only needs to register the runner to GitHub — startup time drops from 3–5 minutes to 60–90 seconds.
GitHub Actions Implementation
Complete Workflow with Error Handling
name: Heavy CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
env:
AWS_REGION: ap-southeast-1
INSTANCE_TYPE: c6i.4xlarge
AMI_ID: ami-0abcdef1234567890 # Custom AMI
jobs:
# Job 1: Launch Spot Instance
# This job runs on the standard GitHub runner (cheap, fast)
launch-runner:
runs-on: ubuntu-latest
outputs:
instance-id: ${{ steps.launch.outputs.instance-id }}
runner-label: ${{ steps.launch.outputs.runner-label }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-launcher
aws-region: ${{ env.AWS_REGION }}
- name: Get GitHub Runner Token
id: get-token
run: |
TOKEN=$(curl -sX POST \
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/actions/runners/registration-token" \
| jq -r .token)
echo "::add-mask::${TOKEN}"
echo "token=${TOKEN}" >> $GITHUB_OUTPUT
- name: Launch Spot Instance
id: launch
run: |
RUNNER_LABEL="spot-$(echo ${{ github.run_id }}-${{ github.run_attempt }})"
# Encode user data as base64
USER_DATA=$(cat <<EOF | base64 -w 0
#!/bin/bash
export GITHUB_ORG="${{ github.repository_owner }}"
export GITHUB_REPO="${{ github.event.repository.name }}"
export RUNNER_TOKEN="${{ steps.get-token.outputs.token }}"
export RUNNER_LABELS="${RUNNER_LABEL},spot,large"
export AWS_REGION="${{ env.AWS_REGION }}"
bash /opt/bootstrap-runner.sh
EOF
)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id ${{ env.AMI_ID }} \
--instance-type ${{ env.INSTANCE_TYPE }} \
--iam-instance-profile Name=ci-runner-instance-profile \
--instance-market-options '{"MarketType":"spot","SpotOptions":{"SpotInstanceType":"one-time","InstanceInterruptionBehavior":"terminate"}}' \
--user-data "${USER_DATA}" \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Purpose,Value=ci-runner},{Key=RunID,Value=${{ github.run_id }}}]' \
--metadata-options '{"HttpTokens":"required","HttpEndpoint":"enabled"}' \
--region ${{ env.AWS_REGION }} \
--query 'Instances[0].InstanceId' \
--output text)
echo "instance-id=${INSTANCE_ID}" >> $GITHUB_OUTPUT
echo "runner-label=${RUNNER_LABEL}" >> $GITHUB_OUTPUT
echo "Instance ${INSTANCE_ID} launched, waiting for the runner to be ready..."
- name: Wait for runner to be ready
run: |
# Wait until the runner is registered on GitHub (max 3 minutes)
TIMEOUT=180
ELAPSED=0
RUNNER_LABEL="${{ steps.launch.outputs.runner-label }}"
while [ $ELAPSED -lt $TIMEOUT ]; do
RUNNER_COUNT=$(curl -s \
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/actions/runners" \
| jq "[.runners[] | select(.labels[].name == \"${RUNNER_LABEL}\")] | length")
if [ "${RUNNER_COUNT}" -gt "0" ]; then
echo "Runner ready after ${ELAPSED} seconds"
exit 0
fi
sleep 10
ELAPSED=$((ELAPSED + 10))
done
echo "Timeout: runner not registered within ${TIMEOUT} seconds"
exit 1
# Job 2: Heavy CI running on the Spot Instance
build-and-test:
needs: launch-runner
runs-on: [self-hosted, "${{ needs.launch-runner.outputs.runner-label }}"]
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Gradle Build
run: ./gradlew build --no-daemon --parallel
- name: Run Tests
run: ./gradlew test --no-daemon
- name: SonarQube Scan
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
run: |
sonar-scanner \
-Dsonar.projectKey=${{ github.event.repository.name }} \
-Dsonar.sources=src \
-Dsonar.java.binaries=build/classes
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: build/libs/
# Job 3: Cleanup — make sure the instance is deleted even if the job fails
cleanup:
needs: [launch-runner, build-and-test]
runs-on: ubuntu-latest
if: always() # Always run, even if the build fails
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-launcher
aws-region: ${{ env.AWS_REGION }}
- name: Terminate instance if still running
run: |
INSTANCE_ID="${{ needs.launch-runner.outputs.instance-id }}"
STATE=$(aws ec2 describe-instances \
--instance-ids ${INSTANCE_ID} \
--query 'Reservations[0].Instances[0].State.Name' \
--output text \
--region ${{ env.AWS_REGION }} 2>/dev/null || echo "terminated")
if [ "${STATE}" != "terminated" ] && [ "${STATE}" != "shutting-down" ]; then
echo "Instance is still running, terminating..."
aws ec2 terminate-instances \
--instance-ids ${INSTANCE_ID} \
--region ${{ env.AWS_REGION }}
else
echo "Instance is no longer running: ${STATE}"
fi
The cleanup job with the if: always() condition is an important safety net — it ensures the instance isn’t forgotten to be shut down even when the job fails midway before self-termination could execute.
Handling Spot Interruptions
Spot interruption is a condition to anticipate, not avoid. The right strategy is making it not painful.
flowchart TD
JOB[Job running<br/>on a Spot Instance] --> INT{Spot Interruption<br/>Notice received}
INT -- Didn't happen --> DONE[Job finished normally<br/>Instance self-terminates]
INT -- Yes, 2 minutes before terminate --> SIGNAL["Instance receives<br/>ITB-Termination-Notice<br/>at the metadata endpoint"]
SIGNAL --> HANDLER["Interruption handler script:<br/>1. Send SIGTERM to the runner<br/>2. Runner checkpoints state<br/>3. Upload partial artifacts"]
HANDLER --> TERMINATE[Instance terminates]
TERMINATE --> RETRY["GitHub Actions:<br/>job marked failed"]
RETRY --> RERUN["Rerun the job manually<br/>or automatically via retry config"]
style INT fill:#fff3e0,stroke:#fb8c00
style DONE fill:#e8f5e9,stroke:#43a047
style RERUN fill:#e3f2fd,stroke:#1e88e5# Script to handle the Spot Interruption Notice
# Add to user data or run as a background service
#!/bin/bash
# spot-interruption-handler.sh
while true; do
# Check the Spot Interruption Notice from the metadata endpoint
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "X-aws-ec2-metadata-token: $(curl -s -X PUT \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 60' \
http://169.254.169.254/latest/api/token)" \
http://169.254.169.254/latest/meta-data/spot/termination-time)
if [ "${HTTP_CODE}" == "200" ]; then
echo "SPOT INTERRUPTION NOTICE received! Starting graceful shutdown..."
# Send a signal to the GitHub Actions runner so it can clean up
pkill -SIGTERM -f "Runner.Listener"
# Wait a moment then terminate
sleep 30
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
aws ec2 terminate-instances --instance-ids "${INSTANCE_ID}" --region ap-southeast-1
exit 0
fi
sleep 5
done
For pipelines running longer than 15 minutes (large Terraform plans, SonarQube analysis, large Maven project builds), it’s highly recommended to break the pipeline into smaller stages with artifacts uploaded between stages. That way, if an interruption happens in one stage, the retry only repeats that stage — not the entire pipeline.
Advanced Optimizations
Multi-AZ Strategy for Higher Availability
Spot capacity varies per Availability Zone. The best strategy is requesting instances from several AZs at once and using flexible instance types.
# Use EC2 Fleet for multi-AZ and multi-instance-type requests
- name: Launch Spot via EC2 Fleet
run: |
aws ec2 create-fleet \
--launch-template-configs '[
{"LaunchTemplateSpecification":{"LaunchTemplateName":"ci-runner-template","Version":"$Latest"}},
]' \
--target-capacity-specification '{
"TotalTargetCapacity": 1,
"SpotTargetCapacity": 1,
"DefaultTargetCapacityType": "spot"
}' \
--spot-options '{
"AllocationStrategy": "price-capacity-optimized",
"InstanceInterruptionBehavior": "terminate"
}' \
--overrides '[
{"InstanceType":"c6i.4xlarge","SubnetId":"subnet-aaa","WeightedCapacity":1},
{"InstanceType":"c5.4xlarge","SubnetId":"subnet-aaa","WeightedCapacity":1},
{"InstanceType":"c6i.4xlarge","SubnetId":"subnet-bbb","WeightedCapacity":1},
{"InstanceType":"c5a.4xlarge","SubnetId":"subnet-bbb","WeightedCapacity":1}
]'
AllocationStrategy: price-capacity-optimized is the best strategy for CI/CD — it selects the pool with the largest capacity that also has a competitive price, so the interruption probability is lower.
Using GitLab Runner Manager (Alternative)
If using GitLab CI, gitlab-runner is available with the docker+machine executor that automatically manages the Spot Instance lifecycle. This setup is simpler because instance management is handled by the runner manager.
# /etc/gitlab-runner/config.toml
[[runners]]
name = "spot-runner"
url = "https://gitlab.com"
token = "RUNNER_TOKEN"
executor = "docker+machine"
[runners.docker]
image = "docker:latest"
[runners.machine]
IdleCount = 0 # No standby instances
IdleTime = 300 # Terminate after 5 minutes idle
MaxBuilds = 1 # Terminate after 1 job (ephemeral)
MachineDriver = "amazonec2"
MachineName = "gitlab-runner-%s"
MachineOptions = [
"amazonec2-instance-type=c6i.4xlarge",
"amazonec2-region=ap-southeast-1",
"amazonec2-request-spot-instance=true",
"amazonec2-spot-price=0.30",
"amazonec2-ami=ami-xxxx",
"amazonec2-iam-instance-profile=ci-runner-instance-profile",
"amazonec2-tags=Purpose,ci-runner",
]
When It’s Right and When It Isn’t
HIGHLY RECOMMENDED if:
✓ CI jobs consistently run > 10 minutes
✓ The required resources are large (>= 8 vCPU, >= 16 GB RAM)
✓ There are many parallel jobs that often queue
✓ The DevOps team is mature enough to manage EC2
✓ The CI infra budget is starting to get disproportionate
✓ All jobs are idempotent and retriable
NEEDS MORE CONSIDERATION if:
⚠ There are non-idempotent jobs (database migrations, deployments without rollback)
⚠ The pipeline needs very fast startup (< 1 minute)
⚠ The team isn't familiar with AWS and EC2
NOT RECOMMENDED if:
✗ CI jobs are small and fast (< 5 minutes) — the setup overhead isn't worth it
✗ Jobs can't be retried at all
✗ Pipeline frequency is very low (< 10 jobs/day)
✗ There's no monitoring and alerting for failed jobs
Summary
- CI/CD is the perfect Spot workload — stateless, ephemeral, retriable, and short duration are all characteristics that make Spot Instances ideal for runners.
- 70–90% savings isn’t theory — with consistent Spot pricing, previously expensive heavy pipelines can run at a very different cost at the scale of hundreds of jobs per day.
- Ephemeral architecture is the key — a runner is created when a job is about to start and dies after the job finishes. No standby instances idling and adding cost.
- The
--ephemeralflag is mandatory — without it, the runner stays registered on GitHub after the job finishes and could accept other jobs while the instance no longer exists.- Don’t store the Runner Token in user data as plaintext — use AWS SSM Parameter Store with KMS encryption and fetch it via an IAM role at bootstrap.
- A custom AMI significantly cuts startup time — preinstall Java, Terraform, Docker, SonarQube, and the GitHub Runner binary so boot only needs registration, not installation.
- The
if: always()cleanup job is a mandatory safety net — if the job fails before self-termination runs, the cleanup job that runs after ensures the instance is still deleted.price-capacity-optimizedis the best allocation strategy for CI/CD — it selects the pool with the largest capacity, which means a lower interruption probability than thelowest-pricestrategy.