Posted 20 September 2026
Design / Frontend / How to Self-Host GitHub Actions Runners with Docker Compose & Dockge (Step-by-Step Guide)

How to Self-Host GitHub Actions Runners with Docker Compose & Dockge (Step-by-Step Guide)

My original purpose was actually used for running a command to my server without SSH-ing into it. And voila, decided to self host runner on the same machine as my website. By setting up Self-Hosted GitHub Actions Runners inside Docker containers using Docker Compose and Dockge, you gain a secure, isolated execution environment directly on your infrastructure.

This comprehensive guide covers everything you need to know to get started—including Organization-Level and Repository-Level configurations, required GitHub Personal Access Token (PAT) permissions, volume mounting, and GitHub Actions workflow integration for automated environment deployment.

Architecture Overview

Running a GitHub Actions Runner inside a Docker container on your server provides a secure and isolated execution environment. By mounting your server's Dockge stacks directory directly into the runner container, the runner can write environment variables and secrets straight to disk without needing open inbound SSH ports or extra deployment agents.

graph TD
    subgraph "1. GitHub Actions Cloud (CI)"
        A[Workflow Dispatch / Push] --> B["Job 1: Build & Push Image (runs-on: ubuntu-latest)"]
        B --> C[Push Container Image to GHCR]
    end

    subgraph "2. Server Environment (Dockge / Docker Compose)"
        D[GitHub Runner Container] -->|Polls Outbound HTTPS| E[GitHub Actions API]
        E -->|Dispatches Job 2| D
        D -->|Job 2: Writes .env Directly| F["App Stack Directory (/opt/dockge/stacks/<app>/.env)"]
        G[Watchtower Service] -->|Monitors GHCR| C
        G -->|Auto-pulls & Restarts| H[App Container Stack]
        H -->|Loads Updated| F
    end
    

Token & Permissions Setup

Before deploying your runner, you need to generate a Personal Access Token (PAT) on GitHub so the container can authenticate and register the runner successfully.

Option A: Organization-Level PAT Setup (For Org Runners)

Use this option when you want a single runner to serve all repositories within a GitHub Organization.

  • Fine-Grained Personal Access Token (Recommended):

    1. Navigate to GitHub → Profile Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens.

    2. Set Resource owner to your Organization Name.

    3. Set Repository access to All repositories (or choose specific organization repositories).

    4. Under Organization Permissions, set Self-hosted runners to Read and write (required to register and unregister org runners).

    5. Under Repository Permissions, set Actions and Contents to Read-only.

  • Classic Personal Access Token: Required scopes: admin:org (full control of organization self-hosted runners), repo, and workflow.

Option B: Repository-Level PAT Setup (For Single Repo Runners)

Use this option when your runner should be dedicated to only one specific repository.

  • Fine-Grained Personal Access Token (Recommended):

    1. Go to GitHub → Profile Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens.

    2. Set Resource owner to your personal account or organization.

    3. Set Repository access to Only select repositories and pick your target repository.

    4. Under Repository Permissions, set Administration to Read and write, and both Actions and Contents to Read-only.

  • Classic Personal Access Token: Required scopes: repo and workflow.

Docker Compose Configurations

Option A: Organization-Level Runner (compose.yaml)

Save this configuration within your Dockge stacks directory (e.g., /mnt/SSDPool/configs/Dockge/Stacks/github-runner-org/compose.yaml):

name: github-runner-org

services:
  runner:
    image: myoung34/github-runner:latest
    container_name: github-runner-org
    restart: unless-stopped
    environment:
      RUNNER_SCOPE: "org"
      ORG_NAME: "YOUR_ORGANIZATION_NAME"
      ACCESS_TOKEN: "ghp_your_org_scoped_pat_here"
      RUNNER_NAME: "dockge-org-runner-1"
      LABELS: "self-hosted,dockge"
      RUN_AS_ROOT: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/SSDPool/configs/Dockge/Stacks:/mnt/SSDPool/configs/Dockge/Stacks

Option B: Repository-Level Runner (compose.yaml)

Save this configuration for single-repository setups:

name: github-runner-repo

services:
  runner:
    image: myoung34/github-runner:latest
    container_name: github-runner-repo
    restart: unless-stopped
    environment:
      REPO_URL: "https://github.com/YOUR_USER_OR_ORG/tl-listen-ai-podcast"
      ACCESS_TOKEN: "ghp_your_repo_scoped_pat_here"
      RUNNER_NAME: "dockge-repo-runner-1"
      LABELS: "self-hosted,dockge"
      RUN_AS_ROOT: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/SSDPool/configs/Dockge/Stacks:/mnt/SSDPool/configs/Dockge/Stacks

GitHub Actions Workflow Integration

To maximize performance and conserve server resources, split your workflow into a two-job architecture:

  1. Cloud Build Job (ubuntu-latest): Uses GitHub's free cloud runners to build and push container images to GHCR.

  2. Local Sync Job ([self-hosted, dockge]): Executes locally on your server runner in seconds to write the .env file directly to disk.

Workflow Example (.github/workflows/on-demand-backend-integration-release.yml)

name: Release on demand backend image to GHCR (Integration)

on:
  workflow_dispatch:

permissions:
  packages: write
  contents: read

jobs:
  # Job 1: Cloud Build & Push (Conserves server CPU & RAM)
  build:
    runs-on: ubuntu-latest
    environment: integration

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Resolve target namespace
        id: meta_params
        env:
          CUSTOM_NAMESPACE: ${{ vars.GHCR_NAMESPACE }}
          REPO_OWNER: ${{ github.repository_owner }}
        run: |
          RAW_NAMESPACE="${CUSTOM_NAMESPACE:-$REPO_OWNER}"
          LOWER_NAMESPACE=$(echo "$RAW_NAMESPACE" | tr '[:upper:]' '[:lower:]')
          echo "namespace=$LOWER_NAMESPACE" >> $GITHUB_OUTPUT

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

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

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ steps.meta_params.outputs.namespace }}/tl-listen-be
          tags: |
            type=raw,value=${{ github.sha }}
            type=raw,value=integration

      - name: Build and push Docker image
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          file: packages/tl_listen_be/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # Job 2: Local .env Sync via Self-Hosted Runner
  sync-env:
    needs: build
    runs-on: [self-hosted, dockge]
    environment: integration

    steps:
      - name: Write .env file to Dockge stack directory
        run: |
          STACK_DIR="/mnt/SSDPool/configs/Dockge/Stacks/tl-listen-ai-podcast"
          mkdir -p "$STACK_DIR"

          cat << 'EOF' > "$STACK_DIR/.env"
          PORT=3000
          DATABASE_URL=${{ secrets.DATABASE_URL }}
          OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
          JWT_SECRET=${{ secrets.JWT_SECRET }}
          EOF
          echo "Successfully updated .env at $STACK_DIR/.env"

Verification & Troubleshooting

Verification Steps

  • Organization Level: Navigate to GitHub Org Settings → Actions → Runners and confirm dockge-org-runner-1 shows as Idle (Green).

  • Repository Level: Navigate to GitHub Repo Settings → Actions → Runners and confirm dockge-repo-runner-1 shows as Idle (Green).

  • Manually trigger a workflow_dispatch event within your repository.

  • Verify that the .env file successfully generates at /mnt/SSDPool/configs/Dockge/Stacks/tl-listen-ai-podcast/.env.

  • Check that Watchtower detects the updated image in GHCR and automatically restarts your app stack.

Troubleshooting Common Issues

  • Error: Failed to register runner: Double-check your PAT permissions. Make sure Self-hosted runners: Read & write (for organizations) or Administration: Read & write (for repositories) is enabled.

  • Permission Denied writing .env: Ensure RUN_AS_ROOT: "true" is configured in your compose.yaml file, and verify host permissions permit volume writes to your Dockge stacks path.

Scaling & Managing Multiple Runners

If your workloads grow and you need to handle parallel job executions, you can scale your runners using two different approaches:

Approach A: Using deploy.replicas (Automatic Scaling)

To run multiple identical runner instances without duplicating code, omit container_name and use RUNNER_NAME_PREFIX:

name: github-runner-org

services:
  runner:
    image: myoung34/github-runner:latest
    restart: unless-stopped
    deploy:
      replicas: 2  # Automatically creates 2 parallel runner instances!
    environment:
      RUNNER_SCOPE: "org"
      ORG_NAME: "YOUR_ORGANIZATION_NAME"
      ACCESS_TOKEN: "ghp_your_token_here"
      RUNNER_NAME_PREFIX: "dockge-org-runner" # Generates dockge-org-runner-1, dockge-org-runner-2
      LABELS: "self-hosted,dockge"
      RUN_AS_ROOT: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/SSDPool/configs/Dockge/Stacks:/mnt/SSDPool/configs/Dockge/Stacks

Approach B: Defining Distinct Services (Custom Labels)

If you require specialized runners with different capabilities or unique labels, declare them as separate services:

name: github-runner-org

services:
  runner-1:
    image: myoung34/github-runner:latest
    container_name: github-runner-org-1
    restart: unless-stopped
    environment:
      RUNNER_SCOPE: "org"
      ORG_NAME: "YOUR_ORGANIZATION_NAME"
      ACCESS_TOKEN: "ghp_your_token_here"
      RUNNER_NAME: "dockge-org-runner-1"
      LABELS: "self-hosted,dockge"
      RUN_AS_ROOT: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/SSDPool/configs/Dockge/Stacks:/mnt/SSDPool/configs/Dockge/Stacks

  runner-2:
    image: myoung34/github-runner:latest
    container_name: github-runner-org-2
    restart: unless-stopped
    environment:
      RUNNER_SCOPE: "org"
      ORG_NAME: "YOUR_ORGANIZATION_NAME"
      ACCESS_TOKEN: "ghp_your_token_here"
      RUNNER_NAME: "dockge-org-runner-2"
      LABELS: "self-hosted,dockge"
      RUN_AS_ROOT: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/SSDPool/configs/Dockge/Stacks:/mnt/SSDPool/configs/Dockge/Stacks

CRITICAL Rules for Multiple Runners:

  1. Each runner registered on GitHub must have a unique name (RUNNER_NAME or RUNNER_NAME_PREFIX). Failing to do so will cause new runners to overwrite existing registrations.

  2. If you manually define multiple services, ensure each service has a unique container_name.


← Back to Journal