Tutorial

Install GitLab CE on a VPS: complete guide

Deployment18 min read9 steps

GitHub and Bitbucket host your repositories, but they set the rules: metered CI minutes, uncertain code ownership, pricing that rises with team size. GitLab CE (Community Edition, MIT/EE Core licence) brings together on a single server a complete git forge, a CI/CD engine with no imposed minute quotas, a private Docker registry and wikis — all without a SaaS subscription. This guide shows you how to install it in under thirty minutes on a VPS with Docker Compose, configure SMTP, connect a runner and avoid the classic pitfalls: a misconfigured GITLAB_OMNIBUS_CONFIG, SSL that expires for lack of ACME, an unregistered runner.

Contents· Why GitLab CE instead of GitHub or Bitbucket1/13
  1. 01Why GitLab CE instead of GitHub or Bitbucket
  2. 02What GitLab CE includes out of the box
  3. 03Prerequisites: what you need before you start
  4. 04From installation to your first project
  5. 05Configure the Docker runner and write your first CI/CD pipeline
  6. 06Configuring SMTP notifications in detail
  7. 07Backing up and restoring GitLab
  8. 08Troubleshooting: the three most common failures
  9. 09Advanced troubleshooting: stuck Sidekiq, Puma timeout and OOM on Gitaly
  10. 10Security: CVE-2026-90970 and critical update
  11. 11RAM optimisation: swap and Puma workers
  12. 12GitLab CE, Forgejo or Gitea: how to choose
  13. 13When to choose GitLab CE, when to choose Forgejo

Why GitLab CE instead of GitHub or Bitbucket

GitHub and Bitbucket are managed services: convenient to start with, but their pricing evolves with team size, CI minutes are capped on free plans, and your code lives on third-party infrastructure. GitLab CE reverses this equation: you deploy the forge on your VPS, you retain full control of the code and data, and CI/CD is included without minute quotas imposed by a third party — only your machine's capacity limits the number of parallel jobs. For an agency or a development team that bills clients or handles sensitive data, this control is often non-negotiable. GitLab CE is today one of the most widely deployed self-hosted git forges: its codebase is public, its core is free, and ten years of existence have earned it an active contributor community.

What GitLab CE includes out of the box

  • Complete git forge: private and public repositories, merge requests, code review, branch protection and CODEOWNERS.
  • Integrated CI/CD with no imposed minute limit: YAML pipelines (.gitlab-ci.yml), environments, deploy tokens and artifacts.
  • Private Docker registry hosted on your domain: docker pull git.yourdomain.com/group/image:tag without a Docker Hub account.
  • Wikis and pages per project and per group, with full Markdown rendering.
  • Issue tracking and milestones: kanban board, labels, iterations and burndown charts included.
  • Parallel runners: register as many runners as you want on your infrastructure or CI workers.
  • Webhooks to notify a third-party tool (Slack, PagerDuty, your deployment server) on every push or merge.
  • Granular RBAC with five access levels (Guest, Reporter, Developer, Maintainer, Owner) and optional LDAP/SAML support.

Prerequisites: what you need before you start

GitLab CE is memory-intensive — it is one of its most cited drawbacks compared to Forgejo or Gitea. Plan for 4 GB of RAM minimum for light usage (fewer than ten active users); 8 GB are recommended as soon as you add runners on the same machine or enable the Docker registry. Below 4 GB, Puma and Sidekiq compete for memory and GitLab responds with 502 under load. For storage, allow at least 20 GB for the installation and initial repositories — plan for 50 GB if you intend to store Docker images in the registry. You also need: a domain name pointing to the VPS IP (for example git.yourdomain.com) for Let's Encrypt to issue a TLS certificate; ports 80, 443 and 22 open in your firewall (port 22 is used by GitLab for SSH pushes — if your system SSH daemon also listens on 22, move it to another port); Docker Engine and the Compose v2 plugin installed (docker compose version should return v2.x).

From installation to your first project

  1. Create the directory structure

    Create a dedicated folder and the three persistent volumes GitLab uses: mkdir -p /opt/gitlab/{config,logs,data}. These directories will be mounted into the container; without them, configuration and repositories vanish on every docker compose down.

  2. Write the docker-compose.yml file

    Create /opt/gitlab/docker-compose.yml. The GITLAB_OMNIBUS_CONFIG key concentrates all instance-specific configuration — replace git.yourdomain.com with your actual domain. Define the gitlab service with image gitlab/gitlab-ce:17.2.1-ce.0, restart: unless-stopped, the hostname, environment variables (including GITLAB_OMNIBUS_CONFIG containing external_url, Let's Encrypt settings and SMTP parameters), ports 80, 443 and 22, volumes for /etc/gitlab, /var/log/gitlab and /var/opt/gitlab, shm_size: 256m and env_file: .env. Important note: the external_url value determines whether GitLab generates http:// or https:// URLs, and whether Let's Encrypt is attempted. It must exactly match a domain resolvable from the Internet — an incorrect value is the leading cause of access errors.

  3. Set GITLAB_OMNIBUS_CONFIG

    Inside GITLAB_OMNIBUS_CONFIG, define at minimum: external_url 'https://git.yourdomain.com'; letsencrypt['enable'] = true with letsencrypt['contact_emails'] = ['[email protected]']; SMTP parameters — gitlab_rails['smtp_enable'] = true, address, port 587, user name, gitlab_rails['smtp_password'] = ENV['GITLAB_SMTP_PASSWORD'], authentication login, smtp_enable_starttls_auto = true, gitlab_email_from; and if you enable the registry: registry_external_url 'https://registry.yourdomain.com'. All these lines must be inside the GITLAB_OMNIBUS_CONFIG block, not as separate environment variables — a key outside the block is silently ignored.

  4. Create the .env file for secrets

    Create /opt/gitlab/.env and restrict its permissions: touch /opt/gitlab/.env && chmod 600 /opt/gitlab/.env. Add your sensitive variables — at minimum GITLAB_SMTP_PASSWORD=your_smtp_password. Never put a password in plain text in the Compose file: it will end up versioned or shared. Add .env to your .gitignore if you version the configuration.

  5. Start GitLab and wait for initialisation

    Launch the stack: docker compose -f /opt/gitlab/docker-compose.yml up -d. The first start takes time: GitLab initialises the database, compiles assets and configures Nginx and Puma. Wait about 5 minutes before accessing the web interface. Follow progress with docker compose -f /opt/gitlab/docker-compose.yml logs -f gitlab and wait for the gitlab Reconfigured! message.

  6. Retrieve the initial root password

    On first initialisation, GitLab generates a temporary password for the root account. Retrieve it with: docker exec -it gitlab-gitlab-1 grep 'Password:' /etc/gitlab/initial_root_password. This file is automatically deleted 24 hours after the first start — note the password immediately.

  7. Log in, change the password and disable open registration

    Open https://git.yourdomain.com in your browser. Log in with root and the password retrieved above. Go to User Settings → Password and set a new strong password. Create your first non-root user: Admin Area → Users → New User. For internal use, disable public sign-up: Admin Area → Settings → General → Sign-up restrictions → uncheck 'Sign-up enabled'.

  8. Register a GitLab Runner

    CI/CD pipelines require at least one runner. Install gitlab-runner on the VPS or a dedicated machine (download the binary from the official GitLab Runner page). Retrieve the registration token from Admin Area → Runners (shared) or from your project's Settings → CI/CD → Runners (project runner). Register: gitlab-runner register --url https://git.yourdomain.com --registration-token YOUR_TOKEN --executor docker --docker-image alpine:latest. Once registered, the runner appears green in the interface and your .gitlab-ci.yml pipelines can execute.

  9. Verify email delivery

    Test SMTP from the GitLab Rails console: docker exec -it gitlab-gitlab-1 gitlab-rails console then type Notify.test_email('[email protected]', 'Test GitLab', 'Hello').deliver_now. If the email does not arrive, check that smtp_* keys are inside GITLAB_OMNIBUS_CONFIG and review the logs: docker exec -it gitlab-gitlab-1 tail -f /var/log/gitlab/gitlab-rails/production.log.

Automatic backups. GitLab ships a full backup command (repositories, database, uploads): docker exec -t gitlab-gitlab-1 gitlab-backup create. Schedule it in crontab -e with 0 3 * * * docker exec -t gitlab-gitlab-1 gitlab-backup create CRON=1. Archives are created in /var/opt/gitlab/backups (mounted at /opt/gitlab/data/backups on the host) and timestamped. Sync this folder to external storage (S3, rclone) — a local backup is not a backup.

Configure the Docker runner and write your first CI/CD pipeline

The Docker executor is the recommended choice for most projects: each job runs in an isolated container, without polluting the runner environment. After registering the runner (step 8 above), its configuration file /etc/gitlab-runner/config.toml contains a [runners.docker] block. Add volumes = ["/cache"] to retain cache between builds, and pull_policy = ["if-not-present"] to avoid pulling the image on every job if it is already present locally — this noticeably reduces the duration of first stages.

A basic pipeline for a Node.js or PHP project fits in about twenty lines in .gitlab-ci.yml at the root of the repository:

stages:
  - test
  - build
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

test:unit:
  stage: test
  image: node:20-alpine
  cache:
    key: $CI_COMMIT_REF_SLUG
    paths:
      - node_modules/
  script:
    - npm ci
    - npm test
  only:
    - merge_requests
    - main

build:image:
  stage: build
  image: docker:26
  services:
    - docker:26-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE
  only:
    - main

deploy:prod:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
  script:
    - ssh -o StrictHostKeyChecking=no [email protected] "docker pull $DOCKER_IMAGE && docker compose up -d"
  only:
    - main
  when: manual

The variables CI_REGISTRY_USER, CI_REGISTRY_PASSWORD and CI_REGISTRY are injected automatically by GitLab CE for the built-in registry — no manual configuration needed. The SSH_PRIVATE_KEY variable must be defined in Settings → CI/CD → Variables of the project, marked Masked so it does not appear in logs. The deploy:prod job is set to when: manual: it does not trigger automatically but waits for a click in the interface or via the API — a minimal safety gate before writing to production.

To check that the runner picked up the job, go to CI/CD → Jobs: each job shows its assigned runner, duration and exit code. A job stuck on 'pending' indicates the runner has no available slot (check concurrent in /etc/gitlab-runner/config.toml, default 1) or is offline.

Configuring SMTP notifications in detail

GitLab sends emails on every merge request, mention, finished pipeline or security alert. A missing or incorrect SMTP configuration silently cuts these notifications — users only notice after an incident.

The full block to place in GITLAB_OMNIBUS_CONFIG for an authenticated SMTP relay (example with a STARTTLS-compatible server on port 587):

gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = 'smtp.yourdomain.com'
gitlab_rails['smtp_port'] = 587
gitlab_rails['smtp_user_name'] = '[email protected]'
gitlab_rails['smtp_password'] = ENV['GITLAB_SMTP_PASSWORD']
gitlab_rails['smtp_authentication'] = 'login'
gitlab_rails['smtp_enable_starttls_auto'] = true
gitlab_rails['gitlab_email_from'] = '[email protected]'
gitlab_rails['gitlab_email_reply_to'] = '[email protected]'

If your relay requires direct TLS (port 465), replace smtp_enable_starttls_auto = true with smtp_tls = true and adjust the port. For relays that require no authentication (internal server, local Postfix), set smtp_authentication = false and remove smtp_user_name and smtp_password.

Main pitfalls: a key defined as a Docker environment variable rather than inside the Omnibus block is silently ignored; a YAML indentation error in docker-compose.yml can cut the block mid-way; always test with Notify.test_email(…).deliver_now from the Rails console after each change.

Backing up and restoring GitLab

GitLab CE ships a full backup command that archives in a single tar file the git repositories, the PostgreSQL database, uploads, CI artifacts and configuration keys. The reference command:

docker exec -t gitlab-gitlab-1 gitlab-backup create

The archive is created in /var/opt/gitlab/backups/ inside the container, i.e. /opt/gitlab/data/backups/ on the host if you mounted the volume as shown. It is named <timestamp>_<version>_gitlab_backup.tar.

Important: gitlab-backup create does not include two critical files — /etc/gitlab/gitlab.rb (the Omnibus configuration) and /etc/gitlab/gitlab-secrets.json (the encryption keys). Without these files a restore fails or decrypts tokens incorrectly. Back them up separately:

tar czf /opt/gitlab/config-secrets-$(date +%Y%m%d).tar.gz \
  /opt/gitlab/config/gitlab.rb \
  /opt/gitlab/config/gitlab-secrets.json

Restoring from a backup requires three steps in order: install GitLab CE of the same minor version that produced the backup; copy the tar archive to /opt/gitlab/data/backups/ and restore gitlab.rb and gitlab-secrets.json; then stop write services and run the restore:

docker exec -it gitlab-gitlab-1 gitlab-ctl stop puma
docker exec -it gitlab-gitlab-1 gitlab-ctl stop sidekiq
docker exec -it gitlab-gitlab-1 gitlab-backup restore BACKUP=<timestamp>_<version>
docker exec -it gitlab-gitlab-1 gitlab-ctl restart
docker exec -it gitlab-gitlab-1 gitlab-rake gitlab:check SANITIZE=true

The gitlab:check command at the end verifies installation integrity and flags corrupted repositories or incorrect permissions. Schedule the full backup at 3 am and sync to external storage (S3 via rclone, remote NAS) — a local backup that burns with the VPS is not a backup.

Troubleshooting: the three most common failures

502 Bad Gateway at startup. GitLab takes about 5 minutes to become fully operational. If the 502 persists, check that Puma has started: docker exec gitlab-gitlab-1 gitlab-ctl status puma. Insufficient RAM is the most frequent cause — ensure you have at least 4 GB available. SMTP not working. Check first: is the gitlab_rails['smtp_*'] block inside GITLAB_OMNIBUS_CONFIG, not defined as a separate environment variable? Incorrect indentation in the YAML or a key outside the omnibus block is silent — GitLab starts normally but ignores the SMTP configuration. Test from the Rails console (step 9). Runner not connected. If the runner shows grey or offline, check it can reach your instance: gitlab-runner verify --url https://git.yourdomain.com. A self-signed TLS certificate or a domain not resolvable from the runner machine are the usual causes. Also check that the token used at registration is for the correct level (instance, group or project).

Advanced troubleshooting: stuck Sidekiq, Puma timeout and OOM on Gitaly

Sidekiq queue stuck — jobs blocked indefinitely. Sidekiq processes GitLab's asynchronous jobs (emails, imports, webhooks, indexing). If jobs accumulate without being consumed, check the worker state first:

docker exec gitlab-gitlab-1 gitlab-ctl status sidekiq
docker exec gitlab-gitlab-1 gitlab-rake gitlab:sidekiq:check

A stuck queue often comes from a job that raised an uncaught exception and went into infinite retry. To clear a specific queue without restarting GitLab: docker exec -it gitlab-gitlab-1 gitlab-rails console -e production then Sidekiq::Queue.new('mailers').clear. If Sidekiq consumes more than 1 GB of RAM, docker exec gitlab-gitlab-1 gitlab-ctl restart sidekiq re-initialises it without losing pending jobs (they remain in Redis).

Puma timeout — intermittent 503 under load. Puma timeouts (slow database calls, poorly optimised Rails requests) appear in /var/log/gitlab/puma/puma_stderr.log. The first action is to raise the timeout if the VPS is slow but functional: in GITLAB_OMNIBUS_CONFIG, add puma['worker_timeout'] = 90 (default 60 s). If timeouts persist, the cause is almost always insufficient RAM or an ill-adapted worker count — see the RAM optimisation section.

OOM on Gitaly — repositories inaccessible after a kill. Gitaly handles git operations (clone, fetch, push). If the kernel kills it by OOM (check with dmesg | grep -i kill), all git access fails until the service restarts. Monitor with docker exec gitlab-gitlab-1 gitlab-ctl status gitaly and, on RAM-constrained VPS, limit automatic maintenance: gitaly['configuration']['git']['catfile_cache_size'] = 5 (default 100) reduces cached git objects. For repeated OOM, the durable solution is more RAM or adding swap.

Container exited at startup. If the container exits immediately after docker compose up, check pre-exit logs: docker compose logs gitlab | tail -50. The most frequent causes are a port 22 conflict (system SSH daemon active), a volume not writable (chown -R 998:998 /opt/gitlab/data) or a GITLAB_OMNIBUS_CONFIG with invalid Ruby syntax (missing comma, unclosed quote).

Security: CVE-2026-90970 and critical update

On October 2, 2026, GitLab disclosed CVE-2026-90970, a critical vulnerability (CVSS 9.9) affecting the AI Gateway component of self-hosted deployments. The flaw is a server-side template injection (SSTI) in custom flow prompt templates: an authenticated user with Duo Agent Platform access can submit a crafted flow configuration to escape the sandbox and execute arbitrary commands on the underlying host.

What is affected. Only self-hosted instances that deploy the AI Gateway component. GitLab.com and Dedicated instances were patched automatically. Exploitation requires Duo Agent Platform access and no in-the-wild exploitation was reported at the time of disclosure.

Vulnerable and fixed versions.

| Branch | Last vulnerable version | Fixed version |
|---|---|---|
| 19.4.x | 19.4.0 | 19.4.1 |
| 19.3.x | 19.3.1 | 19.3.2 |
| 18.1.6 – 19.2.x | 19.2.3 | 19.2.4 |

Updating the AI Gateway on Docker.

# Stop and remove the existing container
docker stop gitlab-ai-gateway && docker rm gitlab-ai-gateway

# Pull the patched image (replace the tag with your branch)
docker pull registry.gitlab.com/gitlab-org/modelops/apigw/self-hosted-v19.4.1-ee
# or: self-hosted-v19.3.2-ee / self-hosted-v19.2.4-ee

# Restart with the same environment variables
docker run -d --name gitlab-ai-gateway \
  --restart unless-stopped \
  [your usual options] \
  registry.gitlab.com/gitlab-org/modelops/apigw/self-hosted-v19.4.1-ee

# Verify the deployed version
docker ps --format '{{.Image}}' | grep ai-gateway

Updating via Helm / Kubernetes. Update image.tag in your Helm values to the patched version, set imagePullPolicy: Always, and trigger a helm upgrade.

After updating, confirm the container is running the patched image: docker inspect <container> --format '{{.Config.Image}}'.

Disable the AI Gateway if you don't use it. If your GitLab CE instance does not use Duo features (AI code completion, chat), the AI Gateway can simply be stopped and removed from your docker-compose.yml. A component that is not deployed cannot be exploited. Verify that no pipelines depend on the Gateway API before removing it.

RAM optimisation: swap and Puma workers

GitLab CE in its default configuration allocates several Puma workers and as many Sidekiq threads as CPU cores detected. On a 4 GB VPS with 2 vCPU, this can exceed available memory as soon as a heavy pipeline runs in parallel. Two immediate levers.

Reduce Puma workers. In GITLAB_OMNIBUS_CONFIG:

puma['worker_processes'] = 2
puma['min_threads'] = 1
puma['max_threads'] = 4

Each Puma worker consumes about 200 to 300 MB. Two workers are sufficient for fewer than fifteen simultaneous active users. A single worker (worker_processes = 0) is possible but removes worker fault tolerance.

Add swap. GitLab recommends at least as much swap as RAM. On a VPS without preconfigured swap:

fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

Swap does not replace RAM — it prevents OOM crashes during momentary load spikes — but it slows GitLab down if the system relies on it permanently. If free -h shows regular swap usage at idle, the VPS is undersized.

RAM and CPU recommendations by team size

| Team size | Recommended RAM | vCPU | Notes |
|---|---|---|---|
| 1–5 developers | 4 GB | 2 | Runners on the same machine possible with swap |
| 5–15 developers | 8 GB | 4 | Dedicated runner advised beyond 8+ pipelines/day |
| 15–30 developers | 16 GB | 8 | Active Docker registry, Gitaly under load |
| 30+ developers | 32 GB+ | 16+ | Separate Gitaly and Sidekiq on dedicated machines |

GitLab CE, Forgejo or Gitea: how to choose

Scroll the table

CriterionGitLab CEForgejo / Gitea
LicenceMIT / EE CoreMIT
Idle RAM300–600 MB (Puma + Sidekiq)30–50 MB
Native CI/CDYes (`.gitlab-ci.yml`, runners)Forgejo: yes via Woodpecker CI; Gitea: no native CI
Integrated Docker registryYesForgejo: yes; Gitea: not native
Per-project wikisYesYes
LDAP / SAMLYes (CE)Forgejo: yes; Gitea: LDAP yes, SAML no
Learning curveHigh (feature-rich interface)Low to medium
Best forTeams of 5+ needing CI, registry and RBACLightweight teams, simple forge, low RAM footprint

When to choose GitLab CE, when to choose Forgejo

GitLab CE is the right choice if your team needs robust CI/CD directly integrated into the forge, a private Docker registry on your domain, deployment pipelines with environments and deploy tokens, or project management with issues, milestones and kanban without a third-party tool. Its memory footprint (4–8 GB) is the price of this functional richness. Forgejo or Gitea are the answer when RAM is the main constraint, when the forge is the only requirement (no integrated CI) and you run CI with an external tool (Woodpecker, Drone, GitHub Actions via a runner). On a 2 GB VPS, GitLab CE is not viable; Forgejo runs on 256 MB. On a VPS of 8 GB dedicated to a development team, GitLab CE provides a complete environment that GitHub puts on its SaaS platform — under your control and without a monthly per-user subscription.

A VPS ready for GitLab CE

GitLab CE needs a VPS with 4 to 8 GB of RAM, generous disk space and a dedicated IP. Our ServOrbit VPS are delivered with your choice of Ubuntu 24.04 LTS, Ubuntu 22.04 LTS, Debian 12 or AlmaLinux 9 and immediate root access — enough to have your forge online in thirty minutes.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab