VPS Tutorials
Deploy GitLab CE on a VPS (2026): Complete Tutorial for Docker Compose, HTTPS, SSH, Backups and Runner
Deploy GitLab CE on a VPS with Docker Compose, set up domain HTTPS, SSH ports, first login, low-spec optimization, backup and upgrade, and GitLab Runner configuration, and troubleshoot common startup and clone issues.

Deploy GitLab CE on a VPS (2026): Complete Tutorial for Docker Compose, HTTPS, SSH, Backups and Runner
If you want to set up GitLab on a VPS, don't rush to copy a single docker run. GitLab packs code repositories, Merge Requests, Issues, Container Registry and CI/CD into one system, and the price is that it consumes more resources than Gitea and depends more on correct domains, ports and backups.
This article uses the official GitLab CE 19.2.1 image, deployed via Docker Compose. The host SSH continues to use 22, while GitLab's SSH cloning moves to 2222; the web side is served directly over HTTPS by GitLab's built-in Nginx and Let's Encrypt. The Runner goes on another VPS, so build jobs don't drag down the code repository.
GitLab's official baseline for an ordinary single node is 8 vCPU and 16GB of memory; the memory-constrained option can go as low as 2GB of memory plus 1GB of Swap, but it is limited to about 5 developers and repositories under 100MB, and it may be noticeably slower. In practice, you can look at it like this:
| Purpose | Suggested specs | Notes |
|---|---|---|
| Temporary trial, small personal repositories | 2–4 vCPU, 4GB memory, 40GB SSD | Requires low-memory tuning; not recommended to run the Runner at the same time |
| Daily use by a small team | 4 vCPU, 8GB memory, 80GB NVMe | Better suited as a starting configuration |
| Heavy CI, Registry, many artifacts | 8 vCPU, 16GB or more | Runner and object storage are best separated |
Random read/write performance of SSD/NVMe matters more than simply piling on CPU. If you haven't settled on a configuration yet, you can first read the VPS configuration selection guide. If you only need a lightweight private Git repository and don't need a full DevOps platform, the Gitea setup tutorial is usually more cost-effective.
Don't calculate disk space based only on repository source code either. The database, LFS, Packages, Container Registry, CI logs and Artifacts all grow, and backups also need extra temporary space. Before going live, list the expected number of users, total repository size, per-build artifacts and retention days, then leave at least one full backup's worth of space on disk. A full disk often brings GitLab down sooner than insufficient CPU.
First point the A/AAAA records of gitlab.example.com to the VPS public address. Replace the domain and email below with your own; during Let's Encrypt validation, 80 and 443 must be reachable from the public internet.
Check port usage:
sudo ss -lntup | grep -E ':22|:80|:443|:2222'
22 should still be used by the host SSH, while 80, 443 and 2222 are left for the GitLab container. Create the persistent directories:
sudo mkdir -p /srv/gitlab/{config,logs,data}
sudo mkdir -p /srv/gitlab-backups
Before configuring UFW, keep your current SSH session open and test from a new terminal, so you don't lock yourself out:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 2222/tcp
sudo ufw enable
sudo ufw status numbered
The cloud provider's security group must also allow the same ports. If you're not familiar with the operation, first read the UFW tutorial to prevent SSH lockout.
After installing Docker on Ubuntu or Debian, first confirm the service and the Compose plugin are available:
sudo systemctl enable --now docker
docker --version
docker compose version
df -h /srv/gitlab
GitLab officially does not support moving this production example directly to Docker Desktop for Windows. On a production server, also don't put the data directory on high-latency network file systems such as NFS or EFS; Gitaly is very sensitive to random I/O.
Create the project directory and the Compose file:
sudo mkdir -p /opt/gitlab
cd /opt/gitlab
sudo nano compose.yaml
Write the following configuration:
services:
gitlab:
image: gitlab/gitlab-ce:19.2.1-ce.0
container_name: gitlab
restart: always
hostname: gitlab.example.com
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://gitlab.example.com'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
letsencrypt['enable'] = true
letsencrypt['contact_emails'] = ['[email protected]']
ports:
- '80:80'
- '443:443'
- '2222:22'
volumes:
- /srv/gitlab/config:/etc/gitlab
- /srv/gitlab/logs:/var/log/gitlab
- /srv/gitlab/data:/var/opt/gitlab
shm_size: '256m'
logging:
driver: json-file
options:
max-size: '20m'
max-file: '5'
Here you must change hostname, external_url, the contact email and DNS at the same time. 2222:22 is the mapping from the host port to the container SSH port, while gitlab_shell_ssh_port = 2222 is responsible for making the web page generate the correct Clone URL; missing any one of them will make users connect to the wrong port.
Check the rendered result first, then start:
docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=200 gitlab
Don't use latest in production. Pinning the patch version not only avoids a sudden upgrade when rebuilding, but restoring a backup also requires exactly the same GitLab version and CE/EE edition. For Compose health checks, logs and rollback principles, see the Docker Compose production configuration checklist.
GitLab needs a few minutes to initialize the database and internal services for the first time. The container showing running doesn't mean the page is already available; if you see 502 for a short time, first check the progress:
docker exec -it gitlab gitlab-ctl status
docker compose logs --tail=200 gitlab
curl -I https://gitlab.example.com
If Let's Encrypt fails, first confirm the domain has taken effect in public DNS, that 80/443 are not blocked by the security group, and that there is no incorrect CAA record. Don't use a example.com that can't be publicly resolved to actually request a certificate.
Once the page works, read the temporary root password:
docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password
The username is root. After logging in, immediately change the password, fill in the administrator email, and create an ordinary account for daily use. This temporary password file is automatically deleted 24 hours after the first restart, so don't copy the password into scripts or chat logs.
First create an empty project in GitLab, then push once over HTTPS. After that, add your local SSH public key to Preferences → SSH Keys in the avatar menu, and test:
ssh -T -p 2222 [email protected]
The first connection will ask whether to accept the Host Key; verify the fingerprint before confirming. The correct SSH address should look like:
ssh://[email protected]:2222/your-name/demo.git
If the page still shows 22, check the spelling of gitlab_shell_ssh_port and rebuild the container; if 2222 is refused, check docker compose ps, ss -lntup, UFW and the cloud security group in turn. Don't map the container's 22 to the host's 22 just to save effort, otherwise it will conflict with the SSH you use to manage the VPS.
A 4GB VPS is only suitable for personal and low-frequency use. You can append the following three lines to the Compose GITLAB_OMNIBUS_CONFIG, then rebuild the container:
puma['worker_processes'] = 0
sidekiq['concurrency'] = 10
prometheus_monitoring['enable'] = false
docker compose up -d
docker exec -it gitlab gitlab-ctl status
free -h
docker stats --no-stream gitlab
Single-process Puma reduces concurrency, lower Sidekiq concurrency makes background jobs queue up, and disabling the built-in Prometheus also removes monitoring capability. Swap can reduce the risk of sudden OOM, but once paging continues, GitLab will be very slow. When Puma or Sidekiq restart repeatedly or kernel OOM records appear, prioritize upgrading memory rather than continuing to shut down key services.
The GitLab Docker image does not include an SMTP service. Without email, invitations, password resets, alerts and backup notifications are all affected; you should connect a trusted SMTP in the Omnibus configuration and send a test email from the Rails Console or the admin backend.
After going live, complete at least these settings:
- Disable user registration when you don't need a public community;
- Enable two-factor authentication for administrators and maintainers;
- Restrict project visibility and default permissions;
- Regularly check administrators, Access Tokens, Deploy Keys and Runners;
- Don't run the Runner on the same host as GitLab, and don't use privileged executors for untrusted projects.
Also check the default project visibility, new user permissions and maximum Artifact retention policy in the Admin Area. If public registration really needs to be enabled, at least pair it with email verification, administrator approval and rate limiting. After configuring SMTP, don't just look at the save success; actually trigger a password reset email and confirm the sender domain, bounces and spam policy are all normal.
Create a business data backup:
docker exec -t gitlab gitlab-backup create
ls -lh /srv/gitlab/data/backups
This archive contains data such as the database, repositories, uploads and artifacts, but it is not the same as a whole-machine backup. The configuration, gitlab-secrets.json, TLS private keys and SSH Host Keys are still in /srv/gitlab/config and must be saved separately:
sudo tar -C /srv/gitlab -czf \
/srv/gitlab-backups/gitlab-config-$(date +%F).tar.gz config
sudo ls -lh /srv/gitlab-backups
If you use external object storage, gitlab-backup won't copy the Bucket for you either. Sync both types of backups to another server or object storage, and regularly run recovery drills; just seeing backup files doesn't count as recoverable. For the complete approach, see VPS backup and recovery drill.
It is recommended to keep three types of recovery points—"daily, weekly, before upgrade"—and record the GitLab tag corresponding to each backup. After syncing, verify the file size or checksum on the remote side, so that if the local disk is damaged you don't lose the backups along with it. When Registry, LFS or Artifacts use an external Bucket, the business backup and the object storage snapshot should be in the same maintenance window to reduce reference inconsistencies after recovery.
Before restoring, prepare a new GitLab that runs normally and has exactly the same version and Edition. Stop Puma and Sidekiq before performing the restore:
docker exec -it gitlab gitlab-ctl stop puma
docker exec -it gitlab gitlab-ctl stop sidekiq
docker exec -it gitlab gitlab-backup restore BACKUP=REPLACE_WITH_BACKUP_ID
docker restart gitlab
docker exec -it gitlab gitlab-rake gitlab:check SANITIZE=true
Restoring rewrites the database, so it can only be run in a maintenance window or on an isolated recovery drill machine. Before restoring, also restore the matching configuration and Secrets.
Before upgrading, record the current version, complete a backup, and check GitLab's official Upgrade Path. When crossing multiple minor or major versions, you must pass through the designated stopover versions; GitLab 19's stopovers include 19.2, 19.5, 19.8 and 19.11, and each step must wait for background migrations to complete.
When upgrading a patch within the same minor version, first change the image in Compose to the target pinned tag, then run:
docker compose pull
docker compose up -d
docker compose logs --tail=200 gitlab
docker exec -it gitlab gitlab-rake gitlab:check SANITIZE=true
Then verify the web page, SSH push, Runner and background migrations. Once the database has been migrated, you cannot roll back simply by changing the image tag back to the old version; a correct rollback requires restoring the old image, old configuration and the backup of the corresponding version.
The Runner executes code from the projects, so it's best placed on a separate VPS. First create a Runner on the project, group or administrator Runner page in GitLab, and get the Runner Authentication Token starting with glrt-. The old Registration Token flow is deprecated, so don't copy old tutorials.
Start a pinned-version container on the Runner VPS:
sudo mkdir -p /srv/gitlab-runner/config
docker run -d --name gitlab-runner --restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:alpine-v19.2.1
docker exec -it gitlab-runner gitlab-runner register \
--non-interactive \
--url 'https://gitlab.example.com' \
--token 'glrt-REPLACE_WITH_RUNNER_AUTH_TOKEN' \
--executor docker \
--docker-image alpine:3.22 \
--description 'vps-docker-runner'
Verify with a minimal .gitlab-ci.yml:
stages: [test]
runner-check:
stage: test
image: alpine:3.22
script:
- echo "GitLab Runner is working"
- uname -a
Mounting the Docker Socket means the Runner can control the host Docker daemon, which is not strong isolation. Only let trusted projects use it, and don't share this Runner with public projects. When you need caching, building images and finer security isolation, you can refer to the GitHub Actions self-hosted Runner guide to understand similar risks.
| Symptom | What to check first | Common cause |
|---|---|---|
| Page shows 502 for a long time | gitlab-ctl status, container logs, memory | Initialization not finished, OOM, internal service failure |
| HTTPS certificate request fails | DNS, 80/443, CAA | Domain not yet effective or validation port blocked |
| SSH address still shows 22 | GITLAB_OMNIBUS_CONFIG | gitlab_shell_ssh_port not set or not applied |
| 2222 connection refused | Compose ports, UFW, security group | Port not mapped or firewall rule missing |
| Runner stays offline | Runner logs, URL, certificate | Wrong URL, wrong Token or untrusted certificate chain |
| Job stays pending | Runner scope, tags, paused state | No matching online Runner for the project |
| Backup cannot be restored | Version, CE/EE, Secrets | Target version mismatch or missing configuration backup |
The general diagnostic order is to check resources first, then container status, and finally the corresponding service logs:
free -h
df -h
docker compose ps
docker compose logs --tail=200 gitlab
docker exec -it gitlab gitlab-ctl tail
docker logs --tail=200 gitlab-runner
- If you need code hosting, MRs, permissions, Registry and CI/CD all in one, choose GitLab, but prepare enough memory and maintenance time.
- If you only need lightweight private Git, Issues and basic collaboration, choose Gitea, which is easier on a low-spec VPS.
- If you keep your code on GitHub and only want to control the build machine, choose GitHub Actions self-hosted Runner.
First complete four things on a non-critical VPS: HTTPS login, SSH push, a minimal CI Job, and recovery on another machine. Once all four work, then migrate the production repositories; otherwise "the container can start" is still a long way from a usable GitLab.