Buy the box
GitLab wants 8 vCPU and 16 GB for one node. All three clear it.
Mini PC
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
| Beelink SER5 Max | $579.00 |
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
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 |
Rackmount tower
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
| Intel Core i5-13500 | $279.99 |
| MSI PRO B760M-P DDR4 | $94.51 |
| Corsair Vengeance LPX 32 GB DDR4-3200 | $249.99 |
| WD_BLACK SN850X 1 TB | $249.99 |
| Rosewill RSV-Z3200U | $129.99 |
| ASRock PRO-650G | $49.99 |
| VEVOR 1U PDU | $37.90 |
The tray CPU ships without a cooler. Boxed retail runs about $295 at Newegg, cheaper than Amazon's boxed listing.
Mac mini M4
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
| Mac mini (M4, 2024) | $799.00 |
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
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.
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.
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.jsonis excluded from the archive on purpose. Put it in a password manager, not the bucket.backup_keep_timeprunes 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.
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.
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
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
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
pendingis 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.
