Setting Up EC2 as a GitHub Actions Self-Hosted Runner
Learn how to create a cost-effective, scalable GitHub Actions runner using Amazon EC2 instead of relying on GitHub's hosted runners.
Learn how to create a cost-effective, scalable GitHub Actions runner using Amazon EC2 instead of relying on GitHub's hosted runners.
Overview
GitHub Actions offers hosted runners, but they come with limitations:
- Limited execution time
- Restricted customization options
- Cost considerations for private repositories
- Queue delays during peak usage
By using EC2 as a self-hosted runner, you gain:
- Full control over the environment
- Custom software installations
- Cost optimization for long-running jobs
- Dedicated resources without queueing
Architecture
GitHub Repository → Triggers Workflow → EC2 Runner → Executes Jobs
↑
CloudFormation Template
↑
Automated Setup Script
Prerequisites
- AWS CLI configured with appropriate permissions
- GitHub CLI installed and authenticated
- An existing EC2 Key Pair in your target AWS region
- GitHub repository where you want to use the runner
Step 1: Create the CloudFormation Template
Create a file named ec2-runner-stack.yaml
:
AWSTemplateFormatVersion: '2010-09-09'
Description: 'EC2 instance configured as GitHub Actions runner with Elastic IP'
Parameters:
KeyPairName:
Type: String
Default: 'your-key-pair-name'
Description: Name of the EC2 Key Pair for SSH access
GitHubToken:
Type: String
NoEcho: true
Description: GitHub Personal Access Token for runner registration
GitHubRepo:
Type: String
Default: 'username/repository-name'
Description: GitHub repository in format owner/repo
Resources:
# Security Group
RunnerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Security group for GitHub Actions runner
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: 0.0.0.0/0
Description: SSH access
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
Description: HTTP access
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
Description: HTTPS access
Tags:
- Key: Name
Value: github-runner-sg
# IAM Role for EC2 instance
EC2Role:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Tags:
- Key: Name
Value: github-runner-role
# Instance Profile
EC2InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles:
- !Ref EC2Role
# Elastic IP
RunnerElasticIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
Tags:
- Key: Name
Value: github-runner-eip
# EC2 Instance
GitHubRunnerInstance:
Type: AWS::EC2::Instance
Properties:
ImageId: ami-05ec1e5f7cfe5ef59 # Ubuntu 22.04 LTS in us-east-1 (latest)
InstanceType: t3.medium
KeyName: !Ref KeyPairName
SecurityGroupIds:
- !Ref RunnerSecurityGroup
IamInstanceProfile: !Ref EC2InstanceProfile
UserData:
Fn::Base64: !Sub |
#!/bin/bash
# Update system
apt-get update -y
apt-get upgrade -y
# Install required packages
apt-get install -y curl wget git jq build-essential
# Install Docker
apt-get install -y apt-transport-https ca-certificates gnupg lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io
systemctl start docker
systemctl enable docker
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
apt-get install -y nodejs
# Create runner user
useradd -m -s /bin/bash runner
usermod -aG docker runner
# Create runner directory
mkdir -p /home/runner/actions-runner
cd /home/runner/actions-runner
# Download and extract GitHub Actions runner
RUNNER_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/v//')
curl -o actions-runner-linux-x64-$RUNNER_VERSION.tar.gz -L https://github.com/actions/runner/releases/download/v$RUNNER_VERSION/actions-runner-linux-x64-$RUNNER_VERSION.tar.gz
tar xzf ./actions-runner-linux-x64-$RUNNER_VERSION.tar.gz
rm actions-runner-linux-x64-$RUNNER_VERSION.tar.gz
# Change ownership
chown -R runner:runner /home/runner/actions-runner
# Configure runner as runner user
sudo -u runner bash -c "cd /home/runner/actions-runner && ./config.sh --url https://github.com/${GitHubRepo} --token ${GitHubToken} --name ec2-runner-$(hostname) --work _work --unattended --replace"
# Install and start runner service
./svc.sh install runner
./svc.sh start
# Create log file
echo "GitHub Actions runner setup completed at $(date)" > /var/log/runner-setup.log
Tags:
- Key: Name
Value: github-actions-runner
# Associate Elastic IP with instance
EIPAssociation:
Type: AWS::EC2::EIPAssociation
Properties:
EIP: !Ref RunnerElasticIP
InstanceId: !Ref GitHubRunnerInstance
Outputs:
InstanceId:
Description: EC2 Instance ID
Value: !Ref GitHubRunnerInstance
Export:
Name: !Sub "${AWS::StackName}-InstanceId"
ElasticIP:
Description: Elastic IP address
Value: !Ref RunnerElasticIP
Export:
Name: !Sub "${AWS::StackName}-ElasticIP"
SSHCommand:
Description: SSH command to connect to the instance
Value: !Sub "ssh -i ~/.ssh/${KeyPairName}.pem ubuntu@${RunnerElasticIP}"
Step 2: Generate GitHub Personal Access Token
Generate a registration token for your runner:
gh api --method POST -H "Accept: application/vnd.github+json" \
repos/username/repository-name/actions/runners/registration-token
Step 3: Deploy the Infrastructure
Deploy your CloudFormation stack:
aws cloudformation deploy \
--template-file ec2-runner-stack.yaml \
--stack-name github-runner-stack \
--parameter-overrides \
KeyPairName=your-key-pair-name \
GitHubToken=your-github-token \
GitHubRepo=username/repository-name \
--capabilities CAPABILITY_IAM \
--region us-east-1
Step 4: Configure the Runner Manually (if needed)
If the automatic setup fails, SSH into the instance and configure manually:
# SSH into the instance
ssh -i ~/.ssh/your-key.pem ubuntu@your-elastic-ip
# Generate new registration token
gh api --method POST repos/username/repo/actions/runners/registration-token
# Configure the runner
sudo -u runner bash -c 'cd /home/runner/actions-runner && \
./config.sh --url https://github.com/username/repo \
--token YOUR-REGISTRATION-TOKEN \
--name ec2-runner --work _work --unattended --replace'
# Start the service
sudo bash -c 'cd /home/runner/actions-runner && \
./svc.sh install runner && ./svc.sh start'
Step 5: Create Test Workflow
Create .github/workflows/test-runner.yml
:
name: Test EC2 Runner
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
schedule:
- cron: "*/5 * * * *" # Every 5 minutes
jobs:
test-on-ec2:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Test system info
run: |
echo "Runner is working!"
echo "Hostname: $(hostname)"
echo "OS: $(uname -a)"
echo "Current user: $(whoami)"
echo "Working directory: $(pwd)"
echo "Available disk space:"
df -h
- name: Test Docker
run: |
echo "Testing Docker installation..."
docker --version
docker run hello-world
- name: Test Node.js
run: |
echo "Testing Node.js installation..."
node --version
npm --version
- name: Create test file with timestamp
run: |
echo "Hello from EC2 runner!" > test-output.txt
echo "Scheduled run at: $(date)" >> test-output.txt
echo "UTC Time: $(date -u)" >> test-output.txt
cat test-output.txt
- name: Test git operations
run: |
git config --global user.email "[email protected]"
git config --global user.name "EC2 Runner Test"
echo "Git configuration complete"
Step 6: Verify Runner Registration
Check that your runner is registered and online:
gh api repos/username/repository-name/actions/runners
Expected output:
{
"total_count": 1,
"runners": [
{
"id": 123,
"name": "ec2-runner",
"os": "Linux",
"status": "online",
"busy": false,
"labels": [
{"name": "self-hosted"},
{"name": "Linux"},
{"name": "X64"}
]
}
]
}
Testing the Setup
-
Manual trigger: Test immediate execution
gh workflow run test-runner.yml --repo username/repository-name
-
Push trigger: Make a commit to trigger the workflow
-
Scheduled trigger: Wait for the cron schedule to execute
Understanding Scheduling Delays
Important Note: Even with self-hosted runners, you may experience delays with scheduled workflows. This is because:
- GitHub's cron scheduler is what triggers workflows (affects everyone)
- Your EC2 runner executes jobs immediately once triggered
- Scheduling delays are a GitHub platform limitation, not a runner issue
What you'll see:
- ✅ Manual workflows execute in seconds
- ✅ Push-triggered workflows execute immediately
- ⚠️ Scheduled workflows may have unpredictable delays (normal GitHub behavior)
Cost Considerations
EC2 Costs (t3.medium in us-east-1):
- On-demand:
$0.0416-$0.0441/hour ($30.37/month) - Spot instances: Up to 90% savings
- Reserved instances: Additional savings for long-term use
Elastic IP: $0.005/hour when not attached to running instance
Data transfer: Minimal for typical CI/CD workflows
Security Best Practices
- Restrict Security Group: Only allow necessary ports
- Use IAM roles: Avoid hardcoded credentials
- Keep runner updated: Regular security patches
- Monitor access: CloudTrail logging
- Private repositories: Ensure runner has appropriate access levels
Troubleshooting
Runner not appearing in GitHub
# Check runner service status
sudo systemctl status actions.runner.*
# View runner logs
sudo journalctl -u actions.runner.* -f
Runner offline
# Restart runner service
sudo systemctl restart actions.runner.*
# Re-register runner with new token
gh api --method POST repos/username/repo/actions/runners/registration-token
Workflow not triggering
- Verify workflow file is in
.github/workflows/
- Check workflow syntax with GitHub's workflow validator
- Ensure runner labels match workflow requirements
Scaling Considerations
For production use, consider:
- Auto Scaling Groups: Automatically scale runners based on demand
- Spot Fleet: Mix of instance types for cost optimization
- Multiple availability zones: High availability setup
- Container-based runners: Use Docker for job isolation
- Runner pools: Separate runners for different job types
Conclusion
Setting up EC2 as a GitHub Actions runner provides significant advantages over hosted runners:
- Complete environment control
- Cost optimization opportunities
- No execution queuing delays
- Custom software installations
While you may still experience GitHub's inherent scheduling delays, your jobs will execute immediately once triggered, providing a much more responsive CI/CD experience.
The infrastructure-as-code approach using CloudFormation ensures reproducible, version-controlled deployments that can be easily maintained and scaled.