How to Configure GitHub Actions Self-Hosted Runner on Windows Server 2022
GitHub Actions provides hosted runners on Linux, macOS, and Windows, but for workflows that require custom software, Windows-specific build tools, access to private networks, or control over the execution environment, a self-hosted runner on Windows Server 2022 is the right choice. Self-hosted runners connect to GitHub and wait for jobs assigned to them through your workflow files. This guide covers registration, service installation, security considerations, and advanced runner configurations.
Creating a Self-Hosted Runner in GitHub
Runners can be added at three levels: repository, organization, or enterprise. For a single repository, navigate to the repository on GitHub, click Settings, expand Actions in the left sidebar, click Runners, then click “New self-hosted runner.” For an organization-level runner (shared across multiple repositories), navigate to your organization’s Settings > Actions > Runners > New runner.
GitHub displays the download and configuration commands specific to the runner type (Windows x64 in this case). These commands include a time-limited registration token that expires after one hour. Copy the token from this screen before proceeding to the server.
On Windows Server 2022, open an elevated PowerShell session. GitHub provides the exact commands on the runner setup page, but the general form is:
# Create a directory for the runner
New-Item -ItemType Directory -Path "C:actions-runner"
Set-Location "C:actions-runner"
# Download the runner package (check GitHub for the current version URL)
Invoke-WebRequest -Uri "https://github.com/actions/runner/releases/download/v2.317.0/actions-runner-win-x64-2.317.0.zip" `
-OutFile "actions-runner-win-x64-2.317.0.zip"
# Extract the archive
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory(
"C:actions-runneractions-runner-win-x64-2.317.0.zip",
"C:actions-runner"
)
Configuring the Runner with config.cmd
After extracting, run the configuration script. This registers the runner with GitHub and writes the local configuration files:
.config.cmd --url https://github.com/YOUR_ORG/YOUR_REPO --token YOUR_REGISTRATION_TOKEN
The configuration script prompts interactively for the runner name (press Enter to accept the hostname default), runner group (press Enter for Default), and labels. For unattended configuration, pass all arguments on the command line:
.config.cmd `
--url "https://github.com/your-org/your-repo" `
--token "YOUR_TOKEN" `
--name "ws2022-runner-01" `
--runnergroup "Default" `
--labels "self-hosted,windows,windows-2022,x64" `
--work "_work" `
--unattended
The –work parameter specifies the working directory for job checkouts. Use a path on a data disk with sufficient space for build artifacts:
.config.cmd `
--url "https://github.com/your-org/your-repo" `
--token "YOUR_TOKEN" `
--name "ws2022-runner-01" `
--labels "self-hosted,windows,windows-2022" `
--work "D:runner-work" `
--unattended
After configuration, the .runner and .credentials files are written to C:actions-runner. The runner is now registered in GitHub but not yet running.
Installing and Starting the Runner as a Windows Service
For production use, install the runner as a Windows service so it starts automatically on boot. The runner package includes a PowerShell script for service management:
# Install the runner as a Windows service
.svc.ps1 install
# Start the service
.svc.ps1 start
# Check service status
.svc.ps1 status
By default, the service installs under the account that ran the script. To specify a different service account for better isolation:
# Create a service account
net user ghrunner StrongPassword789! /add
net localgroup Administrators ghrunner /add
# Install service under the account
.svc.ps1 install --user ".ghrunner" --password "StrongPassword789!"
.svc.ps1 start
Verify the service is running and the runner appears as “Idle” (online, waiting for jobs) in GitHub Settings > Actions > Runners:
Get-Service -Name "actions.runner.*" | Select-Object Name, Status, StartType
Runner Groups and Labels
Runner groups allow organizations to control which repositories can use specific runners. By default all runners go into the “Default” group, which is accessible to all repositories in the organization. Create additional groups in Settings > Actions > Runner groups to restrict access. For example, a “Production” runner group might only allow the deployment repository, preventing development repositories from triggering production deployments.
Labels (tags) are used in your workflow YAML to target specific runners. The default labels applied to a Windows self-hosted runner are self-hosted, windows, and x64. Custom labels you add during registration (like windows-2022) let you create more specific targeting in workflows. Labels are AND-matched: a job with runs-on: [self-hosted, windows-2022] will only run on runners that have both labels.
Using the Runner in Workflows with runs-on
Reference your self-hosted runner in GitHub Actions workflow files by specifying the matching labels in the runs-on key. A basic workflow job targeting the Windows Server 2022 runner:
name: Windows Build
on: [push, pull_request]
jobs:
build:
runs-on: [self-hosted, windows, windows-2022]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build with MSBuild
run: |
& "C:Program FilesMicrosoft Visual Studio2022BuildToolsMSBuildCurrentBinMSBuild.exe" `
MyApp.sln /p:Configuration=Release /p:Platform=x64
- name: Run tests
run: |
dotnet test MyApp.Tests/MyApp.Tests.csproj --configuration Release
PowerShell is the default shell on Windows self-hosted runners. To use Command Prompt for a specific step, add shell: cmd to the step definition.
Runner Security Considerations
Self-hosted runners execute code from your repository’s workflow files. This presents security risks, especially for public repositories where anyone can open a pull request and trigger workflows. Key security practices:
Never use self-hosted runners on public repositories without strict protections. An attacker can submit a PR that modifies a workflow to exfiltrate secrets or damage the runner host. At minimum, configure “Require approval for all outside collaborators” in Settings > Actions > General > Fork pull request workflows.
Run the runner under a least-privilege service account, not LocalSystem or a domain admin. The service account needs only the permissions required for the specific build tasks (read access to source directories, write access to the work directory, access to build tools).
Use ephemeral runners (described below) for security-sensitive workloads to ensure each job starts with a clean environment. Avoid storing secrets as environment variables on the runner host; use GitHub Encrypted Secrets instead, which are injected into workflow jobs at runtime.
Updating the Runner
GitHub will automatically update self-hosted runners when a new version is released, provided the runner is connected to GitHub. The auto-update can also be disabled if you prefer manual control. To manually update the runner, stop the service, download the new version, extract it over the existing installation, reconfigure if necessary, and restart the service:
# Stop the service
.svc.ps1 stop
# Download new version (replace version number)
Invoke-WebRequest -Uri "https://github.com/actions/runner/releases/download/v2.318.0/actions-runner-win-x64-2.318.0.zip" `
-OutFile "actions-runner-win-x64-2.318.0.zip"
# Extract (overwrites binaries but preserves .runner and .credentials config)
[System.IO.Compression.ZipFile]::ExtractToDirectory(
"C:actions-runneractions-runner-win-x64-2.318.0.zip",
"C:actions-runner"
)
# Restart
.svc.ps1 start
Running Multiple Runners on the Same Host
A single Windows Server 2022 host can run multiple concurrent runner instances. Each instance needs its own directory and its own registration. Create separate directories for each runner:
New-Item -ItemType Directory -Path "C:actions-runner-2"
Copy-Item "C:actions-runner*" "C:actions-runner-2" -Recurse -Exclude "_work,.runner,.credentials"
Set-Location "C:actions-runner-2"
.config.cmd --url "https://github.com/your-org/your-repo" --token "NEW_TOKEN" --name "ws2022-runner-02" --unattended
.svc.ps1 install
.svc.ps1 start
Each runner instance registers as a separate service with a unique name. With 4 runner instances on a server with 16 cores, you can handle 4 concurrent workflow jobs. Size the number of runners to the available CPU and memory on the host, leaving headroom for the OS and any build tools that consume significant memory.
Ephemeral Runners
Ephemeral runners register, execute exactly one job, and then de-register themselves. This provides the strongest security guarantee because no state persists between jobs. To configure an ephemeral runner, add the –ephemeral flag during configuration:
.config.cmd `
--url "https://github.com/your-org/your-repo" `
--token "YOUR_TOKEN" `
--name "ephemeral-runner-01" `
--ephemeral `
--unattended
For ephemeral runners to be practical at scale, combine them with a runner orchestration system. GitHub’s own Actions Runner Controller (ARC) automates provisioning Kubernetes-based ephemeral runners. For Windows, a common pattern is a PowerShell script that uses the GitHub API to generate a new registration token (POST /repos/{owner}/{repo}/actions/runners/registration-token), register an ephemeral runner, wait for it to finish its job, and then re-register a fresh instance:
# Generate a new registration token via GitHub API
$headers = @{ Authorization = "Bearer $env:GITHUB_PAT"; Accept = "application/vnd.github+json" }
$response = Invoke-RestMethod `
-Uri "https://api.github.com/repos/YOUR_ORG/YOUR_REPO/actions/runners/registration-token" `
-Method POST `
-Headers $headers
$token = $response.token
# Configure and run ephemeral runner
Set-Location "C:actions-runner"
.config.cmd --url "https://github.com/YOUR_ORG/YOUR_REPO" --token $token --ephemeral --unattended --name "ephemeral-$(Get-Random)"
.run.cmd
This pattern, wrapped in a loop or triggered by a Windows Task Scheduler job, enables a lightweight ephemeral runner pool on Windows Server 2022 without requiring container orchestration infrastructure.