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):
Navigate to GitHub → Profile Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens.
Set Resource owner to your Organization Name.
Set Repository access to All repositories (or choose specific organization repositories).
Under Organization Permissions, set
Self-hosted runnersto Read and write (required to register and unregister org runners).Under Repository Permissions, set
ActionsandContentsto Read-only.
Classic Personal Access Token: Required scopes:
admin:org(full control of organization self-hosted runners),repo, andworkflow.
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):
Go to GitHub → Profile Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens.
Set Resource owner to your personal account or organization.
Set Repository access to Only select repositories and pick your target repository.
Under Repository Permissions, set
Administrationto Read and write, and bothActionsandContentsto Read-only.
Classic Personal Access Token: Required scopes:
repoandworkflow.
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/StacksOption 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/StacksGitHub Actions Workflow Integration
To maximize performance and conserve server resources, split your workflow into a two-job architecture:
Cloud Build Job (
ubuntu-latest): Uses GitHub's free cloud runners to build and push container images to GHCR.Local Sync Job (
[self-hosted, dockge]): Executes locally on your server runner in seconds to write the.envfile 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-1shows as Idle (Green).Repository Level: Navigate to GitHub Repo Settings → Actions → Runners and confirm
dockge-repo-runner-1shows as Idle (Green).Manually trigger a
workflow_dispatchevent within your repository.Verify that the
.envfile 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 sureSelf-hosted runners: Read & write(for organizations) orAdministration: Read & write(for repositories) is enabled.Permission Denied writing
.env: EnsureRUN_AS_ROOT: "true"is configured in yourcompose.yamlfile, 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/StacksApproach 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/StacksCRITICAL Rules for Multiple Runners:
Each runner registered on GitHub must have a unique name (
RUNNER_NAMEorRUNNER_NAME_PREFIX). Failing to do so will cause new runners to overwrite existing registrations.If you manually define multiple services, ensure each service has a unique
container_name.