When a small team asks for its own Git server, GitLab CE on one Ubuntu VM is the usual answer. It installs on Ubuntu 26.04 LTS and 24.04 LTS with the same commands from the official package, once called Omnibus. GitLab’s Ubuntu install page still lists only 22.04 and 24.04, but its supported-platforms table has carried 26.04 since 19.3.
The package is the easy part. The worst of what surrounds it fails quietly: a failed Let’s Encrypt request leaves a self-signed placeholder that later reconfigures serve, and the backup skips the file that decrypts CI variables. The first push stops too, because Git still starts on master. GitLab took 4.2 GiB of an idle 8 GB server on 26.04 and 5.7 GiB on 24.04 after its first push. The low-memory settings gave back 1.7 GiB on 26.04. Each fix is below, with lab output.
Updated October 2026: tested on both releases with GitLab CE 19.4.2. Push, backup and restore ran on 24.04, certificate recovery and memory tests on 26.04.
What the server needs
Memory is the number to get right with GitLab, then disk. For the core setup you need:
- Ubuntu 26.04 LTS or 24.04 LTS from the standard server image, and a user with sudo
- 4 vCPU, 8 GB RAM and a 40 GB disk to follow along, as our lab VMs had
- 8 vCPU and 16 GB RAM for production, GitLab’s single-node baseline
- A hostname that resolves to the server, such as
gitlab.example.com - Ports 80 and 443 reachable from the internet, if you want a free Let’s Encrypt certificate
The lab spec is a floor, not a production size. With default settings, our idle 26.04 server sat at 4,325 MiB of proportional memory (PSS). The 24.04 server read 5,881 MiB, but we took that reading about a minute after the first push to it. Nearly all of the gap is Puma, at 3,399 MiB against 1,945, which points to Puma workers growing as they serve requests rather than to a difference between the releases. Sidekiq is the other service that grows with traffic.
GitLab’s requirements page allows 8 GB only with the memory-constrained settings in the RAM section below. The vCPU count matters more than it looks, because Puma starts a worker per vCPU by default, up to what the RAM allows, and Puma is where most of the memory goes.
Disk is the other driver. The package alone took 3.7 GB under /opt/gitlab, and repositories, CI artifacts, container images and backups all land on the same server. Size the disk for that data plus a week of nightly backup archives, which is what the retention setting in the backup section keeps on disk.
A free Let’s Encrypt certificate needs the hostname to be public. A private name works too, but then you bring your own certificate, which the install section covers. Any DNS provider works.
Minimal cloud images leave out gpg, vim, cron and dig, which later steps use. On one of those, install the gnupg, vim, cron and bind9-dnsutils packages before you start.
Setting the GitLab hostname
The installer bakes the hostname into every config file it writes, so getting it right before the install saves a reconfigure later. Two values repeat through the guide, the hostname and the admin email, so export both once in your SSH session with your own values:
export GITLAB_DOMAIN="gitlab.example.com"
export ADMIN_EMAIL="[email protected]"
The exports only live in the current shell, so run them again after you reconnect. Confirm both are set:
echo "${GITLAB_DOMAIN} ${ADMIN_EMAIL}"
Then check that the name points at this server before you install anything.
getent hosts "${GITLAB_DOMAIN}"
getent should print this server’s IP address next to the name. Empty output means the name doesn’t resolve yet, and that is exactly what makes the certificate request during the install fail, so fix the DNS record before you go on. getent reads /etc/hosts first, though, and many providers map the server’s own name to 127.0.1.1 there. For a Let’s Encrypt certificate, check the public record too, either from a machine outside the server or with dig +short "${GITLAB_DOMAIN}" @1.1.1.1, which asks a public resolver and skips /etc/hosts. It should print the server’s public IP.
Opening the firewall
The core setup needs three firewall rules:
- Port 22, for your SSH session and Git over SSH
- Port 80, for Let’s Encrypt and the redirect to HTTPS, unused if you bring your own certificate
- Port 443, for the web UI over HTTPS
Port 80 stays open even after HTTPS works, because Let’s Encrypt renewals come in on it. If you bring your own certificate, leave the port 80 rule out. Allow SSH before you enable UFW, or you’ll cut off your own session:
sudo apt update
sudo apt-get -o DPkg::Lock::Timeout=600 install -y curl ca-certificates ufw
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
We added DPkg::Lock::Timeout because fresh cloud images often run a package upgrade on first boot. On our 26.04 server that upgrade held the dpkg lock, and an apt run without the option stopped at once with this:
E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 2027 (apt-get)
E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), is another process using it?
With the option, apt waits for the lock instead of failing. If you need more than these three rules, our UFW firewall guide covers rate limiting and per-source rules.
Adding the GitLab CE repository on Ubuntu
GitLab’s docs pipe a setup script into sudo bash. We added the repository by hand instead, because it takes two commands and you see exactly what lands on your system. First, import GitLab’s signing key into a keyring of its own:
curl -fsSL https://packages.gitlab.com/gitlab/gitlab-ce/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/gitlab-ce.gpg
Then write the source file. It reads the release codename from /etc/os-release, so the same line works on 26.04 (resolute) and 24.04 (noble), and its Signed-By field ties the repository to the key you just imported:
. /etc/os-release
printf 'Types: deb\nURIs: https://packages.gitlab.com/gitlab/gitlab-ce/ubuntu/\nSuites: %s\nComponents: main\nSigned-By: /usr/share/keyrings/gitlab-ce.gpg\n' "$VERSION_CODENAME" | sudo tee /etc/apt/sources.list.d/gitlab-ce.sources
Refresh the package index and ask apt which GitLab version it would install.
sudo apt update
apt-cache policy gitlab-ce | head -3
On our 26.04 server the update pulled the index from the resolute/main suite, and the policy showed a candidate with nothing installed yet:
gitlab-ce:
Installed: (none)
Candidate: 19.4.2-ce.0
A version on the Candidate line means the repository works. A broken source usually fails one step earlier: apt update complains about a missing Release file or a bad signature, and apt-cache policy then answers Unable to locate package gitlab-ce. In that case check the codename on the Suites line and the keyring path in the source file before you go further.
Installing GitLab CE
Decide on the certificate before you run the install, because the install requests it. Pass your URL in EXTERNAL_URL. With an https:// URL, GitLab asks Let’s Encrypt for a certificate for that name on the first run, which is the right choice for a public name with port 80 open. On a private network or behind NAT the HTTP challenge can’t reach the server, so run the copy commands under Using your own certificate instead first, then come back to this step. Leave gitlab.rb alone until the install has finished, because the package only writes its full config when that file doesn’t exist yet:
sudo EXTERNAL_URL="https://${GITLAB_DOMAIN}" apt install -y gitlab-ce
Our 24.04 install took 4 minutes 53 seconds. Most of that is the first gitlab-ctl reconfigure, which writes the configs and initialises the database, so a quiet terminal for a few minutes is normal. If apt stops with a dpkg lock error instead, it’s the same cloud-image upgrade as in the firewall section, so wait a few minutes and run the install again. When the package finishes, check that every service is up:
sudo gitlab-ctl status
A default install runs 15 services, and each line should start with run:. Here is our 24.04 server, with the package version on top:

A line that starts with down: names a service that isn’t running, and a run: line whose uptime keeps resetting to a few seconds is a service restarting in a loop. To read its log, run sudo gitlab-ctl tail puma with the name of whichever service is in trouble. With a public name, also check that the certificate really came from Let’s Encrypt, because a failed request doesn’t always look like one:
echo | openssl s_client -connect "${GITLAB_DOMAIN}:443" -servername "${GITLAB_DOMAIN}" 2>/dev/null | openssl x509 -noout -issuer
The issuer line should name Let’s Encrypt. If it shows your own hostname instead, GitLab is serving a self-signed placeholder, and the next section explains how a failed request leaves one behind.
If the Let’s Encrypt step fails
The certificate request runs inside the install, so when DNS doesn’t point at the server yet or port 80 is closed, the install itself ends with an error like the one we captured:
FATAL: Acme::Client::Error::RejectedIdentifier: letsencrypt_certificate[gitlab.example.com] (letsencrypt::http_authorization line 6) had an error: Acme::Client::Error::RejectedIdentifier: acme_certificate[staging] (letsencrypt::http_authorization line 60) had an error: Acme::Client::Error::RejectedIdentifier: Error creating new order :: Cannot issue for "gitlab.example.com": The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy
dpkg: error processing package gitlab-ce (--configure):
E: Sub-process /usr/bin/dpkg returned an error code (1)
Our lab used a private name, so the CA refused it outright. A public name with bad DNS fails at the same step with a different ACME message. Either way GitLab is installed, but dpkg leaves the package half-configured and the next apt run complains about it.
Fix the DNS record or open port 80 first, then watch for the trap we confirmed in the lab. Before the challenge runs, the install writes a self-signed placeholder certificate to /etc/gitlab/ssl/, and the failed run never records that Let’s Encrypt was turned on. A plain gitlab-ctl reconfigure then finds certificate files, switches Let’s Encrypt off and finishes green. Our server ended up serving the placeholder with no error at all.
The fix has three parts, and the order matters. Turn the integration on explicitly, delete the placeholder, then reconfigure before you let dpkg finish. Open the config file:
sudo vim /etc/gitlab/gitlab.rb
Add the line that turns Let’s Encrypt back on, anywhere in the file:
letsencrypt['enable'] = true
Then delete the placeholder, so the next run has no certificate files to find and requests a real one:
sudo rm "/etc/gitlab/ssl/${GITLAB_DOMAIN}.crt" "/etc/gitlab/ssl/${GITLAB_DOMAIN}.key"
Running dpkg --configure -a on its own doesn’t help. On our box it stopped and asked for a manual reconfigure first, so run the two in this order:
sudo gitlab-ctl reconfigure
sudo dpkg --configure -a
With the line in place, our reconfigure went back into the Let’s Encrypt recipe and ran its HTTP challenge. Repeat the openssl issuer check when it finishes. Once that line is set, a failed request makes the reconfigure exit with an error, as ours did when we forced one to fail. It still writes a fresh placeholder, though, so the issuer line is the most reliable sign that it worked. Then confirm that dpkg lists the package as ii (installed) rather than iF (failed config):
dpkg -l gitlab-ce | tail -1
Ours read:
ii gitlab-ce 19.4.2-ce.0 amd64 GitLab Community Edition (including NGINX, Postgres, Redis)
Using your own certificate instead
Servers on a private network or behind NAT can’t pass the HTTP challenge. For those, give GitLab a certificate you already have, from an internal CA or from a DNS-01 client like certbot. Our 24.04 server ran this way. Copy the certificate and key in before the install, named after the host, because that is where GitLab looks for them. Leave gitlab.rb for the install to write:
sudo mkdir -p /etc/gitlab/ssl
sudo chmod 755 /etc/gitlab/ssl
sudo cp fullchain.pem "/etc/gitlab/ssl/${GITLAB_DOMAIN}.crt"
sudo cp privkey.pem "/etc/gitlab/ssl/${GITLAB_DOMAIN}.key"
sudo chmod 600 "/etc/gitlab/ssl/${GITLAB_DOMAIN}.key"
Coming here after a failed install? Copy the two files anyway, since they replace the self-signed placeholder, then skip straight to the gitlab.rb edit below. Don’t run the install again.
Otherwise, run the install command above now. With both files in place, our 24.04 install used them and never contacted Let’s Encrypt. GitLab’s docs still ask you to turn the integration off explicitly when you bring your own certificate, so do that once the install finishes:
sudo vim /etc/gitlab/gitlab.rb
The installer has already written external_url near the top, so add only the line that turns Let’s Encrypt off:
letsencrypt['enable'] = false
Then run sudo gitlab-ctl reconfigure, plus sudo dpkg --configure -a after a failed install. That is how our 26.04 server got from the ACME failure to a finished install with all 15 services running.
A certificate you bring doesn’t renew inside GitLab. Certbot renews into /etc/letsencrypt/live, while GitLab keeps serving its copies in /etc/gitlab/ssl, so the site breaks when the old certificate expires. Copy the new files over after each renewal, from a certbot --deploy-hook for example, then run sudo gitlab-ctl hup nginx. A reconfigure doesn’t reload NGINX when only the certificate content changed, as GitLab’s SSL settings page points out.
Signing in as root
The installer writes a random root password to a file and keeps it there for 24 hours, so read it now:
sudo grep ^Password: /etc/gitlab/initial_root_password
Open https://gitlab.example.com (your own name, of course) and sign in as root with that password:

Change the password right away from the avatar menu, under Edit profile > Password. GitLab deletes the file on the first reconfigure after 24 hours. If it is already gone and you never saved the password, sudo gitlab-rake "gitlab:password:reset[root]" sets a new one.
After that first sign-in, GitLab 19 sends root to a Create your first project page. It asks for a group name and a project name and shows the URL the project will live at. We used platform and hello-gitlab:

Skip is fine too. The first push below clones platform/hello-gitlab, so if you skip, create a group and a blank project later from + > New project/repository > Create blank project.
Turning off open sign-ups
A new install lets anyone who can reach the sign-up page create an account. GitLab 19 holds those accounts for admin approval by default, which keeps strangers out but still leaves you a queue of requests to clear. On a private server there’s no reason to keep the page open, so switch registration off. Go to Admin > Settings > General, expand New user account restrictions, clear Allow new user accounts and select Save changes:

Confirm it from the shell. The command boots Rails, so expect about 30 seconds before it prints false:
sudo gitlab-rails runner 'puts Gitlab::CurrentSettings.signup_enabled'
With registration closed, you add users yourself from Admin > Instance overview > Users > New user. GitLab emails each new user a link to set a password, and that link only arrives once SMTP is set up in the optional section below. Until then, open the user, select Edit and set a password there.
Pushing your first commit over SSH
Do this part on your workstation, not on the server. Create a key if you don’t have one yet.
ssh-keygen -t ed25519 -C "[email protected]"
cat ~/.ssh/id_ed25519.pub
Paste the public key into GitLab under Edit profile > SSH Keys > Add new key. Your workstation doesn’t have the exports from the hostname section, so type your own hostname in place of gitlab.example.com here and in the clone command, then test the connection:
ssh -T [email protected]
GitLab answers with the account the key belongs to:
Welcome to GitLab, @root!
Permission denied (publickey) instead usually means GitLab doesn’t have this key yet, or ssh offered a different private key. Once the greeting comes back, clone the empty project, add a file and push. On a workstation that has never committed, set git config --global user.name and user.email first, as we did, or the commit stops with Author identity unknown. The git branch -M main line matters, as you’ll see:
git clone [email protected]:platform/hello-gitlab.git
cd hello-gitlab
echo "# hello-gitlab" > README.md
git add README.md
git commit -m "Add README"
git branch -M main
git push -u origin main
We first pushed without the rename and got this:
error: src refspec main does not match any
error: failed to push some refs to 'gitlab.example.com:platform/hello-gitlab.git'
Cloning an empty repository leaves Git on its own default branch name. On both Ubuntu releases that is still master, while GitLab expects main. We hit it with Git 2.43 on 24.04, and Git 2.53 on 26.04 keeps the same default. Rename the branch as above, or set git config --global init.defaultBranch main once and stop thinking about it. After the push, the project page shows the commit and the README:

That is the core setup done, with HTTPS, a closed sign-up page and a project you can push to over SSH. The email section below is optional, the RAM section matters if your server has 8 GB, and the backup and the upgrade stops apply to every install.
Sending email through SMTP (optional)
GitLab works without SMTP, but it can’t send mail. It hands each message to /usr/sbin/sendmail, which a stock Ubuntu server doesn’t have, so every one fails with Errno::ENOENT. That costs you password reset mails, notifications and sign-up confirmations. Nothing in the core setup depends on a relay, so you can add one now or later. Open the config file:
sudo vim /etc/gitlab/gitlab.rb
Add the settings for your provider. This example uses port 587 with STARTTLS:
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.example.com"
gitlab_rails['smtp_port'] = 587
gitlab_rails['smtp_user_name'] = "[email protected]"
gitlab_rails['smtp_password'] = "your-smtp-password"
gitlab_rails['smtp_domain'] = "example.com"
gitlab_rails['smtp_authentication'] = "login"
gitlab_rails['smtp_enable_starttls_auto'] = true
gitlab_rails['gitlab_email_from'] = "[email protected]"
Apply it, then send a test mail from the Rails console.
sudo gitlab-ctl reconfigure
sudo gitlab-rails runner "Notify.test_email('${ADMIN_EMAIL}', 'GitLab SMTP test', 'It works').deliver_now"
The test runs in the foreground, so an SMTP error prints straight into that command’s output. Real notification mail goes through Sidekiq later, and sudo gitlab-ctl tail sidekiq shows those errors.
How much RAM GitLab uses on an 8 GB server
GitLab’s low-memory settings took our 26.04 server from 4,325 to 2,575 MiB of GitLab memory at idle, and raised what free reports as available from 3,389 to 5,115 MiB. That difference separates an 8 GB box that only runs GitLab from one with room for a CI runner next to it. We measured proportional set size (PSS) per service, so shared pages count once, and took each reading after the server had sat idle for 8 to 15 minutes. “Defaults” is the server straight after install, and “Low-memory” is the same server with the settings below applied:
| Service (MiB) | Defaults | Low-memory |
|---|---|---|
| Puma | 1,945 | 915 |
| Sidekiq | 1,082 | 1,024 |
| PostgreSQL | 451 | 179 |
| Gitaly | 315 | 308 |
| Prometheus | 233 | off |
| Exporters and Alertmanager (5) | 145 | off |
| Workhorse, KAS, NGINX, Redis, logrotate | 154 | 149 |
| GitLab total (PSS) | 4,325 | 2,575 |
free used | 4,546 | 2,820 |
free available | 3,389 | 5,115 |
Most of the saving came from Puma. By default it starts a worker per vCPU, up to what the RAM allows, which on our 4 vCPU, 8 GB VMs meant a master and four workers, and the low-memory settings drop it to a single process. PostgreSQL shrank too, since fewer Puma processes hold fewer connections. Sidekiq barely moved, because lowering its concurrency to 10 caps how far it can grow under load, not what it uses at idle.
The memory comes back at a price, so weigh it before you change anything:
- Every web request goes through one Ruby process, so busy teams will feel it
- GitLab’s Puma docs still call single mode an experiment, not ready for production
- The bundled Prometheus, Alertmanager and exporters are switched off, so nothing collects or alerts on metrics out of the box
We’d use these settings only on a small internal instance, a handful of developers where a slow page now and then is acceptable, because that trade buys the headroom a CI runner needs on the same machine. Larger teams, and anyone who can’t tolerate an experimental mode, should keep the defaults and give GitLab the 16 GB it asks for.
These are the settings we appended to /etc/gitlab/gitlab.rb. They’re a subset of the settings in GitLab’s memory-constrained guide, and they turn every exporter off. KAS stays on, and Gitaly’s per-RPC concurrency limits stay at their defaults. The same guide asks for at least 1 GB of swap before you use these settings, so add a swap file first, as our swap file guide shows. Then open the file:
sudo vim /etc/gitlab/gitlab.rb
Add the block at the end of the file:
puma['worker_processes'] = 0
sidekiq['concurrency'] = 10
prometheus_monitoring['enable'] = false
alertmanager['enable'] = false
gitlab_exporter['enable'] = false
node_exporter['enable'] = false
postgres_exporter['enable'] = false
redis_exporter['enable'] = false
prometheus['enable'] = false
puma['exporter_enabled'] = false
sidekiq['metrics_enabled'] = false
gitlab_rails['env'] = {
'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000'
}
gitaly['env'] = {
'MALLOC_CONF' => 'dirty_decay_ms:1000,muzzy_decay_ms:1000',
'GITALY_COMMAND_SPAWN_MAX_PARALLEL' => '2'
}
Apply it with a reconfigure and a restart. The reconfigure took 1 minute 22 seconds on our server:
sudo gitlab-ctl reconfigure
sudo gitlab-ctl restart
GitLab’s general requirements page says to disable swap where possible, which contradicts the memory-constrained guide. Our lab VMs ran without swap and stayed up, but they had no users, so that proves little. With this block applied, follow the memory-constrained guide and keep the swap. GitLab’s Puma docs give the same advice for single mode, to keep out-of-memory kills away.
Which release, then? GitLab supports both, and on 26.04 the package installed with the same commands. We ran the install, the failed Let’s Encrypt request, the own-certificate recovery and the memory tests there, while the sign-in, the first push, backup and restore ran on 24.04 only. For a new server we’d pick 26.04, because it avoids a release upgrade under a running GitLab later. Stay on 24.04 if the rest of your fleet runs it.
Backing up and restoring GitLab
A complete GitLab backup is two archives, and the backup command only makes one of them. gitlab-backup create dumps the database, the repositories and the uploads into a single tar file:
sudo gitlab-backup create
On our small instance it took 24 seconds and wrote 860 KB to /var/opt/gitlab/backups/. The last lines carry the warning that people miss:
Warning: Your gitlab.rb and gitlab-secrets.json files contain sensitive data
and are not included in this backup. You will need these files to restore a backup.
Please back them up manually.
Backup 1791905630_2026_10_13_19.4.2 is done.
Without gitlab-secrets.json, a restore brings back the repositories but not CI variables, two-factor secrets or runner tokens. GitLab’s built-in helper archives the whole /etc/gitlab directory, which holds both files the warning names:
sudo gitlab-ctl backup-etc
The helper writes a tar to /etc/gitlab/config_backup/. Ours also contained initial_root_password, because the 24-hour cleanup hadn’t run yet, which is reason enough to keep the config archive somewhere access-controlled. Store both archives off the server, since a backup that lives on the failed disk is no backup.
To run both on a schedule, add them to root’s crontab. The CRON=1 variable hides progress output so cron only mails on errors:
sudo crontab -e
Add these two lines to run the backups at 02:00 and 02:30:
0 2 * * * /opt/gitlab/bin/gitlab-backup create CRON=1
30 2 * * * /opt/gitlab/bin/gitlab-ctl backup-etc --delete-old-backups
Nightly archives fill the disk unless something prunes them, so set a retention time in /etc/gitlab/gitlab.rb. GitLab counts it in seconds, and this value keeps seven days:
gitlab_rails['backup_keep_time'] = 604800
Run sudo gitlab-ctl reconfigure once it’s added. The --delete-old-backups flag on the config backup prunes against the same setting, so one value covers both archives.
We also tested a restore on the same server. Stop the two services that write to the database first, then restore by the backup ID, which is the file name without _gitlab_backup.tar. sudo ls /var/opt/gitlab/backups/ lists the files, so swap our ID for yours:
sudo gitlab-ctl stop puma
sudo gitlab-ctl stop sidekiq
sudo gitlab-backup restore BACKUP=1791905630_2026_10_13_19.4.2
sudo gitlab-ctl reconfigure
sudo gitlab-ctl restart
Our restore asked for confirmation twice and took 50 seconds. Afterwards, sudo gitlab-rake gitlab:check SANITIZE=true passed, sudo gitlab-rake gitlab:doctor:secrets reported 0 failures, and the project cloned with its commit intact.
A move to a new server follows GitLab’s migration guide, which adds what a same-server restore doesn’t need: draining Sidekiq, freezing the old server and moving DNS. Its restore guide and configuration backup page cover the archives. Three things decide whether a move works, and they are where moves go wrong:
- The exact GitLab version named at the end of the backup ID
gitlab-secrets.jsonand a reconfigure before the backup restore- The old server’s SSH host keys from
/etc/ssh
A backup only restores to exactly the same version and type, and on 26.04 that means 19.3 or later, so upgrade an older server before you move it. Without the secrets, CI variables and runner tokens don’t come back. Skip the host keys and every SSH client’s next fetch or push stops on a changed host key warning.
Upgrading GitLab without skipping a required stop
A plain sudo apt upgrade aims straight at the newest package. From too far behind, the package refuses to install: 19.4 stops anything older than 19.2 with It is required to upgrade to the latest 19.2.x version first and leaves the old version in place. A jump the check allows can still fail on background migrations that haven’t finished. GitLab sets required stops at minor versions .2, .5, .8 and .11 of each major, and an upgrade has to land on each stop in turn.
Someone on 18.10 heading for 19.4, for example, goes through 18.11, then 19.2, then 19.4. The resolute suite for 26.04 starts at 19.3, so a 26.04 server begins there and its next stop is 19.5, and the 19.2 listing below only applies to 24.04. The upgrade path page lists the stops for each major.
Every hop is the same routine, in this order:
- Take a backup. The package also dumps the database on upgrade, but that dump doesn’t include the repositories.
- Install the newest patch release of the stop.
- Wait for the background migrations to finish before the next hop.
Start by listing the patch releases apt can see for a stop:
apt-cache madison gitlab-ce | grep ' 19\.2\.'
On our 24.04 server the newest 19.2 patch was at the top, trimmed here to the first three lines:
gitlab-ce | 19.2.8-ce.0 | https://packages.gitlab.com/gitlab/gitlab-ce/ubuntu noble/main amd64 Packages
gitlab-ce | 19.2.7-ce.0 | https://packages.gitlab.com/gitlab/gitlab-ce/ubuntu noble/main amd64 Packages
gitlab-ce | 19.2.6-ce.0 | https://packages.gitlab.com/gitlab/gitlab-ce/ubuntu noble/main amd64 Packages
Install the newest patch of that stop, with 19.2.X replaced by the version the previous command printed, then list the background migrations:
sudo apt install -y gitlab-ce=19.2.X-ce.0
sudo gitlab-rake gitlab:background_migrations:list
Our fresh install listed no migrations at all. On a server with history, wait until every row in the status column reads finished or finalized before the next hop, and fix a row that fails instead of moving past it. Where list is missing on an older release, gitlab:background_migrations:status gives the same view. Our GitLab 17 to 18 upgrade guide walks through a full multi-stop run.
GitLab doesn’t run pipelines on its own, so the next step is to install a GitLab Runner and write a first .gitlab-ci.yml pipeline. If you’d rather run GitLab in containers, the Docker Compose setup is the alternative.