Using AWS Spot Instances as CI/CD Runners
14 min read

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 TypevCPURAMOn-Demand/hourSpot/hourSavings
c6i.2xlarge816 GB$0.34~$0.09~73%
c6i.4xlarge1632 GB$0.68~$0.18~74%
m6i.4xlarge1664 GB$0.768~$0.19~75%
c6i.8xlarge3264 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:#e53935

There 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 terminated

The 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 inject RUNNER_TOKEN directly into the user data script as plaintext if it can be avoided. User data scripts can be read by anyone with DescribeInstances access. 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 --ephemeral flag 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-optimized is the best allocation strategy for CI/CD — it selects the pool with the largest capacity, which means a lower interruption probability than the lowest-price strategy.

Portfolio