AWS Builder Center
Building Ephemeral GitHub Actions Self-Hosted Runners on AWS Fargate with Webhook-Driven Auto-Scaling

Building Ephemeral GitHub Actions Self-Hosted Runners on AWS Fargate with Webhook-Driven Auto-Scaling

Stop paying for idle runners. Deploy ephemeral GitHub Actions runners on AWS Fargate that launch only when jobs are queued and self-terminate after execution; pay only for actual CI/CD compute time. Security-first: KMS encryption, VPC endpoints, egress-only networking, HMAC-validated webhooks, and least-privilege IAM. No persistent infrastructure, no attack surface when idle.

DevSecOps

Building Ephemeral GitHub Actions Self-Hosted Runners on AWS Fargate with Webhook-Driven Auto-Scaling

The Problem

GitHub-hosted runners are convenient but expensive at scale. Self-hosted runners on EC2 save money but introduce a new problem: idle infrastructure. A runner sitting idle waiting for jobs still costs money and expands your attack surface.
What if runners only existed when there's work to do?

The Solution

Ephemeral runners on AWS Fargate that:
  • Launch on-demand when a workflow job is queued
  • Self-terminate immediately after the job completes
  • Cost nothing when idle — zero running tasks, zero charges
  • Leave no attack surface when not in use

Architecture

Cost Savings

Before: Always-on EC2 runners

ResourceMonthly Cost
2x t3.large (24/7)~$120
EBS storage~$16
Total~$136/month

After: Ephemeral Fargate runners

ResourceMonthly Cost
Fargate tasks (avg 2hr/day compute)~$18
Lambda invocations~$0.10
SQS, API Gateway~$0.50
Total~$19/month
Savings vs EC2: ~86% — and it scales to zero automatically.

vs GitHub-Hosted Runners

GitHub charges $0.008/min for Linux runners. Fargate with 2 vCPU / 10 GB costs approximately $0.002/min.
Scenario (60 min CI/day)GitHub-HostedFargate EphemeralSavings
1 runner$14.40/mo$3.60/mo75%
5 parallel runners$72/mo$18/mo75%
10 parallel runners$144/mo$36/mo75%
20 parallel runners$288/mo$72/mo75%
At scale, the difference is dramatic. A team running 500 minutes/day of CI saves ~$90/month with just 5 runners. And unlike GitHub-hosted runners, you get:
  • Full control over installed tools and OS
  • Access to private VPC resources (databases, internal APIs)
  • No queue wait times during peak hours
  • Consistent performance (no noisy neighbors)
The math is simple: Fargate bills per second. If your CI runs 2 hours per day, you pay for 2 hours. Not 24.

Security-First Design

Every component follows least-privilege and encryption-by-default principles:

Encryption at Rest (KMS)

A single customer-managed KMS key encrypts everything:
  • ECR images
  • Secrets Manager secrets
  • SQS messages
  • CloudWatch Logs
  • S3 artifacts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
KmsKey:
Type: AWS::KMS::Key
Properties:
Description: KMS Key for GitHub Runner infrastructure
KeyPolicy:
Version: "2012-10-17"
Statement:
- Sid: AllowCloudWatchLogs
Effect: Allow
Principal:
Service: !Sub "logs.${AWS::Region}.amazonaws.com"
Action:
- "kms:Encrypt"
- "kms:Decrypt"
- "kms:GenerateDataKey*"
Resource: "*"

Network Isolation

  • Runners in private subnets with NAT Gateway (no public IP)
  • Egress-only security group — no inbound traffic possible
  • S3 VPC Gateway Endpoint — artifact access never leaves the VPC
  • HTTPS enforced via bucket policy (aws:SecureTransport)
1
2
3
4
5
6
7
8
9
RunnerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Egress-only SG for ephemeral runners
VpcId: !Ref VpcId
SecurityGroupEgress:
- IpProtocol: -1
CidrIp: "0.0.0.0/0"
# No SecurityGroupIngress = no inbound rules

Webhook Validation

Every incoming webhook is validated with HMAC-SHA256 before processing:
1
2
3
4
5
6
def _verify_signature(body_bytes, signature_header):
if not signature_header or not signature_header.startswith('sha256='):
return False
secret = _get_webhook_secret().encode('utf-8')
expected = 'sha256=' + hmac.HMAC(secret, body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)

Ephemeral = No Persistent Attack Surface

  • Runners exist for minutes, not days
  • No SSH access, no persistent credentials
  • Container filesystem is destroyed after each job
  • PAT stored in Secrets Manager, injected at runtime only

Non-root Container

1
2
USER runner
ENTRYPOINT ["/entrypoint.sh"]
The runner process never runs as root. If a malicious workflow compromises the container, the blast radius is minimized.

Implementation

1. Docker Image (Custom, Controlled)

Two-stage build for full control over what's in the image:
Dockerfile.base — System dependencies:
1
2
3
4
5
FROM ubuntu:24.04@sha256:4fbb8e6a... # Pinned digest

COPY --chmod=700 build/ /tmp/build/
RUN /tmp/build/install_base.sh && rm -rf /tmp/build
USER runner
build/config.json — Declarative package manifest:
1
2
3
4
5
6
7
8
{
"install": [
{ "category": "development", "source": "apt", "packages": ["build-essential", "zstd"] },
{ "category": "docker", "source": "script", "packages": ["docker"] },
{ "category": "git", "source": "script", "packages": ["git", "git-lfs"] },
{ "category": "yarn", "source": "script", "packages": ["yarn"] }
]
}
Want to remove a tool? Delete the line. Want to add one? Add an entry and an install function.

2. CI/CD Pipeline (CodePipeline + CodeBuild)

Automated image builds triggered on push:
1
2
3
4
5
6
7
CodeBuildProject:
Type: AWS::CodeBuild::Project
Properties:
Environment:
PrivilegedMode: true # Required for docker build
Source:
BuildSpec: buildspec.yml

3. Webhook-Driven Scaling

The webhook receiver processes both queued and waiting events — this is critical for jobs with needs: dependencies:
1
2
3
action = payload.get('action')
if action not in ('queued', 'waiting'):
return {'statusCode': 200, 'body': f'ignored (action={action})'}
Rate control via SQS MaximumConcurrency prevents overwhelming Fargate API limits during burst scenarios (e.g., 10 parallel test jobs):
1
2
3
4
5
TaskLauncherEventSourceMapping:
Type: AWS::Lambda::EventSourceMapping
Properties:
ScalingConfig:
MaximumConcurrency: 5

4. Idle Timeout Watchdog

Prevents orphaned runners from running indefinitely (and costing money):
1
2
3
4
5
6
7
8
_JOBS_ACCEPTANCE_TIMEOUT=${JOBS_ACCEPTANCE_TIMEOUT:-300}
if [[ ${_JOBS_ACCEPTANCE_TIMEOUT} -gt 0 ]] && [[ -n "${EPHEMERAL}" ]]; then
(
sleep "${_JOBS_ACCEPTANCE_TIMEOUT}"
# If still idle, terminate
kill -TERM $$ 2>/dev/null
) &
fi

Deployment

Single CloudFormation stack deploys everything:
1
2
3
4
5
6
7
8
9
10
11
12
aws cloudformation deploy \
--template-file infra/template.yaml \
--stack-name github-runners \
--parameter-overrides \
EnvironmentName=prod \
GitHubOrg=your-org \
GitHubPAT=ghp_xxx \
GitHubWebhookSecret=your-secret \
VpcId=vpc-xxx \
SubnetIds=subnet-a,subnet-b \
CodeStarConnectionArn=arn:aws:codestar-connections:... \
--capabilities CAPABILITY_NAMED_IAM
Then configure the org webhook pointing to the WebhookUrl output.

Summary

AspectResult
CostPay only for active CI compute (~86% savings)
SecurityKMS encryption, private networking, ephemeral, non-root
ScalingZero to N runners in ~55 seconds
MaintenanceRebuild image on push, single CloudFormation stack
ControlFull control over installed tools and OS packages
The complete source code is available on GitHub: danf22/Github-Runners-ECS . All infrastructure deploys from a single template.yaml no ClickOps required.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article