How I measured our local GitLab runner usage against GitLab.com compute minutes
Using glab and the GitLab API to turn a self-hosted runner's job durations into GitLab.com SaaS compute-minute estimates.
How I measured our local GitLab runner usage against GitLab.com compute minutes
I wanted a concrete number to compare our self-hosted GitLab runner against GitLab.com’s SaaS runners and pricing tiers. Our runner is a private group runner outside GitLab.com’s compute-minute billing. That leaves no built-in dashboard showing what we would pay if we moved those jobs to shared runners.
The problem
Our monorepo (example-group/example-monorepo) runs on a local Docker executor registered as a group runner. The host is a small KVM box (runner@self-hosted-runner) with a 4-core Intel i5-7500T and 27 GB RAM, but the runner caps each job container at 8 GB of memory.
I needed to answer two questions:
- How many GitLab.com compute minutes would our current workload represent?
- Which GitLab.com plan would it fit into?
Note
I ran this analysis on 2026-07-27 for the preceding 30 days. The runner ID, job counts, and plan quotas are a dated snapshot, so I would rerun the script before making a purchasing decision.
GitLab documents the formula. For this estimate, I retrieved successful and failed jobs handled by that runner and summed their durations. Canceled jobs were excluded, so the result is a lower-bound estimate.
What changed
Finding the runner and the formula
GitLab’s compute-minute formula is:
compute_minutes = job_duration_in_seconds / 60 * cost_factor
The compute minutes docs list the cost factors. For Linux x86-64 hosted runners they are:
| Size | Cost factor | Spec |
|---|---|---|
| small | 1 | 2 vCPU / 8 GB |
| medium | 2 | 4 vCPU / 16 GB |
| large | 3 | 8 vCPU / 32 GB |
| xlarge | 6 | 16 vCPU / 64 GB |
| 2xlarge | 12 | 32 vCPU / 128 GB |
I found the runner through the group runners API:
# infrastructure/gitlab-runner/compute_saas_minutes.py
$ glab api "groups/example-group/runners?type=group_type&per_page=100"
That returned runner 12345678, example self-hosted Docker runner, registered as group_type.
Collecting the jobs
The GitLab API has a runner-scoped jobs endpoint: GET /runners/:id/jobs. It lists every job handled by that runner across every project it is allowed to run on.
I used glab api for every request and avoided reading or hard-coding tokens by hand. To discover how many pages there were, I fetched the first page with headers only and parsed the Link header.
# Last relation tells us the final page
$ glab api -i --silent "runners/12345678/jobs?status=success&per_page=100&page=1&order_by=id&sort=asc"
The response included:
Link: <...page=2...>; rel="next", <...page=54...>; rel="last"
That meant 54 pages of successful jobs. I requested ascending job IDs explicitly, then iterated backwards from page 54 to page 1 and stopped as soon as a page’s newest started_at fell before the 30-day window.
I repeated the same for status=failed (7 pages), because those jobs still consumed runner time.
The script
I wrapped this in a small Python helper that only calls glab api.
# infrastructure/gitlab-runner/compute_saas_minutes.py
#!/usr/bin/env python3
"""Compare local runner usage with GitLab.com SaaS compute minutes using only `glab api`."""
import argparse
import json
import re
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone
COST_FACTORS = {
"small": 1,
"medium": 2,
"large": 3,
"xlarge": 6,
"2xlarge": 12,
}
PLANS = {
"Free": 400,
"Premium": 10000,
"Ultimate": 50000,
}
EXTRA_MINUTES_PRICE_PER_1K = 10
def glab(path: str, *, include_headers: bool = False, silent: bool = False) -> str:
cmd = ["glab", "api"]
if include_headers:
cmd.append("-i")
if silent:
cmd.append("--silent")
cmd.append(path)
try:
return subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
except subprocess.CalledProcessError as e:
print(f"`glab api` failed:\n{e.output}", file=sys.stderr)
raise
def parse_link(header: str) -> dict:
links = {}
for part in header.split(","):
match = re.search(r'<([^>]+)>;\s*rel="([^"]+)"', part)
if match:
links[match.group(2)] = match.group(1)
return links
def last_page(runner_id: int, status: str, per_page: int) -> int:
out = glab(
f"runners/{runner_id}/jobs?status={status}&per_page={per_page}&page=1&order_by=id&sort=asc",
include_headers=True,
silent=True,
)
header_block, separator, _ = out.partition("\r\n\r\n")
if not separator:
header_block, _, _ = out.partition("\n\n")
link = None
for line in header_block.splitlines():
if line.lower().startswith("link:"):
link = line.split(":", 1)[1].strip()
break
if not link:
return 1
last_url = parse_link(link).get("last", "")
match = re.search(r"[?&]page=(\d+)", last_url)
return int(match.group(1)) if match else 1
def fetch_jobs(runner_id: int, status: str, page: int, per_page: int) -> list:
out = glab(
f"runners/{runner_id}/jobs?status={status}&per_page={per_page}&page={page}&order_by=id&sort=asc"
)
return json.loads(out)
def main() -> int:
parser = argparse.ArgumentParser(
description="Convert local runner time to GitLab SaaS compute minutes."
)
parser.add_argument("--runner", type=int, default=12345678)
parser.add_argument("--days", type=int, default=30)
parser.add_argument("--statuses", default="success,failed")
parser.add_argument("--per-page", type=int, default=100)
args = parser.parse_args()
statuses = [s.strip() for s in args.statuses.split(",") if s.strip()]
end = datetime.now(timezone.utc)
start = end - timedelta(days=args.days)
start_str = start.strftime("%Y-%m-%dT%H:%M:%S.000Z")
end_str = end.strftime("%Y-%m-%dT%H:%M:%S.000Z")
print(f"Runner: {args.runner}")
print(f"Window: {start_str} -> {end_str} ({args.days} day(s))")
print(f"Status(es): {', '.join(statuses)}\n")
all_jobs = []
for status in statuses:
last = last_page(args.runner, status, args.per_page)
print(f"{status}: {last} page(s) total")
kept = 0
for page in range(last, 0, -1):
jobs = fetch_jobs(args.runner, status, page, args.per_page)
if not jobs:
continue
newest = max(
(j.get("started_at") or "1970-01-01T00:00:00Z") for j in jobs
)
if newest < start_str:
print(f" page {page}: before window, stopping")
break
page_kept = sum(
1 for j in jobs
if j.get("started_at") and start_str <= j["started_at"] <= end_str
)
for j in jobs:
st = j.get("started_at")
if st and start_str <= st <= end_str:
all_jobs.append(j)
kept += 1
print(f" page {page}: kept {page_kept}")
print(f" total kept: {kept}\n")
total_seconds = sum(j.get("duration") or 0 for j in all_jobs)
print(f"Jobs matched: {len(all_jobs)}")
print(f"Total job run time: {total_seconds / 60:.1f} recorded minutes ({total_seconds / 3600:.1f} h)\n")
print("Equivalent GitLab.com compute minutes by SaaS Linux x86-64 runner size:")
equivalents = {}
for name, factor in COST_FACTORS.items():
minutes = total_seconds / 60 * factor
equivalents[name] = minutes
print(f" {name:7} (cost factor {factor:2}) = {minutes:,.0f} compute minutes")
print("\nPlan fit (small runner estimate):")
baseline = equivalents["small"]
for plan, quota in PLANS.items():
over = max(0, baseline - quota)
cost = over / 1000 * EXTRA_MINUTES_PRICE_PER_1K
status = "within quota" if over == 0 else f"+{over:,.0f} min (~${cost:,.0f})"
print(f" {plan:8} {quota:>6,}: {status}")
print("\nPlan fit (medium runner estimate, cost factor ×2):")
medium = equivalents["medium"]
for plan, quota in PLANS.items():
over = max(0, medium - quota)
cost = over / 1000 * EXTRA_MINUTES_PRICE_PER_1K
status = "within quota" if over == 0 else f"+{over:,.0f} min (~${cost:,.0f})"
print(f" {plan:8} {quota:>6,}: {status}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Results
Running it for runner 12345678 over the last 30 days gave:
| Metric | Value |
|---|---|
| Jobs counted | 2,031 (success + failed; canceled omitted) |
| Total job run time | 7,620 recorded minutes (~127 h) |
| Measurement window | 30 days ending 2026-07-27 |
| All jobs from | example-group/example-monorepo |
Equivalent compute minutes
| SaaS size | Cost factor | Equivalent minutes |
|---|---|---|
| small | 1 | 7,620 |
| medium | 2 | 15,240 |
| large | 3 | 22,861 |
Plan fit
| Plan | Included minutes | vs. small | vs. medium |
|---|---|---|---|
| Free | 400 | +7,220 min (~$72) | +14,840 min (~$148) |
| Premium | 10,000 | within quota | +5,240 min (~$52) |
| Ultimate | 50,000 | within quota | within quota |
The jobs with the highest recorded durations were quality/lint jobs, E2E tests, and Docker image builds:
| Job template | Runs | Recorded minutes |
|---|---|---|
mobile-app:quality | 126 | 1,259 |
api-contracts:drift | 123 | 945 |
e2e-tests:quality | 92 | 687 |
mobile-app:fast | 103 | 610 |
renovate:run | 20 | 516 |
What I’d do differently
The numbers are a lower bound because canceled jobs were excluded. Our local runner reuses the host Docker socket and persistent Maven/pnpm caches, and pulls images only when absent. Jobs on cold SaaS runners would likely take longer, so the estimated compute-minute bill could exceed 7,620 even on small runners.
For sizing, the box has 4 CPU cores, which is closer to a medium runner for CPU, while its 8 GB per-job memory limit is closer to a small runner. The comparison range is 7,620–15,240 compute minutes per month. The small-runner estimate is within the Premium quota; the medium-runner estimate exceeds Premium’s quota and remains within Ultimate’s quota.
GitLab bills compute minutes for time a job actually spent running. A more complete comparison would include canceled jobs with a non-null duration. The available comparison did not change the reported plan classifications when this omission was considered.
References
- GitLab API: List jobs handled by a runner. Returns jobs across all projects a runner can pick up.
- GitLab Docs: Compute minutes. Formula, cost factors, and quotas.
- about.gitlab.com pricing FAQ for compute minutes. Plan quotas and extra-minute pricing.
glabCLI documentation.glab apihandles authentication; this script handles pagination explicitly.
This post was written with AI assistance.