BlogDevOpsSelf-Host GitLab on VPS: Complete Setup Guide (2026)

Self-Host GitLab on VPS: Complete Setup Guide (2026)

Adrian Silaghi
Adrian Silaghi
April 20, 2026
13 min read
5 views
#gitlab #self-hosted #devops #ci-cd #vps #git #docker #gdpr #container registry
Self-Host GitLab on VPS: Complete Setup Guide (2026)

Your source code is the most valuable asset your team produces. Every commit, every merge request, every CI pipeline log, every container image — all of it sitting on someone else's infrastructure. For most teams that's fine, until the day it isn't: a surprise price hike, an outage during a release window, or a compliance officer asking where exactly that repository lives.

GitLab Community Edition is the answer a surprising number of teams have already quietly adopted. It's the same code that powers GitLab.com, it's open source, it's MIT-licensed for self-hosting, and running it on a €24.99/mo VPS replaces a GitHub Enterprise bill that can reach €21/user/month. For a 10-person team that's over €2,000/year saved, on top of full data ownership and zero vendor lock-in.

This guide walks through everything you need to run GitLab in production on a single VPS: hardware sizing, Docker Compose deployment, Let's Encrypt SSL, external PostgreSQL for scale, CI/CD runner configuration, the built-in container registry, automated backups to S3, and the GDPR considerations that matter if you're operating in the EU.

Why Self-Host GitLab?

GitLab.com's free tier is genuinely excellent — if you fit inside it. The moment you need private runners with decent specs, more than 5 users on paid tiers, or control over where data physically lives, the economics flip hard.

Feature GitLab.com (SaaS) GitHub Enterprise Self-Hosted GitLab CE
Monthly cost (10 users) €290 (Premium) €210 €24.99 (VPS only)
CI/CD minutes 10,000/mo included 3,000/mo included Unlimited (your hardware)
Private repositories Unlimited Unlimited Unlimited
Container registry storage 10GB (Premium) Pay per GB Only limited by disk
Data residency US (Google Cloud) US-first Wherever you want
GDPR compliance SCC required SCC required Native in EU
Source code access Closed (EE) Closed MIT license, full source
Offline/air-gapped No Server edition only Yes
Vendor lock-in High High None

The honest counter-argument: you're now the on-call engineer. GitLab.com will page someone else at 3 AM when Postgres locks up. Self-hosting means your CI pipeline going down is your problem. For teams of 5+ with decent ops skills — or for anyone treating their code as regulated data — the math still works.

Hardware Requirements: Don't Undersize GitLab

GitLab has a reputation for being heavy, and it's earned. A fresh Omnibus install boots Rails, Sidekiq, Puma, Gitaly, Workhorse, Nginx, Redis, Postgres, Prometheus, and a container registry all on one box. Here's what actually works in practice:

Team Size Minimum RAM Recommended RAM vCPU Storage DanubeData Plan
1-5 users 4GB (tight) 8GB 4 100GB DD Small (€12.49)
5-20 users 8GB 16GB 8 200GB DD Medium (€24.99)
20-100 users 16GB 32GB 16 400GB DD Large (€49.99)
100+ users 32GB+ 64GB+ 16+ 1TB+ Dedicated + external DB

A word on 4GB RAM: GitLab's docs technically say 4GB minimum. In practice, that's a trap. Sidekiq jobs, a few parallel CI pipelines, or a single large merge request diff will push you into swap and the whole instance becomes unresponsive. If this is the tool you use every day, 8GB is the real floor. 16GB is where GitLab actually feels fast.

For the vast majority of teams we see running self-hosted GitLab, the sweet spot is our DD Medium plan (€24.99/mo, 8 vCPU, 16GB RAM, 200GB NVMe). That gives you comfortable headroom for 5-20 developers, a built-in container registry, and 3-5 parallel CI jobs on the same box without breaking a sweat.

Docker vs Omnibus

GitLab officially supports two production-grade installs: the Omnibus package (deb/rpm drops every component onto your host, configured via /etc/gitlab/gitlab.rb) and the Docker image gitlab/gitlab-ce (same Omnibus package wrapped in a container). This guide uses Docker Compose — easier to reproduce, easier to back up, and friendlier alongside a reverse proxy. Configuration is identical: the Ruby-like gitlab.rb values pass through as GITLAB_OMNIBUS_CONFIG.

Step 1: Provision and Prepare Your VPS

  1. Create a DD Medium VPS on DanubeData
  2. Choose Ubuntu 24.04 LTS
  3. Note your public IPv4 address
  4. SSH in and do the usual hardening
# SSH as root
ssh root@YOUR_SERVER_IP

# Update system
apt update && apt upgrade -y

# Install base tools
apt install -y curl wget git ufw fail2ban ca-certificates

# Configure firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 22/tcp     # GitLab SSH (we'll tunnel on a separate port below)
ufw enable

# Set hostname
hostnamectl set-hostname gitlab

# Create a non-root user
adduser --disabled-password --gecos "" gitlab-admin
usermod -aG sudo gitlab-admin
mkdir -p /home/gitlab-admin/.ssh
cp ~/.ssh/authorized_keys /home/gitlab-admin/.ssh/
chown -R gitlab-admin:gitlab-admin /home/gitlab-admin/.ssh
chmod 700 /home/gitlab-admin/.ssh

About SSH port 22: GitLab needs port 22 open so users can git clone git@gitlab.yourdomain.com:.... If you want your admin SSH on a different port, change sshd's Port to something like 2222 before you spin up GitLab, so port 22 can be dedicated to the GitLab SSH daemon inside the container. Most teams just leave SSHd on 22 and let GitLab SSH sit on 2222 inside Docker, mapping it back to 22 externally via Docker.

Step 2: Install Docker

# Install Docker using the official convenience script
curl -fsSL https://get.docker.com | sh

# Install the Docker Compose plugin (v2)
apt install -y docker-compose-plugin

# Verify
docker --version
docker compose version

# Enable auto-start
systemctl enable --now docker

Step 3: Point DNS at Your Server

Create an A record for your GitLab hostname:

# DNS Configuration
Type:  A
Name:  gitlab   (or gitlab.yourdomain.com for root)
Value: YOUR_SERVER_IP
TTL:   300

# Optional: AAAA record for IPv6
Type:  AAAA
Name:  gitlab
Value: YOUR_IPV6_ADDRESS

Verify propagation before continuing:

dig gitlab.yourdomain.com +short
# Should return your server IP

Step 4: Create the Docker Compose Stack

We'll create a directory structure that keeps GitLab's data, configuration, and logs separated so backups and upgrades stay clean.

# Create directory layout
mkdir -p /srv/gitlab/{config,data,logs,backups}
cd /srv/gitlab

# Export paths (optional, makes later commands readable)
export GITLAB_HOME=/srv/gitlab

Create /srv/gitlab/docker-compose.yml:

cat > /srv/gitlab/docker-compose.yml << 'EOF'
services:
  gitlab:
    image: gitlab/gitlab-ce:17.8.2-ce.0
    container_name: gitlab
    restart: always
    hostname: gitlab.yourdomain.com
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        # External URL (this MUST match what users type in the browser)
        external_url 'https://gitlab.yourdomain.com'

        # Disable the built-in Let's Encrypt because we'll handle TLS at Caddy/Nginx
        letsencrypt['enable'] = false
        nginx['listen_port'] = 80
        nginx['listen_https'] = false
        nginx['proxy_set_headers'] = {
          "X-Forwarded-Proto" => "https",
          "X-Forwarded-Ssl" => "on"
        }

        # Container registry on its own subdomain
        registry_external_url 'https://registry.yourdomain.com'
        registry_nginx['listen_port'] = 5050
        registry_nginx['listen_https'] = false

        # SSH on port 2222 inside the container (mapped to 22 externally)
        gitlab_rails['gitlab_shell_ssh_port'] = 22

        # Email (adjust to your SMTP provider)
        gitlab_rails['smtp_enable'] = true
        gitlab_rails['smtp_address'] = "smtp.yourdomain.com"
        gitlab_rails['smtp_port'] = 587
        gitlab_rails['smtp_user_name'] = "gitlab@yourdomain.com"
        gitlab_rails['smtp_password'] = "CHANGE_ME"
        gitlab_rails['smtp_domain'] = "yourdomain.com"
        gitlab_rails['smtp_authentication'] = "login"
        gitlab_rails['smtp_enable_starttls_auto'] = true
        gitlab_rails['gitlab_email_from'] = "gitlab@yourdomain.com"

        # Backup target (local — we'll rsync to S3 later)
        gitlab_rails['backup_path'] = "/var/opt/gitlab/backups"
        gitlab_rails['backup_keep_time'] = 604800  # 7 days

        # Sensible defaults for a single-node deployment
        puma['worker_processes'] = 2
        sidekiq['max_concurrency'] = 10
        postgresql['shared_buffers'] = "512MB"
        postgresql['max_connections'] = 200
        prometheus_monitoring['enable'] = true
    ports:
      - '127.0.0.1:8929:80'      # HTTP (internal, Caddy proxies to this)
      - '127.0.0.1:5050:5050'    # Registry (internal, Caddy proxies to this)
      - '22:22'                  # SSH for git (public)
    volumes:
      - $GITLAB_HOME/config:/etc/gitlab
      - $GITLAB_HOME/logs:/var/log/gitlab
      - $GITLAB_HOME/data:/var/opt/gitlab
      - $GITLAB_HOME/backups:/var/opt/gitlab/backups
    shm_size: '256m'
    healthcheck:
      test: ['CMD', '/opt/gitlab/bin/gitlab-healthcheck', '--fail', '--max-time', '10']
      interval: 60s
      timeout: 30s
      retries: 5
      start_period: 300s

  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: always
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - $GITLAB_HOME/Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - gitlab

volumes:
  caddy-data:
  caddy-config:
EOF

Step 5: Configure the Reverse Proxy and SSL

Caddy gives us automatic Let's Encrypt certificates with zero config gymnastics — a single Caddyfile gets us HTTPS on both the main site and the container registry.

cat > /srv/gitlab/Caddyfile << 'EOF'
gitlab.yourdomain.com {
    reverse_proxy gitlab:80 {
        header_up X-Forwarded-Proto https
        header_up X-Forwarded-Ssl on
    }

    # Long timeouts for large git pushes and CI log streaming
    request_body {
        max_size 5GB
    }

    encode gzip

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }
}

registry.yourdomain.com {
    reverse_proxy gitlab:5050 {
        header_up X-Forwarded-Proto https
    }

    # Registry pushes can be huge
    request_body {
        max_size 50GB
    }

    encode gzip
}
EOF

A few things to note:

  • request_body max_size 50GB on the registry lets you push large Docker images without Caddy rejecting them.
  • We kept the public-facing SSH on port 22 so git@gitlab.yourdomain.com:group/repo.git URLs work out of the box.
  • HSTS is set to 1 year — only enable this once you're confident HTTPS works on every subdomain you use.

Step 6: First Boot

# Pull images and start
cd /srv/gitlab
docker compose pull
docker compose up -d

# Tail logs — GitLab takes 3-5 minutes to boot fully
docker compose logs -f gitlab

You're waiting for the line gitlab Reconfigured! followed by the Puma workers reporting ready. On a DD Medium that happens in roughly 3 minutes. Once it's up:

# Get the initial root password
docker compose exec gitlab cat /etc/gitlab/initial_root_password
# Note: this file is auto-deleted 24h after first boot

Visit https://gitlab.yourdomain.com, log in as root, change the password immediately, and then go to Admin Area → Settings → General → Sign-up restrictions and disable public registration. If you skip this step, your instance becomes a public GitLab within hours of hitting the open internet.

Step 7: Set Up CI/CD Runners

GitLab's built-in CI is one of the main reasons to self-host — unlimited pipeline minutes on your own hardware instead of $0.008/minute on GitLab.com after the free quota. You need at least one runner to pick up jobs.

Option A: Shared runner on the same host

Good for small teams, wastes no money, keeps things simple. Downside: a runaway CI job can starve GitLab of CPU.

# Install runner on the same VPS
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | bash
apt install -y gitlab-runner

# Register the runner — get the token from Admin Area → CI/CD → Runners
gitlab-runner register 
  --non-interactive 
  --url "https://gitlab.yourdomain.com/" 
  --registration-token "YOUR_REGISTRATION_TOKEN" 
  --executor "docker" 
  --docker-image "alpine:3.19" 
  --description "shared-docker-runner" 
  --tag-list "docker,shared" 
  --run-untagged="true" 
  --locked="false" 
  --docker-privileged="false"

# Verify
gitlab-runner list
systemctl status gitlab-runner

Option B: Dedicated runner VPS (recommended for 5+ users)

Spin up a second cheaper VPS (DD Nano €4.49 or DD Small €12.49) just for CI jobs. The GitLab instance stays responsive, and you can scale CI capacity independently by adding more runner VPSes later.

# On a second VPS, install the runner the same way as above
# Use the same registration token so it registers with your GitLab
# Add tags like "ci-worker" to target jobs:

gitlab-runner register 
  --non-interactive 
  --url "https://gitlab.yourdomain.com/" 
  --registration-token "YOUR_REGISTRATION_TOKEN" 
  --executor "docker" 
  --docker-image "alpine:3.19" 
  --description "ci-worker-01" 
  --tag-list "ci-worker" 
  --docker-privileged="false" 
  --docker-volumes "/cache" 
  --docker-volumes "/var/run/docker.sock:/var/run/docker.sock"

Mounting the Docker socket lets jobs build Docker images without dind (Docker-in-Docker), which is much faster. The trade-off: a malicious CI job can escape the container. For internal-only repos this is usually fine; for public projects use Kaniko or BuildKit in rootless mode instead.

Sample .gitlab-ci.yml

# .gitlab-ci.yml — smoke test that your runner works
stages:
  - test
  - build

hello:
  stage: test
  image: alpine:3.19
  script:
    - echo "Runner works! Host: $(hostname)"
    - apk add --no-cache curl
    - curl -sI https://gitlab.yourdomain.com

build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  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

Step 8: Container Registry

We enabled the registry in Step 4 (registry_external_url). You can now push images from CI without running a separate Docker Hub subscription.

# Manual test from your laptop
docker login registry.yourdomain.com
# Username: your GitLab username
# Password: a personal access token with read_registry/write_registry scope

docker tag myapp:latest registry.yourdomain.com/yourgroup/myapp:latest
docker push registry.yourdomain.com/yourgroup/myapp:latest

The registry stores images on local disk by default, under /var/opt/gitlab/gitlab-rails/shared/registry. On a DD Medium's 200GB disk, this will last a long time — but for teams pushing large images daily, configure S3 backing storage instead to offload growth to object storage.

S3-backed registry (optional)

Add this to GITLAB_OMNIBUS_CONFIG in your compose file and docker compose up -d to apply:

registry['storage'] = {
  's3' => {
    'accesskey' => 'YOUR_ACCESS_KEY',
    'secretkey' => 'YOUR_SECRET_KEY',
    'bucket' => 'gitlab-registry',
    'region' => 'eu-central',
    'regionendpoint' => 'https://s3.danubedata.ro',
    'pathstyle' => true
  }
}

DanubeData's S3-compatible object storage (€3.99/mo base, 1TB included) is the cheap home for this. You keep the registry as fast as local disk for metadata but offload the heavy image layers to S3.

Step 9: Backup Strategy

GitLab has a first-class backup command that produces a single tarball containing repositories, database dump, uploads, CI artifacts, pages, and registry data. Here's a production-ready backup script that uploads to S3 and cleans up old local copies.

#!/bin/bash
# /srv/gitlab/backup.sh
set -euo pipefail

DATE=$(date +%Y-%m-%d_%H-%M-%S)
S3_REMOTE="danubedata:gitlab-backups"
LOG="/var/log/gitlab-backup.log"

exec > >(tee -a "$LOG") 2>&1
echo "=== GitLab backup started: $DATE ==="

# 1. Create GitLab backup (produces /srv/gitlab/backups/*_gitlab_backup.tar)
docker compose -f /srv/gitlab/docker-compose.yml exec -T gitlab 
  gitlab-backup create CRON=1 SKIP=registry

# 2. Back up config (not included in gitlab-backup by design)
tar -czf /srv/gitlab/backups/gitlab-config_$DATE.tar.gz 
  /srv/gitlab/config/gitlab.rb 
  /srv/gitlab/config/gitlab-secrets.json

# 3. Ship to S3 via rclone (configure rclone first: rclone config)
rclone copy /srv/gitlab/backups "$S3_REMOTE" --include "*.tar" --include "*.tar.gz"

# 4. Keep only last 7 days locally
find /srv/gitlab/backups -name "*_gitlab_backup.tar" -mtime +7 -delete
find /srv/gitlab/backups -name "gitlab-config_*.tar.gz" -mtime +7 -delete

echo "=== GitLab backup complete: $(date) ==="
chmod +x /srv/gitlab/backup.sh

# Test it
/srv/gitlab/backup.sh

# Schedule via cron — daily at 2 AM
(crontab -l 2>/dev/null; echo "0 2 * * * /srv/gitlab/backup.sh") | crontab -

Critical: the gitlab-secrets.json file contains encryption keys for CI variables and 2FA secrets. Without it, a restored backup cannot decrypt any of those values. Always back it up separately, and keep at least one copy off-server.

Restore procedure (do this once, then document it)

# On a fresh GitLab install of the EXACT same version:
docker compose exec gitlab gitlab-ctl stop puma
docker compose exec gitlab gitlab-ctl stop sidekiq

# Copy backup into the container's backup dir
cp /path/to/1712345678_2026_04_20_17.8.2_gitlab_backup.tar 
   /srv/gitlab/backups/

# Restore (point to the timestamp prefix, not the full filename)
docker compose exec gitlab gitlab-backup restore BACKUP=1712345678_2026_04_20_17.8.2

# Restore the secrets file manually (IMPORTANT)
cp /backup/gitlab-secrets.json /srv/gitlab/config/gitlab-secrets.json
docker compose exec gitlab gitlab-ctl reconfigure
docker compose exec gitlab gitlab-ctl restart

Step 10: External PostgreSQL (Optional but Recommended at Scale)

GitLab ships with a bundled PostgreSQL, and for teams under 20 users that's genuinely fine. Beyond that, separating the database gives you three big wins: backups run against a dedicated DB, GitLab restarts don't block Postgres, and you can tune the database independently.

DanubeData's Managed PostgreSQL (from €19.99/mo) handles replication, backups, and patching for you. To wire it up, disable bundled Postgres and point GitLab at the external instance:

# Add to GITLAB_OMNIBUS_CONFIG:
postgresql['enable'] = false
gitlab_rails['db_adapter'] = 'postgresql'
gitlab_rails['db_encoding'] = 'utf8'
gitlab_rails['db_host'] = 'pg-xxx.svc.danubedata.ro'
gitlab_rails['db_port'] = 5432
gitlab_rails['db_username'] = 'gitlab'
gitlab_rails['db_password'] = 'YOUR_DB_PASSWORD'
gitlab_rails['db_database'] = 'gitlabhq_production'

Do the same for Redis if you want to split that out too — a Managed Valkey/Redis instance (€4.99/mo) removes one more thing from the GitLab VPS. For most teams, though, bundled Redis is fast enough and not worth moving.

Step 11: Upgrades

GitLab releases a new version on the 3rd Wednesday of every month. Upgrades matter — missing a major version means a painful multi-step upgrade later because GitLab doesn't let you skip majors.

# The SAFE way to upgrade:
cd /srv/gitlab

# 1. Back up first
./backup.sh

# 2. Check the upgrade path (never skip majors!)
# https://docs.gitlab.com/ee/update/#upgrade-paths

# 3. Update the image tag in docker-compose.yml, then:
docker compose pull gitlab
docker compose up -d gitlab

# 4. Watch logs until "Reconfigured!" appears
docker compose logs -f gitlab

Golden rule: always read the release notes before a major-version bump. GitLab occasionally removes deprecated config keys, and those break reconfigure silently.

GDPR and Data Residency Considerations

If you're operating in the EU, self-hosting GitLab inside the EU is one of the cleanest GDPR-compliance wins you can get. A few specifics:

  • Data location: With DanubeData (Falkenstein, Germany), repositories, CI logs, and uploaded artifacts never leave EU data centers. No Standard Contractual Clauses needed, no Schrems II exposure.
  • User data: GitLab stores email, name, and activity logs for every user. Document this in your privacy notice. For contractor/guest access, use GitLab's built-in expiring invitations.
  • Data subject requests: GitLab has a built-in user deletion flow (Admin → Users → Delete user). The "hard" delete option removes all associated issues, comments, and activity. Test it on a non-production user before you need it for real.
  • Log retention: Webhook logs, audit events, and CI pipeline logs accumulate indefinitely by default. Configure retention policies under Admin → Settings → CI/CD → Artifacts to meet your retention schedule.
  • Encryption at rest: The VPS disk is encrypted by default on DanubeData, but you're responsible for backup encryption. Configure rclone with --crypt remote for zero-knowledge backup encryption.

Real Cost Comparison: 10-User Team, One Year

Platform Per-User / Month Monthly Total Annual Total Includes
GitLab.com Premium €29 €290 €3,480 10k CI min, 50GB repo
GitLab.com Ultimate €99 €990 €11,880 50k CI min, security scans
GitHub Team €3.67 €37 €440 3k CI min (no container scanning)
GitHub Enterprise €21 €210 €2,520 50k CI min, audit logs, SAML
Self-hosted GitLab CE (DanubeData DD Medium) €2.50 effective €24.99 VPS + €3.99 S3 €348 Unlimited users, unlimited CI, registry

The math: versus GitLab.com Premium, you save €3,132/year. Versus Ultimate, €11,532. Versus GitHub Enterprise, €2,172. And with the €50 signup credit DanubeData gives new customers, the first 2 months are effectively free.

Add an optional Managed PostgreSQL (€19.99/mo) and Managed Valkey (€4.99/mo) for higher-scale setups, and you're still under €600/year total — a rounding error next to the proprietary alternatives.

Troubleshooting

GitLab is unresponsive / 502 errors

# Check resource usage first
docker stats gitlab
# If memory is at 95%+, you're undersized. Upgrade to DD Large or tune:
# puma['worker_processes'] = 1  (was 2)
# sidekiq['max_concurrency'] = 5  (was 10)

# Check internal services
docker compose exec gitlab gitlab-ctl status
# Any service showing "down" is the culprit — usually postgresql or puma

CI jobs stuck in "pending"

# Confirm runner is alive and talking to GitLab
systemctl status gitlab-runner
gitlab-runner verify

# Check if your .gitlab-ci.yml tags match a registered runner's tags
# Untagged jobs need a runner with "Run untagged jobs" = true

"Push too large" errors on large repos

# Increase the body size in Caddy (already 5GB in our config)
# AND in GitLab itself:
# Admin Area → Settings → Network → Outbound requests → max attachment size

# For git LFS files, enable LFS:
gitlab_rails['lfs_enabled'] = true

Let's Encrypt rate limit hit

# Happens if you up/down Caddy too often. Switch to the staging CA for testing:
# In Caddyfile, add at top:
# {
#     acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
# }
# Once working, remove it to get real certs.

Frequently Asked Questions

Is GitLab Community Edition missing features versus GitLab.com?

Yes, but possibly not the ones you think. CE includes unlimited private repos, CI/CD, container registry, merge requests, issue boards, wikis, SSO via OAuth, and the API. What's behind the paywall is mostly enterprise features: advanced compliance, security scanning, portfolio management, and approval rules. For 95% of development teams, CE is feature-complete.

How long does GitLab take to install?

About 15-20 minutes end-to-end: 3-5 minutes to pull the Docker image, 2-3 minutes for the first reconfigure, plus Caddy fetching Let's Encrypt certs. Budget 30 minutes including DNS propagation.

Can I migrate from GitLab.com to self-hosted?

Yes, easily. Use the Direct Transfer feature (GitLab 15.11+) or export individual projects from Project Settings → General → Advanced → Export project, then import them into your self-hosted instance.

What happens if I run out of disk space?

GitLab stops accepting pushes and returns 500 errors. Prevention: alert at 80% disk. Recovery: resize the VPS disk (DanubeData supports online resize), move the registry to S3, or run gitlab-rake gitlab:cleanup:orphan_job_artifact_files to reclaim space from old CI artifacts.

Do I need a separate VPS for GitLab Runners?

Not strictly. For 1-5 developers, a shared runner on the same VPS is fine. Beyond that, a dedicated €4.49/mo runner VPS pays for itself in responsiveness the first time someone triggers a 20-minute pipeline during a production incident.

Can I use another S3 provider for backups?

Yes. The rclone-based backup script works with any S3-compatible endpoint — DanubeData object storage, AWS S3, Backblaze B2, Wasabi. DanubeData is cheapest if you're already on DanubeData (same DC, no egress charges).

How do I handle GitLab major-version upgrades?

GitLab requires one-major-version-at-a-time upgrades. The documented paths live at docs.gitlab.com/ee/update. Pull the next-major image, restart, wait for reconfigure, repeat. Back up first and never skip a major even if Docker lets you.

Wrapping Up

Self-hosting GitLab sounds scary until you realize the hard part is just the initial Docker Compose file — everything after that is just reading logs and running docker compose pull once a month. For a team of 5-20 developers, you end up with:

  • A full DevOps platform for €25-€30/mo instead of €200+
  • Unlimited CI minutes on your own hardware
  • A container registry you don't pay per-GB for
  • All your source code physically inside the EU
  • Zero vendor lock-in on the MIT-licensed Community Edition

Recommended DanubeData setup for GitLab:

  • DD Medium VPS (€24.99/mo) — 8 vCPU, 16GB RAM, 200GB NVMe, ideal for GitLab + runner + registry
  • S3 Object Storage (€3.99/mo) — offsite backups + optional registry storage, 1TB included
  • Optional: Managed PostgreSQL (€19.99/mo) — if you want to split the database out
  • Optional: DD Nano runner (€4.49/mo) — dedicated CI worker so jobs don't compete with the main instance
  • All EU-hosted (Falkenstein, Germany), GDPR-compliant by default, 20TB traffic per VPS included

Total cost for a complete, production-grade GitLab setup: under €30/month for a single-server deployment, under €55/month with separated database and dedicated CI runner.

👉 Create Your GitLab VPS and redeem your €50 signup credit.

Running into issues setting up GitLab on DanubeData? Get in touch — we've helped a lot of teams make this exact transition and we're happy to review your config.

Share this article

Ready to Get Started?

Deploy your infrastructure in minutes with DanubeData's managed services.