Background: The Allure of Spot Instances
Many teams chase cost savings by running self‑hosted GitHub Actions runners on spot or pre‑emptible VMs. The price advantage looks attractive, especially when the CI workload is tied to edge services that need frequent, low‑latency updates. However, the volatility of spot capacity introduces subtle failure modes that can cripple deployment pipelines, increase latency, and even expose production systems to partial updates.
Why Spot‑Based Runners Are a Liability
The core problem is not the price tag but the non‑deterministic availability of the underlying compute. When a spot instance is reclaimed, any in‑progress job is terminated abruptly. GitHub Actions does not retry a cancelled job automatically; it marks the workflow as failed. For edge services that rely on atomic rollouts, a half‑finished deployment can leave stale containers, mismatched configuration files, or orphaned network routes.
A second issue is state persistence. Spot VMs often lack persistent storage, so caches, build artifacts, and Docker layer directories disappear on each pre‑empt. Re‑building from scratch on every run inflates CI time, erodes the cost benefit, and adds noise to performance metrics.
Finally, spot instances are typically placed in regions far from the edge nodes they ultimately serve. Network latency during artifact upload can dominate the total pipeline duration, defeating the very purpose of edge‑centric deployment.
Designing a Resilient Alternative
The recommended pattern is to combine dedicated on‑prem or reserved cloud runners with a lightweight fallback that runs only when the primary fleet is saturated. This hybrid approach preserves cost efficiency while guaranteeing that critical jobs complete.
# .github/workflows/edge-deploy.yml
name: Edge Deploy
on:
push:
branches: [ main ]
jobs:
build:
runs-on: self-hosted
# Prefer dedicated runners first
strategy:
matrix:
runner:
- dedicated
- fallback
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build Docker image
run: |
docker build -t myapp:${{ github.sha }} .
docker save myapp:${{ github.sha }} -o myapp.tar
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: image
path: myapp.tar
deploy:
needs: build
runs-on: self-hosted
steps:
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: image
- name: Transfer to edge node
run: |
scp -i ~/.ssh/edge_key myapp.tar [email protected]:/tmp/
ssh -i ~/.ssh/edge_key [email protected] '
docker load -i /tmp/myapp.tar &&
docker tag myapp:${{ github.sha }} myapp:latest &&
docker compose -f /opt/myapp/docker-compose.yml up -d
'
In this workflow we define a matrix that prefers the dedicated runner label. If no dedicated runner is free, the job falls back to a fallback label that can be backed by spot instances. The fallback is explicitly marked with a lower priority and limited to non‑critical jobs (e.g., nightly builds). Critical edge deployments always run on dedicated hardware.
Provisioning Dedicated Runners with Terraform
Below is a minimal Terraform configuration that creates a small autoscaling group of always‑on EC2 instances (or equivalent VMs) and tags them for GitHub Actions registration. The instances use a reserved‑instance purchase option to keep costs predictable.
# terraform/main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_launch_configuration" "gh_actions" {
name_prefix = "gh-actions-"
image_id = data.aws_ami.ubuntu.id
instance_type = "t3.medium"
associate_public_ip_address = true
lifecycle {
create_before_destroy = true
}
user_data = <<-EOF
#!/bin/bash
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf actions-runner-linux-x64-2.311.0.tar.gz
./config.sh --url https://github.com/yourorg/yourrepo --token ${var.runner_token} --labels dedicated
./svc.sh install
./svc.sh start
EOF
}
resource "aws_autoscaling_group" "gh_actions_asg" {
name = "gh-actions-asg"
launch_configuration = aws_launch_configuration.gh_actions.name
min_size = 2
max_size = 5
desired_capacity = 3
vpc_zone_identifier = ["subnet-abc123", "subnet-def456"]
health_check_type = "EC2"
health_check_grace_period = 300
}
The --labels dedicated flag ensures that GitHub Actions can target these runners explicitly. Because the ASG maintains a minimum of two instances, a single instance failure never blocks the pipeline.
Implementing a Graceful Fallback to Spot
Spot runners are still useful for low‑priority workloads. We provision them in a separate ASG and register them with the fallback label. The key is to limit the types of jobs they can accept.
# terraform/spot-runner.tf
resource "aws_launch_configuration" "gh_actions_spot" {
name_prefix = "gh-actions-spot-"
image_id = data.aws_ami.ubuntu.id
instance_type = "t3.medium"
spot_price = "0.0123"
associate_public_ip_address = true
user_data = <<-EOF
#!/bin/bash
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf actions-runner-linux-x64-2.311.0.tar.gz
./config.sh --url https://github.com/yourorg/yourrepo --token ${var.runner_token} --labels fallback
./svc.sh install
./svc.sh start
EOF
}
resource "aws_autoscaling_group" "gh_actions_spot_asg" {
name = "gh-actions-spot-asg"
launch_configuration = aws_launch_configuration.gh_actions_spot.name
min_size = 0
max_size = 3
desired_capacity = 1
vpc_zone_identifier = ["subnet-abc123", "subnet-def456"]
health_check_type = "EC2"
health_check_grace_period = 300
termination_policies = ["OldestInstance"]
}
Note the min_size = 0 – the spot fleet scales down to zero when capacity is not needed, eliminating idle costs. Because spot instances can disappear at any moment, we keep their job list short and stateless.
Monitoring Runner Health
A simple Bash watchdog can alert you when a dedicated runner becomes unavailable. The script polls the GitHub API for the runner’s status and sends a Slack notification if the runner is offline for more than five minutes.
# monitor-runners.sh
#!/usr/bin/env bash
GITHUB_TOKEN="YOUR_PERSONAL_TOKEN"
ORG="yourorg"
REPO="yourrepo"
SLACK_WEBHOOK="https://hooks.slack.com/services/XXX/YYY/ZZZ"
while true; do
offline=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$ORG/$REPO/actions/runners" |
jq -r '.runners[] | select(.labels[]=="dedicated") | select(.status!="online") | .name')
if [[ -n "$offline" ]]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"⚠️ Runner $offline is offline!\"}" $SLACK_WEBHOOK
fi
sleep 300
done
Deploy this script as a systemd service on a monitoring host. By catching runner outages early, you avoid the scenario where a spot pre‑empt knocks out the only available runner for a critical deployment.
Security and Best Practices
- Never store runner tokens in plain text. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault) and inject the token at launch time.
- Restrict SSH access. Configure
AllowUsersin/etc/ssh/sshd_configto accept only the GitHub Actions service account. - Enable
♥ Enjoyed this article? Use the like banner at the top of the page to let us know — you can like it more than once! Each additional like from the same reader carries a little less weight than the first, so our appreciation scores reflect genuine enthusiasm rather than accidental clicks. Your feedback helps us understand which topics resonate most.