Self-Hosted GitHub Actions Runners: When Free Minutes Run Out

by Cristian Caiazzo
GitHub Actions DevOps CI/CD Docker Self-Hosted

Running out of free GitHub Actions minutes? Here's how to set up your own self-hosted runner for unlimited CI/CD workflows.

Self-Hosted GitHub Actions Runners: When Free Minutes Run Out

It happened to me too, one night a few months ago: I was working on a personal project with a fairly large monorepo. Every push triggered Docker builds, tests, and deployments. Everything was going smoothly until around 2 AM when I noticed GitHub Actions had stopped. Out of minutes. Build stuck halfway. And there I was, needing to push an urgent deploy.

That’s when I realized I needed a solution. And I found it: self-hosted runners.


🎯 The Problem: Limited Free Minutes (And My Real Case)

GitHub Actions is fantastic, truly.

But let’s be honest: when working on private projects, the free tier limits hit sooner than you think:

In my case, I had a monorepo with several services.

Every push triggered:

Quick math: about 8-10 minutes per workflow. With 5-6 pushes a day (active development), that’s 50 minutes daily. In a month? Over 1,500 minutes. And that’s without counting rebuilds for urgent fixes.

Recently, I started experimenting with GitHub Copilot’s agent mode for some projects. While it’s incredibly powerful for automating development tasks, I quickly realized it consumes runner minutes too — and not just a few. The agents run on your GitHub Actions runners to execute their tasks, which can add up fast. If you’re already pushing your minute limits with regular CI/CD, adding Copilot agents into the mix might tip you over the edge, leaving you without minutes for your critical deployments.

The good news? You can use your own server to run GitHub Actions workflows — completely free, no minute limits, and you’re in full control.


💡 What Are Self-Hosted Runners?

Think of GitHub’s hosted runners as renting a car: convenient, but you pay per mile.

Self-hosted runners are like owning your own car: upfront cost, but unlimited usage.

For example, I have a small VPS that I was already using for other projects. I configured it as a runner and now I use it for:

GitHub-Hosted vs Self-Hosted

AspectGitHub-HostedSelf-Hosted
CostMetered (free tier limits)Your hardware/cloud costs
SetupZero — just use itRequires configuration
MaintenanceFully managed by GitHub (OS updates, security patches, tools)You manage everything (updates, patches, security, tools)
InfrastructureAzure VMs managed by GitHubYour own servers/VMs/cloud instances
CustomizationLimitedFull control
ScalingAutomaticManual (or scripted)
SecurityIsolated, GitHub-managedYour responsibility
SpeedGoodPotentially faster (local network)

Important: GitHub-hosted runners run on fresh virtual machines in Microsoft Azure that are automatically provisioned, updated, and destroyed after each job. With self-hosted runners, you are responsible for all operating system updates, security patches, tool installations, and general maintenance.

When Self-Hosted Makes Sense ✅

In my case, I chose self-hosted for these reasons:

However, consider self-hosted when:

When I DON’T Recommend Self-Hosted ⚠️

Being honest is important. Self-hosted isn’t always the right solution.

I wouldn’t use it myself in these cases:

⚠️ Security Warning: Self-hosted runners on public repositories are a significant security risk. Malicious actors can submit pull requests that execute arbitrary code on your infrastructure. Always use GitHub-hosted runners for public repos, or enable “Require approval for all outside collaborators” in your repository’s Actions settings.

A trick I learned: you can combine both approaches. I use GitHub-hosted for quick tests and self-hosted only for heavy Docker builds.


🚀 Setting Up a Self-Hosted Runner

Let’s walk through setting up a self-hosted runner. I’ll assume you have a Linux server (physical, VM, or cloud instance).

Prerequisites

Before we start, make sure you have:

# A Linux server with:
# - Ubuntu 20.04+ (or similar)
# - Stable internet connection
# - At least 2GB RAM and 20GB disk space

Note: Docker is not required for basic runner setup. You’ll only need Docker if you plan to build container images in your workflows. We’ll cover Docker-specific workflows later in this article.

Step 1: Navigate to Your Repository Settings

  1. Go to your GitHub repository
  2. Click SettingsActionsRunners
  3. Click New self-hosted runner
  4. Choose your OS (we’ll use Linux)

GitHub will show you the exact commands to run. Here’s the general flow:

Step 2: Download and Configure the Runner

# Create a folder for the runner
mkdir actions-runner && cd actions-runner

# Download the latest runner package
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

# Extract the installer
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz

Pro Tip: Check the GitHub UI for the latest version URL — runner versions update frequently.

Step 3: Configure the Runner

# Configure the runner (use the token from GitHub UI)
./config.sh --url https://github.com/YOUR_USERNAME/YOUR_REPO \
  --token YOUR_RUNNER_TOKEN

# You'll be prompted for:
# - Runner name (e.g., "my-server-runner")
# - Runner group (default is fine)
# - Labels (optional, but useful for targeting)
# - Work folder (default is fine)

Important: About the Registration Token

The token shown in the GitHub UI is a short-lived registration token (valid for 1 hour). This token is used only during the initial runner configuration to authenticate and register the runner with your repository or organization.

Once configured, the runner uses a different authentication mechanism (a runner token stored in the .runner file) to communicate with GitHub Actions. The registration token cannot be reused after initial setup.

Key points:

  • The registration token expires after exactly 1 hour
  • You need to generate a new token each time you configure a new runner
  • The token is displayed in Settings → Actions → Runners → New self-hosted runner
  • Never share or commit this token to version control

Important labels to consider:

GitHub recommends using labels to help target specific runners in your workflows. Labels allow you to route jobs to runners with specific capabilities or characteristics.

# Example with custom labels
./config.sh --url https://github.com/YOUR_USERNAME/YOUR_REPO \
  --token YOUR_RUNNER_TOKEN \
  --labels self-hosted,linux,x64,docker,my-server

Default labels automatically assigned:

Custom labels let you add specific capabilities:

Labels help you target specific runners in your workflows (see examples in the “Using Your Self-Hosted Runner” section below).

Step 4: Install as a Service

Instead of running the runner manually, let’s set it up as a systemd service:

# Install the service
sudo ./svc.sh install

# Start the service
sudo ./svc.sh start

# Check status
sudo ./svc.sh status

This ensures the runner starts automatically on boot and restarts if it crashes.

Step 5: Verify in GitHub

Head back to your repository’s SettingsActionsRunners. You should see your runner listed as Idle (green dot).


🔧 Using Your Self-Hosted Runner

Now that your runner is set up, let’s use it in a workflow.

Basic Workflow Example

name: Build on Self-Hosted Runner

on:
  push:
    branches: [ main ]

jobs:
  build:
    # Use your self-hosted runner
    runs-on: self-hosted
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Build project
        run: |
          echo "Building on my own server!"
          npm install
          npm run build
      
      - name: Run tests
        run: npm test

Targeting Specific Runners with Labels

If you have multiple self-hosted runners, use labels to target specific ones:

jobs:
  build-docker:
    # Only run on self-hosted runners with 'docker' label
    runs-on: [self-hosted, docker]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Build Docker image
        run: docker build -t myapp:latest .

Combining GitHub-Hosted and Self-Hosted

You can use both in the same workflow:

jobs:
  test:
    # Fast tests on GitHub-hosted
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test
  
  deploy:
    # Heavy deployment on self-hosted
    runs-on: self-hosted
    needs: test
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

📦 Working with Docker and GitHub Container Registry (GHCR)

This section is for users who need to build and push Docker images. If your workflows don’t involve Docker, you can skip this section. For standard use cases (building code, running tests, deploying applications), the basic setup above is all you need!

If you’re building Docker images in your CI/CD pipeline, you can use GitHub Container Registry (GHCR) with both GitHub-managed and self-hosted runners. The main difference with self-hosted runners is the authentication setup — let’s see how it changes.

Prerequisites for Docker Workflows

If you want to use your self-hosted runner for Docker-related tasks, you’ll need:

# Install Docker on your runner server
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add the runner user to the docker group (if using a service account)
sudo usermod -aG docker $USER

# Verify Docker is working
docker --version

Why GHCR?

GitHub Container Registry offers:

Note: These GHCR benefits apply to both GitHub-managed and self-hosted runners!

Authentication: GitHub-Managed vs Self-Hosted Runners

The key difference when using self-hosted runners is how you authenticate with GHCR.

On GitHub-Managed Runners (Default)

GitHub-managed runners use the built-in GITHUB_TOKEN:

- name: Log in to GitHub Container Registry
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}  # Built-in token

On Self-Hosted Runners (Requires PAT)

Self-hosted runners need a Personal Access Token (PAT) and your GitHub username:

- name: Log in to GitHub Container Registry
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: <your-github-username>  # Replace with your username
    password: ${{ secrets.GHCR_TOKEN }}  # Your PAT token

Setting Up PAT for Self-Hosted Runners

To use GHCR with self-hosted runners, you need to create a Personal Access Token.

Step 1: Create a Personal Access Token

  1. Go to GitHub SettingsDeveloper settingsPersonal access tokensTokens (classic)
  2. Click Generate new token (classic)
  3. Give it a descriptive name (e.g., “GHCR Access”)
  4. Select scopes:
    • write:packages (for pushing)
    • read:packages (for pulling)
    • delete:packages (optional, for cleanup)
  5. Generate and copy the token (you won’t see it again!)

Step 2: Add Token as a Secret

  1. Go to your repository SettingsSecrets and variablesActions
  2. Click New repository secret
  3. Name: GHCR_TOKEN
  4. Value: paste your PAT
  5. Click Add secret

Security Note: Never commit tokens to your repository. Always use GitHub Secrets!

Building and Pushing to GHCR

Here’s a complete example using docker/build-push-action:

name: Build and Push to GHCR

on:
  push:
    branches: [ main ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: self-hosted  # Using our self-hosted runner
    permissions:
      contents: read
      packages: write  # Required for GHCR
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      # Set up Docker Buildx for advanced build features
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      # Log in to GHCR using PAT (required for self-hosted runners)
      # Note: GitHub-managed runners can use secrets.GITHUB_TOKEN and github.actor
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: <your-github-username>  # Replace with your GitHub username
          password: ${{ secrets.GHCR_TOKEN }}
      
      # Extract metadata for tagging
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # Tag with branch name
            type=ref,event=branch
            # Tag with commit SHA
            type=sha,prefix={{branch}}-
            # Tag as 'latest' for main branch
            type=raw,value=latest,enable={{is_default_branch}}
      
      # Build and push Docker image
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # Use local cache for faster builds
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
      
      # Rotate cache to prevent unlimited growth
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache

Understanding the Workflow

Let’s break down the important parts:

Docker Buildx

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

Buildx provides:

Metadata Action

- name: Extract Docker metadata
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
    tags: |
      type=ref,event=branch
      type=sha,prefix={{branch}}-
      type=raw,value=latest,enable={{is_default_branch}}

This creates intelligent tags:

Build and Push

- name: Build and push Docker image
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=local,src=/tmp/.buildx-cache
    cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

Key features:


🔐 GHCR vs Docker Hub: Key Differences

If you’re coming from Docker Hub, here are the main differences with GHCR:

FeatureDocker HubGHCR
Image URLdocker.io/user/imageghcr.io/user/image
AuthenticationUsername + Password/TokenGitHub username + Token (PAT for self-hosted, GITHUB_TOKEN for managed)
PermissionsSeparate systemIntegrated with GitHub
Rate Limits100 pulls/6h (anonymous), 200 pulls/6h (free)No limits (authenticated)
Private Images1 free repo, then paidIncluded with GitHub plan
IntegrationManual setupNative GitHub Actions support

Pulling from GHCR on Your Server

To pull images from GHCR on your self-hosted runner or any other server:

# Log in to GHCR
echo $GHCR_TOKEN | docker login ghcr.io -u USERNAME --password-stdin

# Pull the image
docker pull ghcr.io/username/repo:latest

# Run it
docker run -d ghcr.io/username/repo:latest

Using in Docker Compose

services:
  app:
    image: ghcr.io/username/myapp:latest
    # ... other config

Before running docker-compose up, make sure you’re logged in:

docker login ghcr.io -u USERNAME -p $GHCR_TOKEN
docker-compose up -d

🛡️ Security Considerations

Running self-hosted runners comes with security responsibilities. Here’s what you need to know.

1. Isolation is Critical

Problem: Self-hosted runners can access your local network and resources.

Solution:

# Run runner in a container or VM
docker run -d \
  --name github-runner \
  --restart unless-stopped \
  -v /var/run/docker.sock:/var/run/docker.sock \
  your-runner-image:latest

Or use ephemeral runners that are destroyed after each job.

2. Be Careful with Public Repositories

Problem: Anyone can fork your public repository and open a pull request containing malicious code that will execute on your self-hosted runner. This is a critical security risk.

Real-world risks:

GitHub’s official recommendation: Do not use self-hosted runners with public repositories unless you have strict security measures in place.

Solutions:

⚠️ GitHub Security Advisory: “We strongly recommend that you do NOT use self-hosted runners with public repositories. Forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow.” — GitHub Docs: About self-hosted runners

3. Secrets Management

Never hard-code secrets:

# ❌ WRONG
- run: docker login -u myuser -p mypassword

# ✅ CORRECT (for self-hosted runners)
- run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u <your-github-username> --password-stdin

4. Network Security

Consider firewall rules:

# Only allow outbound connections to GitHub
sudo ufw allow out to github.com
sudo ufw allow out to ghcr.io

# Block all other outbound by default
sudo ufw default deny outgoing

🎛️ Advanced: Multiple Runners for Scaling

Need more capacity? Run multiple runners on the same or different machines.

Same Machine, Multiple Runners

# Create separate directories
mkdir ~/actions-runner-1
mkdir ~/actions-runner-2

# Configure each with different names
cd ~/actions-runner-1
./config.sh --url ... --name runner-1

cd ~/actions-runner-2
./config.sh --url ... --name runner-2

# Install both as services
sudo ~/actions-runner-1/svc.sh install
sudo ~/actions-runner-2/svc.sh install

Auto-Scaling with Runner Groups

For enterprise needs, consider:


📊 Monitoring Your Runners

Keep an eye on your runners’ health and performance.

Check Runner Status

# View systemd service status
sudo systemctl status actions.runner.*

# View runner logs
sudo journalctl -u actions.runner.* -f

Monitor Resource Usage

# Install monitoring tools
sudo apt install htop

# Watch resource usage
htop

# Or use docker stats if running in containers
docker stats github-runner

GitHub UI Monitoring

In SettingsActionsRunners:


💡 Tips and Best Practices

From my experience running self-hosted runners, here are some lessons learned:

1. Use Labels Wisely

# In config.sh
--labels self-hosted,linux,x64,docker,production,gpu

# In workflow
runs-on: [self-hosted, gpu]  # Only runs on runners with GPU

2. Clean Up Docker Images

Self-hosted runners can accumulate Docker images quickly:

# Add to cron (weekly cleanup)
0 0 * * 0 docker system prune -af --volumes

Or add as a workflow step:

- name: Clean up Docker
  if: always()
  run: docker system prune -f

3. Keep Runners Updated

# Check for updates
cd ~/actions-runner
sudo ./svc.sh stop
./config.sh remove
# Download latest version
# Reconfigure and restart

Or use a container-based runner that you can update by pulling a new image.

4. Use Ephemeral Runners for Untrusted Code

# Configure as ephemeral (auto-deregisters after one job)
./config.sh --url ... --token ... --ephemeral

This is great for:


🎬 Conclusion and Personal Reflections

After months of using my self-hosted runner, I can say it was one of the best decisions I made for my projects.

What I’ve Learned

The advantages I’ve personally experienced:

But it’s not all roses:

There were moments when I thought “maybe GitHub-hosted was simpler”:

My Practical Advice

Use self-hosted if:

DON’T use self-hosted if:

The middle ground I use:

In my main workflow I have both GitHub-hosted and self-hosted:

This way I get the best of both worlds.

What to Check Before Using Self-Hosted

Before diving in, verify:

  1. Real costs: Calculate if the server cost is less than the GitHub plan you’d need to subscribe to
  2. Server capacity: Monitor CPU, RAM, and disk during a few builds to see if your server can handle it
  3. Connectivity: A server with unstable connection is worse than GitHub-hosted
  4. Security: Make sure you understand the security implications (especially for public repos)
  5. Maintenance time: Are you willing to dedicate 30 min per month for maintenance?

When I DON’T Recommend It

There are situations where I strongly advise against self-hosted:

Very popular public repositories: If you receive many PRs from external contributors, someone could exploit your runner for cryptocurrency mining, data theft, or other malicious purposes. GitHub’s official documentation explicitly warns against using self-hosted runners with public repositories due to the risk of arbitrary code execution from pull requests.

Critical projects without backup: If your server is a single point of failure and you have no alternatives, use GitHub-hosted which has guaranteed SLAs.

Teams without DevOps experience: If nobody on the team knows how to manage Linux servers, troubleshooting, monitoring… better avoid it.

Compliance and audit: If you work in environments with strict compliance requirements, GitHub-hosted offers better certifications and audit trails.

What to Do If You Need Temporary Runners

A trick I discovered later: ephemeral runners.

If you need runners for a limited period or for testing:

# Configure as ephemeral (auto-deregisters after one job)
./config.sh --url ... --token ... --ephemeral

Useful for:

I use them when I need to test new configurations before putting them on permanent runners.


📚 Resources

Related articles on my blog:


Thanks for reading! If you have questions or want to share your self-hosted runner setup, feel free to reach out. I’d love to hear how you implemented it!

Related Articles

Contattami

Hai domande o vuoi collaborare? Inviami un messaggio!

Accetto la Privacy Policy e autorizzo l'uso dei miei dati per la gestione di questa richiesta.

Back to Blog