Why This Guide Exists
GitLab’s official installation docs are fine. They’ll get you from zero to a running instance. But they’re also scattered across dozens of pages, assume you already know which options to pick, and leave out the parts where things actually go wrong. I spent more time jumping between tabs and Stack Overflow threads than actually installing GitLab the first time around.
This guide is what I wish I had when I started. It’s a single, linear walkthrough based on real production experience, not a collection of reference pages. Every command here has been run on actual servers, and the troubleshooting section covers errors I’ve personally hit (and fixed) in production. If something can bite you, I’ve called it out.
Why Self-Host GitLab?
There are plenty of managed Git hosting options out there: GitHub, GitLab SaaS, Bitbucket Cloud. They’re convenient, well-maintained, and require zero infrastructure work. So why would anyone choose to run their own GitLab instance?
For us, it came down to three things: control, compliance, and cost. We needed full ownership of our source code and CI/CD data, the ability to configure authentication exactly how we wanted (SAML SSO with our identity provider), and at our scale, the self-hosted license was significantly cheaper than per-seat SaaS pricing.
Running a self-hosted GitLab instance isn’t trivial, but it’s far from rocket science either. This guide walks through every step of setting up GitLab Enterprise Edition on a fresh Ubuntu LTS server, from initial system prep all the way through SSL, backups, and SSO configuration.
Prerequisites
Before you start, make sure you have the following ready:
- A fresh Ubuntu server: Ubuntu 22.04 LTS or 24.04 LTS. Don’t install GitLab on a server that’s already running other services.
- Minimum hardware: 4 vCPUs, 8 GB RAM, 50 GB disk. For production use with 50+ users, I’d recommend 8 vCPUs, 16 GB RAM, and 100+ GB disk.
- A DNS A record pointing your desired domain (e.g.,
gitlab.example.com) to the server’s public IP. This needs to be set up before you start, since SSL certificate generation depends on it. - Root or sudo access on the server.
- Internet access: the server needs to download packages from GitLab’s repository and apt mirrors.
- (Optional) SMTP credentials if you want email notifications, and SAML IdP details if you’re setting up SSO.
Here’s a quick checklist to verify before proceeding:
# Verify Ubuntu version
cat /etc/os-release | grep VERSION_ID
# Check available resources
nproc # CPU cores
free -h # RAM
df -h / # Disk space
# Verify DNS resolution (from another machine)
dig gitlab.example.com +short
Step 1: System Preparation
Start by updating the system and installing the dependencies that GitLab requires.
sudo apt update && sudo apt upgrade -y
Install the required packages:
sudo apt install -y curl openssh-server ca-certificates tzdata perl postfix
When postfix prompts you during installation, select “Internet Site” and enter your server’s domain name. If you plan to use an external SMTP service (like SendGrid or AWS SES), you can skip postfix and configure SMTP directly in GitLab later.
Configure the Firewall
If UFW (Uncomplicated Firewall) is enabled, open the necessary ports:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
Port 80 is required for Certbot’s HTTP-01 challenge during SSL certificate issuance, even though GitLab will ultimately serve traffic on 443.
Step 2: Add the GitLab Repository
GitLab provides an official package repository. Add it with:
curl -fsSL https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh | sudo bash
This script adds GitLab’s apt repository and GPG key to your system. You can verify it was added correctly:
apt-cache policy gitlab-ee
You should see available versions listed from the packages.gitlab.com repository.
Step 3: Install GitLab EE
Now install GitLab. The EXTERNAL_URL environment variable tells GitLab what domain it should use:
sudo EXTERNAL_URL="https://gitlab.example.com" apt-get install -y gitlab-ee
This installs the latest version of GitLab EE, runs gitlab-ctl reconfigure automatically, and starts all services. The entire process takes 5-10 minutes depending on your server’s specs.
Installing a Specific Version
If you need a specific version (for example, to match an existing backup or stay on a tested release):
sudo EXTERNAL_URL="https://gitlab.example.com" apt-get install -y gitlab-ee=18.7.0-ee.0
The version format is MAJOR.MINOR.PATCH-EDITION.REVISION, so GitLab EE 18.7.0 becomes gitlab-ee=18.7.0-ee.0.
Skipping Auto-Reconfigure
If you want to install GitLab but configure it manually before the first reconfigure (useful when you need to set up SSL certs or modify gitlab.rb first):
sudo GITLAB_SKIP_RECONFIGURE=1 EXTERNAL_URL="https://gitlab.example.com" apt-get install -y gitlab-ee
This installs the package without running gitlab-ctl reconfigure. You’ll need to run it manually later after configuring everything.
Step 4: SSL/TLS with Certbot
GitLab has a built-in Let’s Encrypt integration, but I prefer using Certbot directly. It gives you more control over certificate management, works better when you’re behind a CDN like Cloudflare, and makes it easier to troubleshoot certificate issues.
Install Certbot
sudo apt install -y certbot
Obtain the Certificate
Before running Certbot, make sure GitLab’s nginx isn’t already running on port 80 (it will be if you didn’t use GITLAB_SKIP_RECONFIGURE). Stop it temporarily:
sudo gitlab-ctl stop nginx
Now obtain the certificate using standalone mode:
sudo certbot certonly --standalone -d gitlab.example.com
Certbot will:
- Start a temporary web server on port 80
- Verify domain ownership via HTTP-01 challenge
- Save the certificate and private key
The certs are saved to:
- Certificate:
/etc/letsencrypt/live/gitlab.example.com/fullchain.pem - Private key:
/etc/letsencrypt/live/gitlab.example.com/privkey.pem
Configure GitLab to Use Certbot Certs
Edit /etc/gitlab/gitlab.rb and add these lines:
# Disable GitLab's built-in Let's Encrypt
letsencrypt['enable'] = false
# Point to Certbot's certificates
nginx['ssl_certificate'] = "/etc/letsencrypt/live/gitlab.example.com/fullchain.pem"
nginx['ssl_certificate_key'] = "/etc/letsencrypt/live/gitlab.example.com/privkey.pem"
Set Up Auto-Renewal
Certbot’s auto-renewal needs to temporarily stop GitLab’s nginx to free up port 80. Create a renewal hook:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/pre
sudo mkdir -p /etc/letsencrypt/renewal-hooks/post
# Pre-hook: stop nginx before renewal
cat << 'EOF' | sudo tee /etc/letsencrypt/renewal-hooks/pre/stop-gitlab-nginx.sh
#!/bin/bash
gitlab-ctl stop nginx
EOF
# Post-hook: start nginx after renewal
cat << 'EOF' | sudo tee /etc/letsencrypt/renewal-hooks/post/start-gitlab-nginx.sh
#!/bin/bash
gitlab-ctl start nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/pre/stop-gitlab-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/post/start-gitlab-nginx.sh
Test the renewal process:
sudo certbot renew --dry-run
Certbot installs a systemd timer for auto-renewal by default. Verify it’s active:
sudo systemctl status certbot.timer
Verify the Certificate
sudo certbot certificates
You should see your domain, expiry date, and certificate paths listed.
Step 5: Core Configuration
GitLab’s entire configuration lives in a single file: /etc/gitlab/gitlab.rb. This is the file you’ll come back to for virtually every configuration change.
Essential Settings
Open the file:
sudo nano /etc/gitlab/gitlab.rb
Here are the key settings to configure:
# The URL users will use to access GitLab
external_url 'https://gitlab.example.com'
# Puma (application server) settings
# Adjust based on your server's CPU and RAM
puma['worker_processes'] = 4
puma['min_threads'] = 4
puma['max_threads'] = 4
# Workhorse (handles Git HTTP traffic and file uploads)
gitlab_workhorse['auth_backend'] = "http://localhost:8080"
SMTP Configuration (Optional)
If you want GitLab to send emails (notifications, password resets, etc.):
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.example.com"
gitlab_rails['smtp_port'] = 587
gitlab_rails['smtp_user_name'] = "gitlab@example.com"
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'] = 'gitlab@example.com'
gitlab_rails['gitlab_email_reply_to'] = 'noreply@example.com'
Monitoring Whitelist
GitLab bundles Prometheus and Grafana for monitoring. By default, the monitoring endpoints are only accessible locally. If you want to access them from specific IPs:
gitlab_rails['monitoring_whitelist'] = ['127.0.0.0/8', '10.0.0.0/8']
Apply the Configuration
After making changes, apply them:
sudo gitlab-ctl reconfigure
This command can take a few minutes. It configures all bundled services (PostgreSQL, Redis, Nginx, Puma, Sidekiq, Gitaly, etc.) based on your gitlab.rb settings. You’ll see a long output of Chef recipes being applied. That’s normal.
Step 6: Set Up a Backup Volume
This is one of the most important steps that often gets skipped. Without a proper backup strategy, a disk failure or cloud instance termination means losing everything: repositories, issues, CI/CD pipelines, and user data.
Attach and Format the Volume
If you have a separate block storage volume (recommended), attach it to your instance and format it:
# Identify the volume
lsblk
# Format it (only if it's a new, empty volume!)
sudo mkfs.ext4 /dev/sdb
Mount the Volume
Create the mount point and mount it:
sudo mkdir -p /mnt/gitlab-data
sudo mount /dev/sdb /mnt/gitlab-data
Add to fstab for Persistence
Get the volume’s UUID and add it to fstab so it mounts automatically on reboot:
# Get the UUID
sudo blkid /dev/sdb
# Add to fstab
echo 'UUID=<YOUR-VOLUME-UUID> /mnt/gitlab-data ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab
# Verify fstab is valid (this will catch syntax errors)
sudo mount -a
Create the Backup Directory Structure
Set up the directories that will mirror GitLab’s data layout:
sudo mkdir -p /mnt/gitlab-data/gitlab
sudo mkdir -p /mnt/gitlab-data/etc-gitlab
sudo mkdir -p /mnt/gitlab-data/var-log-gitlab
The mapping is:
gitlab/mirrors/var/opt/gitlab(repositories, database, uploads, LFS, etc.)etc-gitlab/mirrors/etc/gitlab(configuration, secrets, SSL certs)var-log-gitlab/mirrors/var/log/gitlab(service logs)
Set Up Automated Sync
Create a cron job that syncs GitLab’s data to the backup volume:
sudo crontab -e
Add these entries:
# Sync GitLab data to backup volume every 6 hours
0 */6 * * * rsync -a --delete /var/opt/gitlab/ /mnt/gitlab-data/gitlab/
0 */6 * * * rsync -a --delete /etc/gitlab/ /mnt/gitlab-data/etc-gitlab/
0 */6 * * * rsync -a --delete /var/log/gitlab/ /mnt/gitlab-data/var-log-gitlab/
You can adjust the frequency based on your RPO (Recovery Point Objective). For critical instances, consider running the sync hourly or even using volume-level snapshots provided by your cloud provider.
Verify the Backup
Run the sync manually and verify:
sudo rsync -a --delete /var/opt/gitlab/ /mnt/gitlab-data/gitlab/
sudo rsync -a --delete /etc/gitlab/ /mnt/gitlab-data/etc-gitlab/
sudo rsync -a --delete /var/log/gitlab/ /mnt/gitlab-data/var-log-gitlab/
# Check the structure
ls -la /mnt/gitlab-data/
ls -la /mnt/gitlab-data/gitlab/gitlab-rails/
cat /mnt/gitlab-data/gitlab/gitlab-rails/VERSION
Step 7: Configure SSO (Optional)
If your organization uses a centralized identity provider, configuring SSO eliminates the need for separate GitLab passwords and gives you centralized access control.
SAML with Microsoft Entra ID (Azure AD)
Add the following to /etc/gitlab/gitlab.rb:
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['saml']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_auto_link_saml_user'] = true
gitlab_rails['omniauth_providers'] = [
{
name: "saml",
label: "Microsoft Login",
args: {
assertion_consumer_service_url: "https://gitlab.example.com/users/auth/saml/callback",
idp_cert_fingerprint: "<YOUR_IDP_CERT_FINGERPRINT>",
idp_sso_target_url: "https://login.microsoftonline.com/<YOUR_TENANT_ID>/saml2",
issuer: "https://gitlab.example.com",
name_identifier_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
attribute_statements: {
first_name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'],
last_name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'],
email: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
}
}
}
]
On the Entra ID side, you’ll need to:
- Register a new Enterprise Application
- Set the Reply URL to
https://gitlab.example.com/users/auth/saml/callback - Configure the required claims (email, first name, last name)
- Copy the IdP certificate fingerprint and SSO URL into the config above
OAuth with Bitbucket (Optional)
If you also want to allow Bitbucket login:
gitlab_rails['omniauth_providers'] << {
name: "bitbucket",
app_id: "<YOUR_BITBUCKET_APP_ID>",
app_secret: "<YOUR_BITBUCKET_APP_SECRET>",
url: "https://bitbucket.org/"
}
After adding SSO configuration, apply the changes:
sudo gitlab-ctl reconfigure
Testing SSO
- Open your GitLab URL in an incognito browser window
- You should see a “Microsoft Login” button on the sign-in page
- Click it and authenticate with your Microsoft account
- On first login, GitLab auto-creates an account linked to your SSO identity
Common gotchas:
- Reply URL mismatch: the URL in Entra ID must exactly match
assertion_consumer_service_urlin gitlab.rb - Clock skew: if the server’s time is off by more than a few minutes, SAML validation will fail. Use NTP:
sudo timedatectl set-ntp true - Certificate rotation: when your IdP rotates its signing certificate, you’ll need to update the fingerprint in gitlab.rb
Step 8: Post-Install Verification
Before calling it done, run through these checks to make sure everything is working correctly.
Check Service Status
sudo gitlab-ctl status
You should see all services running. A healthy GitLab instance typically has 15-17 services:
run: alertmanager: (pid 1234) 100s
run: gitaly: (pid 1235) 100s
run: gitlab-exporter: (pid 1236) 100s
run: gitlab-kas: (pid 1237) 100s
run: gitlab-workhorse: (pid 1238) 100s
run: logrotate: (pid 1239) 100s
run: nginx: (pid 1240) 100s
run: node-exporter: (pid 1241) 100s
run: postgres-exporter: (pid 1242) 100s
run: postgresql: (pid 1243) 100s
run: prometheus: (pid 1244) 100s
run: puma: (pid 1245) 100s
run: redis: (pid 1246) 100s
run: redis-exporter: (pid 1247) 100s
run: registry: (pid 1248) 100s
run: sidekiq: (pid 1249) 100s
If any service shows down, check its logs:
sudo gitlab-ctl tail <service-name>
Run GitLab’s Built-in Health Check
sudo gitlab-rake gitlab:check SANITIZE=true
This runs a comprehensive check of GitLab’s configuration, database, Git repositories, and more. Every line should show ... yes or informational output. Fix any items marked with a warning or error.
Access the Web UI
Open https://gitlab.example.com in your browser. On the first visit after installation, you’ll need to set the root password. The initial auto-generated password is available at:
sudo cat /etc/gitlab/initial_root_password
Important: This file is automatically deleted after 24 hours. Save the password or set a new one immediately through the web UI at Admin Area > Users > root > Edit.
Test Email (If Configured)
sudo gitlab-rails console
In the Rails console:
Notify.test_email('your-email@example.com', 'Test Subject', 'Test Body').deliver_now
Check your inbox for the test email.
Troubleshooting
gitlab-ctl reconfigure Fails
The most common causes:
- Port conflicts: another service is already using port 80 or 443. Check with
sudo lsof -i :80andsudo lsof -i :443. - Insufficient memory: GitLab needs at least 4 GB of free RAM for reconfigure. Check with
free -h. - Disk full: reconfigure writes temporary files. Verify with
df -h.
Certbot ACME Failures
If Certbot can’t obtain a certificate:
- Port 80 blocked: verify
sudo ufw statusshows port 80 open - Behind Cloudflare proxy: if Cloudflare is proxying traffic, the HTTP-01 challenge may fail. Either temporarily disable Cloudflare proxy (grey cloud the DNS record), or use the DNS-01 challenge instead:
sudo apt install -y python3-certbot-dns-cloudflare
# Create /etc/letsencrypt/cloudflare.ini with your API token
sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini -d gitlab.example.com
- DNS not propagated: verify with
dig gitlab.example.comthat it resolves to your server’s IP
Services Not Starting
Check the service-specific logs:
# General log directory
ls /var/log/gitlab/
# Specific service logs
sudo gitlab-ctl tail puma
sudo gitlab-ctl tail postgresql
sudo gitlab-ctl tail nginx
Common issues:
- PostgreSQL won’t start: usually a data directory permission issue. Check
ls -la /var/opt/gitlab/postgresql/data/ - Redis won’t start: stale PID file. Remove it:
sudo rm -f /var/opt/gitlab/redis/redis.pid
502 Bad Gateway
This almost always means Puma (the application server) hasn’t finished starting yet. GitLab can take 2-5 minutes to fully boot, especially on servers with less RAM. Wait and refresh.
If it persists:
- Check Puma logs:
sudo gitlab-ctl tail puma - Verify RAM:
free -h. If swap is being used heavily, you need more RAM - Check if Puma workers are running:
ps aux | grep puma
Permission Errors
If you see permission denied errors in logs:
sudo gitlab-ctl reconfigure
Reconfigure resets all file permissions to their expected values. If that doesn’t help:
sudo chown -R git:git /var/opt/gitlab/git-data
sudo chown -R git:git /var/opt/gitlab/gitlab-rails
sudo chmod 2770 /var/opt/gitlab/git-data/repositories
Best Practices
- Keep
gitlab.rbin version control, but excludegitlab-secrets.json. The secrets file contains encryption keys for CI/CD variables, 2FA tokens, and runner authentication tokens. If you lose it, that encrypted data is gone forever. - Set up the backup volume from day one. Don’t wait until you have “enough data to worry about.” By then it’s too late.
- Test your restore process. A backup that’s never been tested is just a hope. Spin up a test instance and restore from your backup volume at least quarterly.
- Use GitLab’s bundled Prometheus and Grafana. They’re pre-configured to monitor all GitLab services. Access Grafana at
https://gitlab.example.com/-/grafana. - Plan your upgrade path. GitLab requires sequential version upgrades. You can’t jump from 16.x to 18.x directly. Check the upgrade path tool before upgrading.
- Keep the OS updated. Regular
apt update && apt upgradefor security patches. GitLab’s packages won’t be affected by OS updates. - Pin your GitLab version to prevent accidental upgrades during
apt upgrade. Hold the package:
sudo apt-mark hold gitlab-ee
When you’re ready to upgrade intentionally:
sudo apt-mark unhold gitlab-ee
sudo apt install gitlab-ee=<NEW_VERSION>
sudo apt-mark hold gitlab-ee
Key Takeaways
- Self-hosting GitLab gives you full control over your source code, authentication, and infrastructure, but it comes with operational responsibility.
- Use Certbot for SSL certificates. It’s more flexible and easier to debug than GitLab’s built-in Let’s Encrypt.
- The backup volume is your lifeline. Set it up on day one, automate the sync, and test restores regularly.
gitlab-secrets.jsonis the single most critical file in your GitLab installation. Lose it, and all encrypted data (CI/CD variables, 2FA keys, runner tokens) becomes unrecoverable.- GitLab’s
gitlab-ctl reconfigureis your swiss army knife. It applies configuration changes, fixes permissions, and restarts services. - Start with the basics (install, SSL, backups) and layer on complexity (SSO, monitoring, CI runners) incrementally.
In the next post, I cover disaster recovery: restoring GitLab from a backup volume when things go sideways. If you’re setting up a production instance, I’d strongly recommend reading that one too, because a backup you’ve never tested is just a hope.
I’ve also open-sourced the restore and prerequisites check scripts on GitHub at gitlab-scripts. Feel free to use them, fork them, or adapt them to your setup.