Pelican logoPelican runs on its own GitLab CI stack, saving about $70,000 a year in equivalent hosted minutes. Shared with ❤️ 🏎️💨
Found a bug? Poke @featherless Prices and uptime data read on August 6, 2026

Looking for something to do while GitHub is down?

GitHub Actions has been down 68.5 hours so far in 2026.

Standing up your own GitLab server takes an afternoon, and keeping it running takes a few hours a month, so in the time you spend watching that status page you could build the whole thing three times over.

Below: the parts at today's prices, the install commands, the migration steps, and a calculator for when it pays for itself.

Rolling 90-day uptime

Healthy Degraded
89.93%2026 so far
91.06%Trailing 12 mo
95.40%All time
78.33%Worst month

Actions on its own

The part of GitHub this page replaces

Year Incidents Hours Critical
2024 27 46.0 0
2025 36 95.9 0
2026 so far 30 68.5 6

Seven months of 2026 already beat all of 2024, and the critical rating went from unused to six. Half these incidents ran past two hours.

The long ones

Single Actions outages, 2026

Reading the numbers

  • Each point is the share of the prior 90 days GitHub was healthy, from The Missing GitHub Status Page.
  • Maintenance is excluded. Everything else counts in full, so this is time GitHub was not fully healthy, not time it was hard down.
  • The mirror trails live status by about a day, so today's incident is not here yet. Figures run through August 5, 2026.
  • August 2026 is a partial month.
1

Buy the box

GitLab wants 8 vCPU and 16 GB for one node. All three clear it.

Mini PC

Cheapest way in
$579 buybox price

Eight cores, 24 GB and a terabyte for under $600, which is the whole argument on this page for the price of a few months of seats. Nothing to assemble and quiet enough for a shelf.

CPU
8 cores / 16 threads
RAM
24 GB LPDDR5
Disk
1 TB NVMe
Net
Gigabit
Size
5 × 5 × 1.6 in
Power
11 W idle · 55 W building
How many 0

The memory is soldered, so 24 GB is what you have forever. It clears GitLab's 16 GB baseline with room for the graphics share these chips carve out of system RAM.

Gigabit rather than 2.5 GbE, which only matters if you move large artifacts around.

The same, but bigger

Room to grow
$859 buybox price

The same box one size up: eight cores instead of six, 32 GB instead of 16, DDR5 instead of DDR4. Still nothing to assemble, still quiet enough for a shelf.

CPU
8 cores / 16 threads
RAM
32 GB DDR5
Disk
1 TB NVMe
Net
2.5 GbE
Size
5 × 5 × 1.6 in
Power
12 W idle · 65 W building
Beelink SER8 $859.00
How many 0

Rackmount tower

Best value · real build
$1,092 for 7 parts

Fourteen cores for the price of eight, because it is DDR4 on LGA 1700 instead of DDR5. This is an actual parts list from an actual build, not a spec sheet. 3U, so it slides into a rack next to the switch.

CPU
14 cores / 20 threads
RAM
32 GB DDR4 (128 GB max)
Disk
1 TB NVMe
Form
3U rackmount
Build
About 90 minutes
Power
35 W idle · 130 W building
How many 0

The tray CPU ships without a cooler. Boxed retail runs about $295 at Newegg, cheaper than Amazon's boxed listing.

Mac mini M4

macOS CI only
$799 buybox price

Hosted macOS minutes bill at ten times the Linux rate, so App Store builds are where a GitHub invoice grows fastest. Buy the base config and give it one job: running the macOS runner.

CPU
10 cores
RAM
16 GB unified
Disk
256 GB
Net
Gigabit (10 GbE option)
Size
5 × 5 × 2 in
Power
4 W idle · 65 W building
How many 0

GitLab Omnibus has no macOS package. The server lives on the Linux box; this one only runs the runner.

Power protection

So an outage does not corrupt the database

APC BX850M $161.99
850 VA / 510 W, enough for every build on this page. Has a USB data port, so the machine can shut itself down cleanly before the battery runs out. Drive it with apcupsd or NUT.
How many 0
1500 VA / 1000 W pure sine, also over USB. Only worth it once you are running several machines off one battery.
How many 0
VEVOR 1U PDU $37.90
Switched rack PDU, if you are not already getting one with the tower.
How many 0

Amazon links are affiliate links. Every price was read off its listing on August 6, 2026, not estimated, and will have moved since.

Why the parts cost this much

Every price on this page was pulled live on August 6, 2026

Datacenter demand has roughly doubled memory and NAND prices this year. Memory and storage are now most of what you pay, so buying last generation is what keeps the bill down.

Instead of Buy Saves
32 GB DDR5-5600 · $529.94 32 GB DDR4-3200 · $249.99 $279.95
64 GB DDR5-6000 · $949.99 32 GB, and add later $700.00
2 TB NVMe · $374.95 1 TB NVMe · $249.99 $124.96
8-core Zen 5 · $279.99 14-core i5-13500 · $279.99 6 more cores
  • DDR4 on LGA 1700 is where the value is. A 13th-gen i5 gives you 14 cores on cheap memory. The DDR5 equivalent costs $280 more for six fewer cores.
  • 1 TB is plenty. GitLab wants 40 GB plus your repos, and most teams' whole history is a few gigabytes. Buy more when you run out.
  • Buy the least memory that clears 16 GB. Add more when prices fall.
  • Stock moves weekly. Several parts here had no buybox the day this was written.

Sizing rules

Straight from GitLab's requirements page

Resource Baseline Notes
CPU 8 vCPU 4 is fine for a few users. GitLab advises against burstable cloud instances such as AWS t-series, which throttle under sustained load
Memory 16 GB 8 GB is the documented floor, with swap
App disk 40 GB Plus every repository you import
Postgres 5–12 GB 12 GB if you enable Ultimate features
Storage SSD Gitaly is I/O bound, so keep repos off NFS and network shares

Omnibus bundles Redis and Postgres. You install neither.

Network setup

Do this before you install anything

  • Give the box a DHCP reservation so its address never moves.
  • Pick a real hostname you control, like gitlab.example.com, even on a LAN. Certificates and clone URLs bake it in.
  • Point it at the LAN IP with split-horizon DNS, or add it to every /etc/hosts.
  • Open 22, 80 and 443. Keep it off the public internet unless you want to run a reverse proxy and fail2ban.
  • Remote access without port-forwarding: Tailscale or a Cloudflare Tunnel.

Prompt: Buy the box

Fill in anything in angle brackets, then hand it to your agent.

Help me buy hardware to self-host GitLab so my team stops depending on GitHub Actions.

My situation:
- Developers: {{devs}}
- We build for: {{platforms}}
- CI usage today: {{linux}} Linux minutes and {{macos}} macOS minutes a month
- Currently on the GitHub {{plan}} plan
- Shortlist I am already looking at: {{machines}}, about {{budget}}

Constraints you must respect:
- GitLab's own baseline for a single node is 8 vCPU, 16 GB RAM, and 40 GB of
  disk plus the size of every repository, on SSD. Gitaly is I/O bound, so no
  NFS and no network shares.
- Concurrent CI jobs = monthly minutes / 60 / 160 working hours. Budget about
  2 threads and 4 GB per concurrent job. Past one box, put GitLab on its own
  machine so a runaway build cannot make the web UI unresponsive.
- DDR4 on LGA 1700 currently gives far more cores per dollar than DDR5. A
  32 GB DDR4 kit runs about half what the same capacity costs in DDR5.
- Apple platforms need real Apple hardware. Hosted macOS minutes bill at ten
  times the Linux rate, which is usually what justifies the purchase.

Pricing, and read this twice:
- Every price you remember is wrong. Datacenter demand has roughly doubled
  memory and NAND over the last year. A 64 GB DDR5 kit that a model would
  guess at $175 is closer to $950 today, and a 2 TB NVMe drive that used to be
  $160 is closer to $375.
- So: open a real listing for every single part before you quote it. Read the
  price off the page. If you cannot open the listing, say so and leave the
  price blank rather than filling in a number you believe.
- Check it is actually in stock and actually shipping from the retailer, not
  a marketplace reseller at three times the price. Plenty of mainstream parts
  currently have no buybox at all.
- Tell me the date you checked, and link every listing you used.

Deliver:
1. One recommended build, with a table of part, model, live price, listing
   link, and the date you checked it.
2. The total, and the expected idle and full-load wattage.
3. A UPS with a USB data port, sized to that load, so the machine can shut
   down cleanly. Not a bigger one than the load needs.
4. Anything out of stock, called out explicitly with an alternative. Do not
   substitute parts silently.
2

Install GitLab

Ubuntu 24.04 LTS and the Omnibus package, in about four commands.

Prepare the OS

Ubuntu 24.04 LTS server

sudo apt update && sudo apt upgrade -ysudo apt install -y curl openssh-server ca-certificates tzdata perl# Outbound notification mail. Choose "Internet Site" and# enter the hostname you picked above.sudo apt install -y postfixsudo timedatectl set-timezone America/New_Yorksudo systemctl enable --now ssh

Skip Postfix if you send mail through SMTP. That goes in gitlab.rb.

Install the package

One package, and it is the Enterprise one

curl --location \
  "https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh" \
  | sudo bashsudo EXTERNAL_URL="https://gitlab.example.com" apt install gitlab-ee

GitLab ships two builds. CE is the community edition, EE is the enterprise one, and they are the same binary: with no licence applied, EE runs the Free feature set exactly as CE does. Install gitlab-ee anyway, because moving to a paid tier later is then a licence upload rather than a reinstall and migration. GitLab explains the split in its installation docs.

First login

The root password self-destructs in 24 hours

sudo cat /etc/gitlab/initial_root_password# Sign in at https://gitlab.example.com as "root",# then immediately:#   Admin > Settings > General > Sign-up restrictions#     - untick "Sign-up enabled"#   Admin > Settings > General > Visibility#     - default project visibility: Private#   User > Preferences > SSH Keys - add your key

If the file is already gone, reset the password instead.

sudo gitlab-rake "gitlab:password:reset[root]"

TLS

Two paths, pick one

Public DNS. Omnibus requests a Let's Encrypt certificate automatically when EXTERNAL_URL is https:// and port 80 is reachable.

# /etc/gitlab/gitlab.rbletsencrypt['enable'] = trueletsencrypt['contact_emails'] = ['ops@example.com']letsencrypt['auto_renew'] = true

LAN only. Issue your own and drop the pair in place:

sudo mkdir -p /etc/gitlab/ssl && sudo chmod 755 /etc/gitlab/ssl# copy gitlab.example.com.crt and .key into /etc/gitlab/ssl/# /etc/gitlab/gitlab.rbletsencrypt['enable'] = falsenginx['redirect_http_to_https'] = true

Tune gitlab.rb

Omnibus defaults assume a server rack

Pick a machine in step 1 and these values follow its cores and memory.

sudo gitlab-ctl reconfigure

Omnibus bundles Prometheus for GitLab's built-in performance dashboards. Whether to keep it depends on the memory you have.

Health check

Everything green before you migrate

sudo gitlab-ctl statussudo gitlab-rake gitlab:check SANITIZE=truesudo gitlab-rake gitlab:env:info# Watch a reconfigure or a slow first bootsudo gitlab-ctl tail

Prompt: Install GitLab

Fill in anything in angle brackets, then hand it to your agent.

Install GitLab on a fresh Ubuntu 24.04 LTS machine I control.

Access: I have sudo over SSH at {{ssh}}.
External URL: https://gitlab.example.com
TLS: {{tls}}.
The machine has {{cores}} cores and {{ram}} GB of RAM.

Facts you need:
- Install the `gitlab-ee` package, not `gitlab-ce`. With no licence applied it
  behaves exactly as Free, and moving to a paid tier later is a licence upload
  rather than a reinstall.
- The initial root password is in /etc/gitlab/initial_root_password and is
  deleted automatically after 24 hours.
- Omnibus bundles PostgreSQL and Redis. Do not install either separately.
- Omnibus defaults assume a much larger machine. Scale puma workers, sidekiq
  concurrency and postgresql shared_buffers to the cores and RAM above.

Do this, showing me each command before you run it:
1. Install dependencies, add the repository, and install with EXTERNAL_URL set.
2. Retrieve the initial root password and tell me to change it immediately.
3. Turn off open sign-ups and set default project visibility to Private.
4. Write the tuning values into /etc/gitlab/gitlab.rb and reconfigure.
5. Verify with `gitlab-ctl status` and `gitlab-rake gitlab:check SANITIZE=true`,
   and show me the output.

Do not expose this instance to the public internet. If it needs remote access,
propose Tailscale or a Cloudflare Tunnel rather than port forwarding.
3

Back it up

Do this before you migrate anything, while there is nothing to lose.

What a backup actually contains

Two artifacts, and the second one is the one people forget

# Repos, database, uploads, CI artifactssudo gitlab-backup create STRATEGY=copy# Secrets and config - NOT in the backup above.# Without these the backup will not restore.sudo tar -czf /secure/gitlab-config.tgz \
  /etc/gitlab/gitlab.rb /etc/gitlab/gitlab-secrets.json# Nightly at 02:00echo '0 2 * * * root /usr/bin/gitlab-backup create CRON=1' \
  | sudo tee /etc/cron.d/gitlab-backup

Archives land in /var/opt/gitlab/backups as <timestamp>_gitlab_backup.tar. Set gitlab_rails['backup_keep_time'] = 604800 or that directory grows until the disk fills.

Get the backups off the box

A backup sitting on the machine that died is not a backup

Omnibus pushes each backup straight to any S3-compatible bucket. Backblaze B2 and Cloudflare R2 cost a few dollars a month here, and neither charges egress.

# /etc/gitlab/gitlab.rbgitlab_rails['backup_upload_connection'] = {
  'provider' => 'AWS',
  'region' => 'us-west-000',
  'aws_access_key_id' => 'KEY',
  'aws_secret_access_key' => 'SECRET',
  # Drop this line if you really are on AWS
  'endpoint' => 'https://s3.us-west-000.backblazeb2.com'
}gitlab_rails['backup_upload_remote_directory'] = 'acme-gitlab-backups'gitlab_rails['backup_multipart_chunk_size'] = 104857600gitlab_rails['backup_encryption'] = 'AES256'gitlab_rails['backup_keep_time'] = 604800sudo gitlab-ctl reconfiguresudo gitlab-backup create CRON=1
  • Give the bucket key write and list only. A compromised runner should not be able to delete your history.
  • Turn on object lock, which makes an uploaded file impossible to change or delete until a date you set. Anything that gets hold of your credentials can then write new objects but cannot touch last week's.
  • gitlab-secrets.json is excluded from the archive on purpose. Put it in a password manager, not the bucket.
  • backup_keep_time prunes local copies only. Set a bucket lifecycle rule for the rest.

Restore into a throwaway VM every quarter. An untested backup is a guess.

sudo gitlab-backup restore BACKUP=1754400000_2026_08_05_18.2.1

Prompt: Back it up

Fill in anything in angle brackets, then hand it to your agent.

Set up backups for my self-hosted GitLab, before I migrate anything onto it.
Instance: https://gitlab.example.com

Facts you need, because getting this wrong makes the backups useless:
- `gitlab-backup create` writes an archive to /var/opt/gitlab/backups. It
  contains repositories, the database, uploads and CI artifacts.
- It deliberately does NOT contain /etc/gitlab/gitlab.rb or
  /etc/gitlab/gitlab-secrets.json. Without the secrets file a restore cannot
  decrypt anything, so those two must be captured separately and stored
  somewhere other than the backup bucket.
- `backup_keep_time` only prunes local copies. Remote retention needs a
  lifecycle rule on the bucket.

Do this:
1. Schedule a nightly `gitlab-backup create CRON=1`.
2. Configure `gitlab_rails['backup_upload_connection']` to push each archive to
   {{bucket}}, with server-side encryption on.
3. Scope the bucket credential to write and list only, so a compromised runner
   cannot delete history. Turn on object lock or versioning.
4. Capture gitlab.rb and gitlab-secrets.json into {{vault}},
   and tell me exactly what you stored and where.
5. Prove it works: restore into a throwaway VM and show me the result. An
   untested backup is a guess.

Tell me the restore procedure in full when you are done, written so that
somebody who is not you can follow it during an outage.
4

Migrate from GitHub

GitLab's importer pulls repos, issues, pull requests and comments over the GitHub API.

Token

Classic PAT only, since fine-grained tokens do not work

  • repo, required.
  • read:org, needed for collaborators and Git LFS objects.
  • The source organization must not enforce third-party application restrictions, or the token silently sees nothing.
  • You need Maintainer or Owner on the destination GitLab group.

Generate one at github.com/settings/tokens, which opens the classic form with both scopes already ticked. Delete it when the migration is done.

Run the import

UI for a handful, API for a fleet

UI: + → New project → Import project → GitHub, paste the token, tick the repositories.

# API - one repocurl --request POST "https://gitlab.example.com/api/v4/import/github" \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "personal_access_token": "'"$GITHUB_TOKEN"'",
    "repo_id": 123456789,
    "target_namespace": "acme/backend",
    "new_name": "billing",
    "optional_stages": {
      "single_endpoint_issue_events_import": true,
      "attachments_import": true,
      "collaborators_import": true
    }
  }'

The API path also works for public repositories you do not own, and for GitHub Enterprise Server sources. The UI flow handles neither.

Run both at once for a while

Mirror first, switch when you are ready

Set the new GitLab project to pull-mirror from GitHub. Both remotes stay live while you rewrite CI; when you are ready, flip the mirror off and archive the GitHub repo.

# Settings > Repository > Mirroring repositories#   Direction: Pull#   URL: https://<user>@github.com/acme/billing.git#   Password: GitHub PAT# Then on every developer's laptop:git remote set-url origin git@gitlab.example.com:acme/billing.gitgit remote -v

Push-mirroring in the other direction (GitLab → GitHub) keeps a public read-only copy alive if your users expect one.

What actually comes across

Everything the importer moves, and everything it does not

Item Imported
Repository, all branches and tags Yes
Issues, PRs (as merge requests), comments Yes
Labels, milestones, release notes Yes
Wiki, branch protection rules Yes
Git LFS objects Yes, with read:org
Collaborators and their roles Opt-in stage
Markdown attachments Opt-in stage
Actions workflows No, rewrite by hand
Secrets and variables No, GitHub never exposes them
Webhooks, deploy keys, Pages No
Discussions, Projects boards No

Comment threads above roughly 30,000 items are skipped unless you enable the single-endpoint stage. GitLab tested the importer against Kubernetes, which has 80k PRs and 1.5M comments, and it took about 76 hours. Both figures come from the GitHub importer documentation.

Post-import checklist

Per repository, once the import finishes

Prompt: Migrate from GitHub

Fill in anything in angle brackets, then hand it to your agent.

Migrate my GitHub repositories to my self-hosted GitLab, where I am an Owner.
Instance: https://gitlab.example.com

Source: GitHub {{source}}
Repositories: {{repos}}
Team size, for seat planning: {{devs}}

Facts you need:
- The importer needs a CLASSIC personal access token with the `repo` scope,
  plus `read:org` for collaborators and Git LFS objects. Fine-grained tokens do
  not work. If the org enforces third-party application restrictions the token
  will silently see nothing.
- It brings across: repository, branches, tags, issues, pull requests as merge
  requests, comments, labels, milestones, release notes, wikis, and branch
  protection rules.
- It does NOT bring across: Actions workflows, secrets, webhooks, deploy keys,
  Pages, Discussions, or Projects boards. All of those are manual.
- Use POST /api/v4/import/github rather than the UI, so this is repeatable and
  so it works for public repositories I do not own.

Do this:
1. Import each repository, with the collaborators and attachments stages on.
2. After each one, verify the default branch is correct, protected branch and
   tag rules exist, and LFS objects arrived.
3. Give me a per-repository checklist of everything that did not transfer.
4. Set up pull mirroring from GitHub so both sides stay live during cutover,
   and tell me how to switch it off when we commit.
5. Give me the exact `git remote set-url` command for each repository.

Do not delete, archive, or change anything on GitHub without asking me first.
5

Runners and CI

The runner takes five minutes. Rewriting the workflows takes days.

Linux runner

Docker executor, for everything that is not an Apple build

curl --location \
  "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" \
  | sudo bashsudo apt install -y gitlab-runner docker.iosudo usermod -aG docker gitlab-runner# Create the runner in the UI first:#   Group or project > Settings > CI/CD > Runners > New runner# It hands you a token beginning with glrt-sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.example.com" \
  --token "$RUNNER_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --docker-privileged=false \
  --description "shelf-box-docker"

--registration-token still works but is deprecated and scheduled for removal in GitLab 20.0. Use glrt- authentication tokens for anything new.

What to tune afterwards

Registration writes config.toml for you

You do not write /etc/gitlab-runner/config.toml by hand. Registration creates it and fills in the token. Three values in it are worth revisiting.

Key Why
concurrent Total jobs across every runner on the host. Roughly core count minus two, so GitLab keeps headroom
[runners.docker] memory, cpus Caps one job so a runaway build cannot take the machine down with it
pull_policy if-not-present stops every job re-pulling images over your connection
sudo gitlab-runner restart

Watch queue times under Admin, CI/CD, Runners. Jobs sitting in pending mean you need another host, not more concurrency on this one.

macOS runner

Shell executor, because macOS cannot be containerised

brew install gitlab-runnerbrew services start gitlab-runnergitlab-runner register \
  --non-interactive \
  --url "https://gitlab.example.com" \
  --token "$RUNNER_TOKEN" \
  --executor "shell" \
  --shell "bash" \
  --tag-list "macos,xcode" \
  --description "mac-mini-m4"# Keep Xcode explicit so a Sonoma update cannot silently# change the toolchain under your builds.sudo xcode-select -s /Applications/Xcode.app

The shell executor gives you no isolation, so jobs share one machine's state. Reset simulators and derived data between runs. Keep it away from untrusted forks.

Actions → GitLab CI

The translation table

GitHub Actions GitLab CI Notes
.github/workflows/*.yml .gitlab-ci.yml One file at the repo root
jobs.<id>.runs-on tags: Selects runners by tag
steps: - run: script: A plain list of shell lines
uses: actions/checkout Nothing GitLab always clones first
uses: actions/cache cache: {key, paths} Built in
uses: actions/upload-artifact artifacts: paths: Built in
container: image: Same idea
services: services: Same key, different syntax
env: variables:
secrets.FOO $FOO Masked + protected CI/CD variable
if: rules: - if: Rules also gate when and allow_failure
needs: needs: Same key
strategy.matrix parallel: matrix:
concurrency resource_group: Serializes deploys
workflow_dispatch when: manual Or a pipeline schedule
workflow_call include: project: Plus extends:
Composite action CI/CD component Or a plain include:
${{ github.sha }} $CI_COMMIT_SHA
${{ github.ref_name }} $CI_COMMIT_REF_NAME
${{ github.repository }} $CI_PROJECT_PATH
${{ runner.os }} $CI_RUNNER_EXECUTABLE_ARCH
GITHUB_TOKEN $CI_JOB_TOKEN Scoped to the job, expires with it

There is no official one-shot converter in this direction. The Marketplace is the hard part: every uses: line is a dependency you now write yourself, or replace with a CI/CD component.

The same pipeline, both ways

Build, test, deploy, which covers most workflows

.github/workflows/ci.yml
name: CIon: [push]jobs:  build:    runs-on: ubuntu-latest    container: node:22    steps:      - uses: actions/checkout@v4      - uses: actions/cache@v4        with:          path: .npm          key: cache-${{ hashFiles('package-lock.json') }}      - run: npm ci      - run: npm run build      - uses: actions/upload-artifact@v4        with:          path: dist/          retention-days: 7  test:    needs: build    runs-on: ubuntu-latest    strategy:      matrix:        node: ["20", "22"]    container: node:${{ matrix.node }}    steps:      - uses: actions/checkout@v4      - run: npm ci      - run: npm test  deploy:    needs: test    runs-on: ubuntu-latest    if: github.ref_name == 'main'    concurrency: production    environment:      name: production      url: https://example.com    steps:      - uses: actions/checkout@v4      - run: ./deploy.sh
.gitlab-ci.yml
stages: [build, test, deploy]default:  image: node:22  tags: [docker]cache:  key:    files: [package-lock.json]  paths: [.npm]build:  stage: build  script:    - npm ci    - npm run build  artifacts:    paths: [dist/]    expire_in: 1 weektest:  stage: test  needs: [build]  parallel:    matrix:      - NODE: ["20", "22"]  image: node:$NODE  script: [npm ci, npm test]deploy:  stage: deploy  needs: [test]  resource_group: production  environment:    name: production    url: https://example.com  rules:    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH  script: [./deploy.sh]

The checkout steps have no counterpart because GitLab clones before every job. Caching and artifacts stop being Marketplace actions and become two keys in the file.

How many boxes

Working out runner capacity from your minute count

CI minutes do not spread evenly across the month. They land in business hours, so size against that window rather than 730 hours.

concurrent jobs = monthly minutes / 60 / 160 working hours  20,000 min  ->  2 jobs at once   ->  one box, easily  80,000 min  ->  8 jobs at once   ->  two boxes 200,000 min  -> 21 jobs at once   ->  four or more
  • Budget roughly 2 threads and 4 GB per concurrent job. A 14-core i5-13500 comfortably runs six to eight.
  • Once you pass one box, put GitLab on its own machine. A runaway build should not make the web UI unresponsive for everybody.
  • Two runner hosts also means you can reboot one for kernel updates without stopping CI.
  • Watch Admin → CI/CD → Runners for queue wait times. Jobs sitting in pending is the signal to add a box, not CPU percentage.

These are the numbers behind the profiles in the calculator. The scaling team preset picks two towers for exactly this reason.

Registry and packages

Stop paying for GHCR while you are here

docker login registry.example.com \
  -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"# In .gitlab-ci.yml the credentials are already there:publish:  image: docker:27  services: [docker:27-dind]  script:    - docker login -u "$CI_REGISTRY_USER" \
        -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"

Set a cleanup policy under Settings → Packages and registries, or the registry will happily eat the whole disk.

Prompt: Runners and CI

Fill in anything in angle brackets, then hand it to your agent.

Set up GitLab runners and convert my GitHub Actions workflows to GitLab CI.
Instance: https://gitlab.example.com

Runner hosts: {{runnerhosts}}
Workflows to convert: {{workflows}}

Facts you need:
- Register with a runner AUTHENTICATION token, which starts with `glrt-` and is
  created in the UI under Settings, CI/CD, Runners. `--registration-token` is
  deprecated and is removed in GitLab 20.0.
- Set `concurrent` in /etc/gitlab-runner/config.toml to roughly the core count
  minus two, so GitLab itself keeps headroom during a busy pipeline.
- On Linux use the Docker executor. On macOS use the shell executor, because
  macOS cannot be containerised. The shell executor has no isolation, so reset
  simulators and derived data between jobs, and never point it at forks.
- Pin the Xcode path explicitly with `xcode-select -s` so an OS update cannot
  change the toolchain underneath the builds.

Translation rules:
- `runs-on` becomes `tags`. `steps.run` becomes `script`.
- `actions/checkout` becomes nothing; GitLab clones automatically.
- `actions/cache` becomes `cache:`, `actions/upload-artifact` becomes
  `artifacts:`, `container:` becomes `image:`.
- `strategy.matrix` becomes `parallel: matrix:`, `concurrency` becomes
  `resource_group:`, `workflow_call` becomes `include: project:`.
- `${{ github.sha }}` becomes `$CI_COMMIT_SHA`, `github.ref_name` becomes
  `$CI_COMMIT_REF_NAME`, `github.repository` becomes `$CI_PROJECT_PATH`, and
  `GITHUB_TOKEN` becomes `$CI_JOB_TOKEN`.
- Every remaining `uses:` line is a Marketplace dependency I now own. Replace it
  with explicit script steps or a CI/CD component, and list the ones you could
  not replace so I can decide.

Do this:
1. Install and register the runners, tagged so Linux and macOS jobs land on the
   right machine.
2. Write .gitlab-ci.yml for each workflow.
3. List every secret the workflows reference, so I can add them as masked and
   protected CI/CD variables. Do not put secrets in the file.
4. Run the pipeline and iterate until it is green, showing me the failures.