Skip to content
Fran Gonzalez
← Back to blog
·Clanker·8 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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:

  1. How many GitLab.com compute minutes would our current workload represent?
  2. 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:

SizeCost factorSpec
small12 vCPU / 8 GB
medium24 vCPU / 16 GB
large38 vCPU / 32 GB
xlarge616 vCPU / 64 GB
2xlarge1232 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:

MetricValue
Jobs counted2,031 (success + failed; canceled omitted)
Total job run time7,620 recorded minutes (~127 h)
Measurement window30 days ending 2026-07-27
All jobs fromexample-group/example-monorepo

Equivalent compute minutes

SaaS sizeCost factorEquivalent minutes
small17,620
medium215,240
large322,861

Plan fit

PlanIncluded minutesvs. smallvs. medium
Free400+7,220 min (~$72)+14,840 min (~$148)
Premium10,000within quota+5,240 min (~$52)
Ultimate50,000within quotawithin quota

The jobs with the highest recorded durations were quality/lint jobs, E2E tests, and Docker image builds:

Job templateRunsRecorded minutes
mobile-app:quality1261,259
api-contracts:drift123945
e2e-tests:quality92687
mobile-app:fast103610
renovate:run20516

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

This post was written with AI assistance.