Setting Up EC2 as a GitHub Actions Self-Hosted Runner

Ifeoluwa Akande
15 min read
AWS
EC2
GitHub Actions
DevOps
CI/CD
CloudFormation

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

  1. Manual trigger: Test immediate execution

    gh workflow run test-runner.yml --repo username/repository-name
    
  2. Push trigger: Make a commit to trigger the workflow

  3. 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

  1. Restrict Security Group: Only allow necessary ports
  2. Use IAM roles: Avoid hardcoded credentials
  3. Keep runner updated: Regular security patches
  4. Monitor access: CloudTrail logging
  5. 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:

  1. Auto Scaling Groups: Automatically scale runners based on demand
  2. Spot Fleet: Mix of instance types for cost optimization
  3. Multiple availability zones: High availability setup
  4. Container-based runners: Use Docker for job isolation
  5. 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.