Why teams run GitLab runners for Windows and macOS builds
A team building .NET services, Windows desktop applications or Apple platform software often finds that its jobs cannot run on the Linux runner fleet that handles the rest of its CI/CD work. A Windows or macOS runner registered to a self-managed GitLab instance fills that gap: the job runs on the operating system it targets, while GitLab still holds the pipeline definition, the approvals and the logs. The runner is the component that executes the job, and it has to run on the platform that the toolchain requires. GitLab Runner is the application that executes CI/CD jobs. It is distributed under the MIT license and supports shell, Docker and autoscaling executors across clouds and hypervisors, according to the GitLab Runner documentation. For Windows and macOS work, the executor choice follows the toolchain: a shell executor runs jobs directly on the host, which is the common starting point for desktop builds that need installed SDKs, signing identities or Xcode, while container-based executors depend on what the host platform can run. Before you commit to an executor, confirm which Windows and macOS builds, SDKs and signing tools your pipelines require, and confirm with the runner documentation which executors support each platform. The Runner documentation advises keeping the Runner major and minor versions aligned with the GitLab version of your instance, so a platform runner added today must be upgraded on the same schedule as the rest of the fleet. Desktop hosts are often patched by a different team on a different cadence, which is where version drift and broken jobs tend to start. Decide early who owns the Windows and macOS hosts, how their Runner version is pinned and how an upgrade of GitLab is followed by a matching Runner upgrade.
Which executor fits Windows and macOS jobs
The shell executor runs each job directly on the host operating system as the account that runs the Runner service. It is the simplest model for Windows and macOS because the toolchain (Visual Studio Build Tools, the .NET SDK, Xcode command line tools, code signing identities) is installed once on the machine and reused by every job. The trade-off is that the host is shared state. Jobs from different projects see the same installed SDKs, the same global caches, the same keychain and the same network identity, so one job can change what the next job finds.
- Isolation: Little beyond the operating system user account. Process and filesystem isolation between jobs is not provided by the executor.
- Shared between jobs: Installed SDKs, global package caches, environment variables, the signing keychain and any files left in the build directory.
- Good fit: A single trusted project group that needs signing keys or licensed tools kept on one machine.
- Operational cost: Someone must keep the toolchain patched and documented, and rebuild the machine when it drifts.
- Confirm first: Which shell the Runner uses on Windows (PowerShell or cmd) and how the service account is configured on each operating system in the GitLab Runner documentation.
Before choosing this model, confirm that every project allowed to run on the machine is trusted with its contents and secrets.
A Windows host can run Docker and execute jobs in containers. This isolates the job's filesystem and installed packages from the host and from other jobs, provided each job starts from a clean image. The important constraint is that Windows containers and Linux containers are different. A Windows host running Docker for Windows containers can run Windows images, and the Windows image version must be compatible with the host's Windows version. Teams building .NET Framework or other Windows-only applications often need this path, while Linux-compatible .NET builds may run on the Linux fleet instead.
- Isolation: Per-job container filesystem and installed tooling, which limits leftover state between jobs.
- Shared between jobs: The host kernel, the Docker daemon, the image cache and any volumes or bind mounts you configure.
- Image maintenance: Your team builds and patches the Windows base and build images, and must keep them compatible with the host version.
- Resource contention: Parallel container jobs compete for CPU, memory and disk on the same host unless you set limits.
- Confirm first: Which Windows container modes and Docker executor options the GitLab Runner documentation supports for your Windows version, and whether your build needs features that containers do not provide.
Check the compatibility table for your Windows release before building images.
Some jobs need the whole machine: a macOS build that drives Xcode simulators, a Windows installer test that installs services and drivers, or a job that needs attached hardware such as a signing token or a device. Containers and shared shell runners cannot reproduce these conditions cleanly. A dedicated VM per job, or a pool of VMs that are replaced after use, gives the job a full operating system with its own state. The Instance executor is designed for this pattern, and autoscaling can add and remove machines as demand changes.
- Isolation: The strongest of the three models, because each job gets an operating system instance that is discarded or reset afterward.
- Shared between jobs: Little, apart from the cache and any external services you deliberately connect.
- Cost driver: Each job pays the time to start a fresh machine, and idle capacity costs money unless the fleet scales down.
- Platform limits: Whether macOS and Windows virtual machines can be hosted on your hardware depends on the licensing and hypervisor rules that apply to your organization.
- Confirm first: Which executors and autoscaling options the GitLab Runner documentation lists for each operating system, and what it says about hosting macOS on non-Apple hardware before you design the fleet.
For the autoscaling mechanics, see the separate Instance executor topic before committing to a design.
Install GitLab Runner as a service on Windows and macOS
This sequence takes a Windows or macOS host from a blank machine to a GitLab Runner that runs as a managed service under a dedicated account and executes a test job against your self-managed GitLab. Each step ends with a check so the team knows the runner is ready for real pipelines.
Confirm the runner version matches GitLab
Before downloading anything, check the version of your self-managed GitLab instance and select the GitLab Runner release with the same major and minor version. The GitLab Runner documentation advises keeping Runner major and minor versions aligned with GitLab, so write the chosen version into your build record. Done means you have a single target version agreed by the platform owner and recorded alongside the host's change ticket.
Install the runner binary on the host
Place the GitLab Runner binary for Windows or macOS on the build host using the method in the GitLab Runner installation documentation, which covers both platforms. On Windows, choose a stable install directory outside user profile folders, and on macOS, confirm the binary sits where your administrators expect. Confirm exact commands and package sources in the GitLab Runner documentation before running them. Done means the runner reports the expected version when invoked from a terminal.
Create a dedicated service account
Create a local or directory account used only to run the runner service, with no interactive login rights and access limited to the build workspace and the tools the jobs need. Avoid running the service as a personal administrator, because jobs inherit the account's permissions. Confirm with your security team which group policy or privilege settings apply to service accounts in your environment. Done means the account exists, can write to its workspace, and cannot read other team profiles.
Register the runner with an authentication token
Create the runner in the GitLab user interface or through the API so GitLab issues a token starting with glrt-, then register the runner on the host using that authentication token rather than the legacy registration token workflow. Record which runner tags and executor you selected, since jobs will target them. Confirm the registration command and any flags against the current GitLab Runner documentation. Done means the runner appears as online in the GitLab runner list with the tags you intended.
Install and start the runner as a service
Install the runner as a system service under the dedicated account and start it, using the service commands in the GitLab Runner documentation for your operating system. On Windows, confirm the service runs under the service account and starts automatically after a reboot. On macOS, confirm the launch configuration loads under the same account. Done means the service status shows running, and a restart of the host brings the runner back online without manual intervention.
Verify the runner with a test job
Create a small test project in your self-managed GitLab with a pipeline that targets the runner's tags and prints the Runner version, operating system and toolchain versions the job sees. Run the pipeline and confirm the job starts on the Windows or macOS host and finishes successfully. Done means the job log shows the expected host details and the runner's status in GitLab remains online after the job completes.
Record the configuration and confirm ownership
Save the runner version, service account, registered tags, executor type and host location in your operations record, and name the team that owns patching and runner upgrades. Confirm that upgrades will follow the GitLab version cycle, since the runner must stay aligned with GitLab over time. Done means the runner has a documented owner, a rollback note and a scheduled review date set by your team.
Configuration options and when each fits
This table compares the configuration decisions for Windows and macOS runners: executor, service account, tags, concurrency, cache location and scope. Read each row as a question to settle before installation, and check the trade-off against the sensitivity of the builds that will run on the host. Exact configuration keys are in the GitLab Runner configuration reference, which should be checked against your installed runner version.
| Decision | When it fits | Trade-off |
|---|---|---|
| Executor: shell | Xcode, MSBuild or Visual Studio builds that need the host's installed SDKs, signing identities or licensed desktop tools | Jobs share the host's files, caches and installed state, so one job can affect the next unless you clean the workspace |
| Executor: Docker on Windows | Linux-container or Windows-container jobs on a Windows host where a clean image per job is worth the setup | Windows container images are tied to host OS build versions, and macOS hosts cannot run Docker containers natively |
| Executor: instance or autoscaling | Builds that need the full VM, such as a macOS job on a fresh image or a Windows job that changes system settings | Needs cloud or hypervisor capacity and image maintenance, and cost rises with idle capacity unless scaling is tuned |
| Service account for the runner service | A dedicated local or directory account with only the rights its build tools need, so signing and deployment credentials stay out of reach | The service runs as that account, so any file or keychain it can read is available to jobs unless isolated |
| Keychain or certificate store access (macOS and Windows) | Code signing and notarization for Apple and Windows artifacts from a dedicated runner | Signing material on a shared host is a high-value target, so limit it to protected branches and dedicated runners |
| Tags | Separating runners by platform, toolchain version (for example, Xcode or .NET SDK versions) or trust level so jobs land on the right host | Tags route jobs but do not isolate them, so untrusted projects must not share tags with signing runners |
| Concurrency | Low values on hosts with limited memory or disk, and higher values where builds are light and independent | Higher concurrency speeds queues but makes jobs compete for CPU, disk and shared tool caches |
| Cache location | A local path on a persistent host for fast restores, or an S3-compatible distributed cache when hosts are ephemeral | Local caches persist between jobs and can leak state, while distributed caches add network transfer and configuration |
| Scope: instance, group or project runner | Instance runners for shared, low-trust build tooling; group or project runners for team-owned SDKs and signing | Narrower scope limits blast radius but multiplies hosts to patch and register; confirm your registration model before rollout |
Isolating desktop toolchains from the shared Linux fleet
Keep Xcode, MSBuild, Visual Studio and signing certificates off the hosts that run general Linux jobs. Desktop toolchains need their own operating system images, licensing arrangements and update cycles, and they often hold the most sensitive material in the pipeline: Apple signing identities, Windows code-signing certificates and deployment credentials. A runner that executes an arbitrary merge request from a shared Linux pool should never have a path to those keys. Plan the Windows and macOS runners as a separate trust zone, and route work to them with dedicated runner tags so that a job requesting a Windows or macOS toolchain can only land on the matching fleet. Confirm the exact tag and runner-scope behavior for your GitLab and Runner versions in the GitLab Runner documentation before you write job rules around it.
Scope the signing material as tightly as the runners themselves. Store certificates, provisioning profiles and keychain passwords in your secrets manager or as protected CI/CD variables, mark them protected so they are exposed only to pipelines running on protected branches or tags, and pair them with runner tags that select only the dedicated desktop fleet. Use environment scoping so that signing variables are not visible to feature branch pipelines. On macOS, decide whether each job gets a clean keychain that is created and deleted per job, because a persistent login keychain on a shared build host outlives the job that used it. Ask your security team to confirm how these controls map to your own change management and release approval policy before the first signed build runs.
Plan these boundaries together with the shared fleet design rather than adding Windows and macOS runners afterwards. Decide which projects may use the desktop fleet, which groups or instance runners can reach it, and whether untrusted forks or external contributors can ever trigger jobs there. Record the answers in the same design document as your Linux runner tiers, because a single misplaced group runner can expose signing keys to a project that should never see them. Ask your platform team to test the boundary with a deliberately mis-tagged job and a feature branch that tries to read a protected variable, and to confirm that both fail.
Security risks in Windows and macOS runner fleets
Windows and macOS runners often run on long-lived machines that also host desktop toolchains, signing material and developer accounts. The risks below share one root cause: a job that runs on a host it does not own can reach state, credentials and network paths left behind by earlier jobs. Each card names the control to plan before the fleet takes production traffic.
Persistent workspace state between jobs
Classic shell-executor runners on Windows and macOS reuse the build directory, global caches and user profiles across jobs, so one job can read or poison what the previous job left behind. Plan a clean workspace per job, separate the build user from the login user, and decide whether GIT_CLONE_PATH and cache paths are isolated per project. Check what survives a reboot and whether an ephemeral VM or a fresh user profile is the cleaner boundary. Read the runner isolation guidance in the GitLab hardening recommendations before choosing.
Service accounts with excess rights
Runner services often run as an administrator because a signing certificate, a Windows feature or a Homebrew install needed elevation once. Every job then inherits that privilege, including the ability to change system settings or read other projects' files. Plan a dedicated least-privilege account for the runner service, grant elevation only to named provisioning steps, and review who can log on interactively to the host. Confirm which tasks truly need administrative rights before the first pipeline runs.
Untrusted merge requests on privileged hosts
A merge request from a fork or an unreviewed contributor can run arbitrary scripts on any runner that matches its tags. A Windows or macOS machine that holds signing keys or deployment access makes that risk severe. Limit which projects can reach these runners, keep privileged runners off merge request pipelines from untrusted sources, and use protected branches for jobs that touch secrets. Document which tags map to which trust level so the fleet design can be audited.
Secrets left on disk
Build steps frequently write tokens, certificates and .npmrc or NuGet credentials into the workspace or user profile, where they persist after the job ends. Keychain entries, Windows certificate stores and credential managers can also outlive the job. Plan masked CI/CD variables, short-lived credentials from your secrets manager, and an explicit cleanup step. Ask your team to list every location where a signing identity is imported, then confirm each one is removed or scoped to the job.
Unpatched operating systems and toolchains
A runner image pinned to an old Windows build, an out-of-date Xcode or a stale .NET SDK creates risk that the pipeline itself cannot see. Desktop toolchains also update through their own channels, which can drift from the image your team tested. Plan a versioned image build, a patch window that includes the runner binary, and a rebuild cadence for each image. Keep the runner major and minor versions aligned with your GitLab instance, as the GitLab Runner documentation advises.
Unrestricted outbound network access
Build jobs commonly reach public package feeds, code signing services and arbitrary download URLs, which gives a compromised job a path to exfiltrate code or pull in new payloads. Windows and macOS hosts on the corporate network may also reach internal systems that Linux runners never touch. Plan an egress allowlist for package sources and signing endpoints, and place these runners on a segment that cannot reach production networks. Confirm the list with the teams that own each dependency source.
Questions teams ask before running Windows or macOS runners
What drives the cost of running Windows and macOS GitLab runners?
The main cost drivers are the number of build hosts, how many concurrent jobs they must handle, and the hardware and licensing behind them. Apple build machines are physical Apple hardware, so capacity planning depends on how many macOS jobs run at the same time and how long they take. Windows hosts carry Windows and Visual Studio licensing that you should confirm with your Microsoft agreement before sizing. Recurring costs include hosts, storage for caches and artifacts, monitoring, and the engineering time spent on patching and runner upgrades. One-time costs include the initial build of images, pipeline migration and hardening. Ask any partner for a quote that separates hardware, licensing, labor for the build and labor for ongoing operations, and that states which items are your responsibility.
Can our internal team install and run these runners without outside help?
Yes, many teams do this in-house, especially if they already run Linux runners and understand GitLab Runner's configuration. The work is different on each platform. On Windows you install the runner as a service and manage PowerShell, toolchain versions and the Windows update cycle. On macOS you manage the runner as a launch service on Apple hardware, along with Xcode versions, signing identities and the keychain. The harder part is the ongoing operation: keeping hosts patched, keeping the runner aligned with your GitLab version, and isolating desktop toolchains from shared fleets. If your team has no one who owns Windows or macOS build infrastructure, a fixed-scope engagement can cover the build and handover, and a retainer can cover upgrades.
What do we need to provide before Windows or macOS runners can be built?
Plan for four groups of inputs. First, the hosts: physical or virtual Windows machines and Apple hardware, with enough CPU, memory and disk for your heaviest jobs. Second, accounts and access: a GitLab instance, a runner authentication token, service accounts for build tools and artifact stores, and the signing or code-signing credentials your builds need. Third, network: outbound access from the hosts to your GitLab instance, package feeds and cache storage, with any proxy or firewall rules written down. Fourth, licensing: Apple hardware and macOS terms, Windows licensing and any Visual Studio or Xcode terms. Confirm each licensing point with the vendor or your legal team, because the terms depend on how and where you run the hosts.
How do we keep Windows and macOS runners aligned with GitLab upgrades?
GitLab ships a minor release on the third Thursday of each month and patch releases twice a month, and it maintains the current major line plus the two before it. GitLab Runner's documentation advises keeping the runner's major and minor versions aligned with GitLab, so you should plan a runner upgrade with each GitLab upgrade. Bug fixes are backported only to the current stable release, while security fixes also go to the previous two monthly releases, so staying on a supported line matters for security. GitLab requires upgrades to pass through required upgrade stops, which fall at versions x.2, x.5, x.8 and x.11 since GitLab 17.5. Schedule runner upgrades within the same window as the GitLab stop you are targeting, and test a Windows or macOS job on the new runner before you move production pipelines.
Does Docker Autoscaler work for Windows and macOS builds?
Docker Autoscaler is built around Linux containers on cloud virtual machines, so it is generally not the right fit for native Windows or macOS builds. Many .NET and Apple toolchains need the host operating system itself, including Xcode, code signing and Windows-specific SDKs, which containers do not supply. For these jobs, the shell executor on a dedicated host is a common starting point, and the Instance executor is worth reviewing when a job needs the whole virtual machine. Confirm the executor options for your platform in the GitLab Runner documentation, and check which capabilities your chosen version supports before you commit to a design. Keep these hosts in their own runner tags so that desktop toolchains do not share a pool with Linux container jobs.
What should we confirm before we hire a partner for this work?
Ask for a written scope that lists the platforms, runner executors, hosts and operating system versions in scope, and the GitLab version you are targeting. Confirm who buys and owns the Apple hardware, Windows licenses and any Visual Studio or Xcode terms. Ask how isolation between desktop toolchains and shared Linux runners will be implemented, and how secrets, signing keys and caches are handled. Request the handover items: runbooks, configuration files, the upgrade procedure and the test jobs used for acceptance. Finally, ask how upgrades will follow GitLab's maintenance policy and required upgrade stops, and whether support is covered by a retainer or a separate agreement. DeployOpen provides a quote from the written scope, so a clear scope is the basis for a comparable price.
Limits to confirm in your environment
Windows and macOS runners depend on details that change with runner versions, host licensing and hardware. The cards below list what to confirm before committing to a design, and who can answer each question. Confirm each item against the current GitLab Runner documentation and your own environment before the build starts.
Executor and plugin support by platform
Not every executor and autoscaling plugin supports Windows and macOS in the same way, and support can change between Runner releases. Check which executors are documented for each operating system, and whether the autoscaling plugin you plan to use is a supported or community plugin. Confirm this with GitLab support or against the current Runner docs, and record the Runner version you tested with.
Host operating system licensing
Apple and Microsoft license terms govern how many macOS and Windows hosts you may run, whether virtual machines on shared hardware are permitted, and how hosts may be used for build workloads. Ask your legal or procurement team to review the current licence terms for each operating system and hypervisor you plan to use. Licensing cost and terms often decide the fleet shape more than the runner software does.
macOS hardware and virtualization limits
macOS runners depend on Apple hardware and on the virtualization features the host supports. Confirm which Mac models you can source, how many macOS virtual machines a single host can run under Apple's current terms, and whether your chosen virtualization tool supports the Xcode versions your teams need. Ask your Apple hardware vendor and your build engineers to check these together.
Windows container compatibility
Windows container jobs only run on Windows hosts whose build matches the base image, and mixing host and image versions is a common source of failed jobs. Confirm which Windows Server or Windows client builds your .NET and tooling images require, and whether your Docker or container runtime supports process isolation or Hyper-V isolation for them. Ask your platform team and the image maintainers to verify the pairing.
Offline installation requirements
If your runners sit in a restricted network, Runner binaries, helper images, package managers and SDK installers all need an internal source. Review GitLab's offline environment guidance to see which components need mirroring and how the runner registers with a self-managed instance without outbound access. Ask your network and security teams which download sources they can allow, and test a full job with no internet access.
Version alignment with the instance
GitLab documents keeping Runner major and minor versions aligned with the self-managed instance, and your instance follows the upgrade stops in its own schedule. Confirm which GitLab version your instance will run during the rollout, and plan Windows and macOS runner upgrades into the same change window. Ask your GitLab administrators to confirm the target version before you pin any runner package.
Keeping Windows and macOS runners current with GitLab releases
Runner versions should move with the GitLab instance they serve. The GitLab maintenance policy describes a major release once a year, a minor release on the third Thursday of each month and patch releases twice a month. GitLab currently maintains 19.5, 19.4 and 19.3, and bug fixes are backported only to the current stable release, while security fixes also reach the previous two monthly releases. A Windows or macOS runner that falls behind the instance can miss fixes, and the GitLab Runner documentation advises keeping the Runner major and minor versions aligned with GitLab. Because a desktop runner often sits outside the Linux fleet's automation, it is easy to forget. Record the runner version on each host, and add it to the same release calendar you use for the instance.
Upgrade stops apply to the instance, but they set the pace for the runners too. GitLab requires upgrades to pass through required stops, which since GitLab 17.5 fall at x.2, x.5, x.8 and x.11, for example 19.2, 19.5, 19.8 and 19.11. Plan each instance upgrade as a window in which the matching Runner version is also staged. Confirm in the upgrade paths documentation which stop applies to your path before you schedule anything, and confirm with your team how many Windows and macOS hosts must be updated by hand, since those hosts are usually not managed by the same tooling as Linux runners.
Test every runner version on a staging instance before production. Register a Windows runner and a macOS runner against a staging GitLab at the target version, then run representative jobs: a .NET build, a signed macOS build if you use one, and a job that reads the cache and artifacts. Check the service restarts cleanly after a reboot, that the Runner user keeps access to the toolchain, and that jobs report status back to the instance. Keep the previous Runner binary available so you can roll back a host without rebuilding it. Confirm with your own change process whether the staging result counts as evidence for the change record.
How DeployOpen scopes and delivers Windows and macOS runners
DeployOpen plans, installs, hardens and documents self-managed GitLab Runner hosts for Windows and macOS builds inside your own infrastructure. Engagements are fixed-scope, with the scope, deliverables and timeline agreed in writing before any build work starts.
How we scope
- Inventory the .NET, Windows and Apple toolchains each team builds with, including signing and notarization steps.
- Map which projects need a Windows or macOS host and which can stay on the shared Linux fleet.
- Agree the executor choice, host count, network paths to artifact stores and the service account model in writing.
- Confirm the GitLab and Runner version pairing, install method and whether the environment is offline or air-gapped.
How we build
- Install GitLab Runner as a Windows service or macOS launch daemon on hosts you provide, with documented config.toml settings.
- Isolate desktop toolchains on dedicated hosts or tagged runners so Apple and Windows jobs never share a host with privileged Linux work.
- Configure tags, protected-branch restrictions and credential handling so deployment jobs run only on approved runners.
- Test representative pipelines end to end, including restart behavior after host reboots and failed job cleanup.
How we hand over
- Deliver a written runbook covering host build, Runner registration, updates, tag ownership and rollback steps.
- Document the executor decision, its trade-offs and the isolation boundaries agreed with your security team.
- Walk your platform and build engineers through monitoring, log locations and how to add a new Windows or macOS host.
- Provide configuration and evidence files in a form your compliance team can retain as change records.
How we support
- Ongoing support, Runner upgrades aligned with your GitLab version and periodic testing are available as a retainer.
- Plan each Runner upgrade alongside GitLab upgrade stops so host versions never drift from the instance.
- Review tag usage, host capacity and toolchain updates with your team on an agreed cadence.
- Confirm with your own environment owners which hosts, accounts and network rules a support scope must cover before work starts.

