
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.
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
| Resource | Monthly Cost |
|---|---|
| 2x t3.large (24/7) | ~$120 |
| EBS storage | ~$16 |
| Total | ~$136/month |
After: Ephemeral Fargate runners
| Resource | Monthly 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-Hosted | Fargate Ephemeral | Savings |
|---|---|---|---|
| 1 runner | $14.40/mo | $3.60/mo | 75% |
| 5 parallel runners | $72/mo | $18/mo | 75% |
| 10 parallel runners | $144/mo | $36/mo | 75% |
| 20 parallel runners | $288/mo | $72/mo | 75% |
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 rulesWebhook 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 runnerbuild/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.yml3. 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: 54. 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
) &
fiDeployment
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_IAMThen configure the org webhook pointing to the
WebhookUrl output.Summary
| Aspect | Result |
|---|---|
| Cost | Pay only for active CI compute (~86% savings) |
| Security | KMS encryption, private networking, ephemeral, non-root |
| Scaling | Zero to N runners in ~55 seconds |
| Maintenance | Rebuild image on push, single CloudFormation stack |
| Control | Full 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.Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article