Skip to Content

Production Guide: Deploy GitLab CE with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu

A production-ready self-hosted Git platform with HTTPS, container registry, CI/CD runners, and operational guardrails.

Source code is the most valuable intellectual property most companies own, yet many teams host their repositories on platforms they do not control. Pricing changes and compliance requirements can force painful migrations at the worst possible moment. GitLab Community Edition is a complete DevOps platform that gives you Git repository management, issue tracking, code review, container registry, and CI/CD pipelines inside your own infrastructure. When self-hosted, it becomes a private engineering workspace where you control every merge request, every artifact, and every runner.

This guide deploys GitLab CE on Ubuntu with Docker Compose, Caddy for automatic HTTPS, an external PostgreSQL database, and Redis for caching and background job queues. You will also configure the built-in container registry and a GitLab Runner for CI/CD execution. By the end, you will have a fully operational Git platform accessible at your own domain, with persistent storage, nightly backups, and security defaults that meet production expectations.

Running GitLab on your own hardware means you avoid per-seat licensing for core features, can enforce network-level access controls, and keep build artifacts and container images inside your perimeter. You also gain the ability to customize CI/CD behavior with your own runner images and audit every access log without relying on third-party retention policies.

Architecture and flow overview

Caddy sits at the edge and terminates TLS with automatically managed Let's Encrypt certificates. It reverse-proxies HTTPS traffic to the GitLab container on its internal HTTP port. GitLab CE bundles the web interface, API, Git-over-HTTP backend, and Sidekiq job processor into a single container. PostgreSQL stores all repository metadata, user accounts, merge requests, and CI/CD configurations. Redis handles session caching, background job queues, and rate-limit counters.

The container registry runs as a separate service inside the GitLab container and stores Docker images in a dedicated volume. A GitLab Runner container connects to the GitLab instance over HTTPS and executes CI/CD jobs inside ephemeral Docker containers. All persistent state lives in named Docker volumes on the host. Caddy binds to public ports 80 and 443, while internal services communicate over a dedicated bridge network. A nightly cron job exports a PostgreSQL dump and archives the GitLab configuration and registry data to a remote target.

  • Edge: Caddy handles TLS termination, security headers, and HTTP-to-HTTPS redirects.
  • Application: GitLab CE container serves web UI, API, Git HTTP, and Sidekiq workers.
  • Database: PostgreSQL holds all relational data with automated dumps.
  • Cache: Redis manages sessions, job queues, and cache layers.
  • Registry: Built-in container registry stores Docker images in a dedicated volume.
  • CI/CD: GitLab Runner connects over HTTPS and spawns ephemeral build containers.
  • Ops: systemd restart policies and a nightly cron job handle resilience and backups.

Prerequisites

  • Ubuntu 22.04 or 24.04 LTS server with at least 4 vCPU, 8 GB RAM, and 100 GB SSD.
  • A DNS A record pointing your domain to the server IP address.
  • Ports 80 and 443 open on the host firewall and cloud security group.
  • Docker Engine 24.x and Docker Compose plugin installed.
  • A non-root user with sudo privileges and a working SSH key pair.
  • At least 20 GB of free space for repositories, container images, build artifacts, and backups.

Step-by-step deployment

1) Create directories and permissions

Create the project directory structure and set ownership so containers can write to the volumes.

sudo mkdir -p /opt/gitlab/{compose,backups}
sudo mkdir -p /var/lib/gitlab/{config,logs,data,registry}
sudo chown -R $USER:$USER /opt/gitlab
sudo chmod 700 /opt/gitlab

2) Store runtime options in an environment file

Keep domain settings, database credentials, and secrets out of the Compose file by placing them in a restricted .env file.

cat > /opt/gitlab/compose/.env << 'EOF'
GITLAB_DOMAIN=gitlab.example.com
GITLAB_REGISTRY_DOMAIN=registry.example.com
POSTGRES_USER=gitlab
POSTGRES_PASSWORD=$(openssl rand -base64 32)
POSTGRES_DB=gitlabhq_production
REDIS_PASSWORD=$(openssl rand -base64 32)
GITLAB_ROOT_PASSWORD=$(openssl rand -base64 32)
GITLAB_SECRETS_DB_KEY_BASE=$(openssl rand -hex 64)
GITLAB_SECRETS_SECRET_KEY_BASE=$(openssl rand -hex 64)
GITLAB_SECRETS_OTP_KEY_BASE=$(openssl rand -hex 64)
EOF
chmod 600 /opt/gitlab/compose/.env

3) Configure the Caddy reverse proxy

Caddy will automatically obtain and renew Let's Encrypt certificates for both the GitLab domain and the registry subdomain.

cat > /opt/gitlab/compose/Caddyfile << 'EOF'
gitlab.example.com {
    reverse_proxy gitlab:80
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
}

registry.example.com {
    reverse_proxy gitlab:5000
}
EOF

4) Define the Docker Compose services

The Compose file defines PostgreSQL, Redis, GitLab CE, Caddy, and a GitLab Runner. Each service mounts persistent volumes and connects to the internal bridge network.

cat > /opt/gitlab/compose/docker-compose.yml << 'EOF'
services:
  postgres:
    image: postgres:16-alpine
    container_name: gitlab_postgres
    restart: unless-stopped
    env_file: .env
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - gitlab_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: gitlab_redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      - gitlab_net
    healthcheck:
      test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  gitlab:
    image: gitlab/gitlab-ce:latest
    container_name: gitlab
    restart: unless-stopped
    hostname: gitlab.example.com
    env_file: .env
    environment:
      - GITLAB_OMNIBUS_CONFIG=
        external_url 'https://gitlab.example.com';
        gitlab_rails['db_adapter'] = 'postgresql';
        gitlab_rails['db_encoding'] = 'unicode';
        gitlab_rails['db_host'] = 'postgres';
        gitlab_rails['db_port'] = 5432;
        gitlab_rails['db_username'] = '${POSTGRES_USER}';
        gitlab_rails['db_password'] = '${POSTGRES_PASSWORD}';
        gitlab_rails['db_database'] = '${POSTGRES_DB}';
        gitlab_rails['redis_host'] = 'redis';
        gitlab_rails['redis_port'] = 6379;
        gitlab_rails['redis_password'] = '${REDIS_PASSWORD}';
        gitlab_rails['gitlab_shell_ssh_port'] = 2222;
        gitlab_rails['initial_root_password'] = '${GITLAB_ROOT_PASSWORD}';
        gitlab_rails['db_key_base'] = '${GITLAB_SECRETS_DB_KEY_BASE}';
        gitlab_rails['secret_key_base'] = '${GITLAB_SECRETS_SECRET_KEY_BASE}';
        gitlab_rails['otp_key_base'] = '${GITLAB_SECRETS_OTP_KEY_BASE}';
        registry_external_url 'https://registry.example.com';
        gitlab_rails['registry_enabled'] = true;
        gitlab_rails['registry_host'] = 'registry.example.com';
        gitlab_rails['registry_port'] = 5000;
        nginx['listen_port'] = 80;
        nginx['listen_https'] = false;
        nginx['proxy_set_headers'] = {
          "X-Forwarded-Proto" => "https",
          "X-Forwarded-Ssl" => "on"
        };
    ports:
      - "2222:22"
    volumes:
      - /var/lib/gitlab/config:/etc/gitlab
      - /var/lib/gitlab/logs:/var/log/gitlab
      - /var/lib/gitlab/data:/var/opt/gitlab
      - /var/lib/gitlab/registry:/var/opt/gitlab/gitlab-rails/shared/registry
    networks:
      - gitlab_net
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  caddy:
    image: caddy:2-alpine
    container_name: gitlab_caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - gitlab_net
    depends_on:
      - gitlab

  runner:
    image: gitlab/gitlab-runner:alpine
    container_name: gitlab_runner
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - runner_config:/etc/gitlab-runner
    networks:
      - gitlab_net
    depends_on:
      - gitlab

volumes:
  postgres_data:
  redis_data:
  caddy_data:
  caddy_config:
  runner_config:

networks:
  gitlab_net:
    driver: bridge
EOF

5) Start the stack and verify health

Bring up the database and cache first, then GitLab. The initial boot can take several minutes as it runs database migrations and compiles assets.

cd /opt/gitlab/compose
docker compose up -d
docker compose logs -f gitlab

Wait for the log line gitlab Reconfigured! and Listening on 0.0.0.0:80, then press Ctrl+C and verify Caddy has obtained certificates.

docker compose ps
curl -sI https://gitlab.example.com/users/sign_in | head -n 1

6) Complete first-time setup and register the runner

Sign in with the root account and the password stored in .env. Create your first project and note the runner registration token under Admin Area → CI/CD → Runners.

docker compose exec runner gitlab-runner register \
  --non-interactive \
  --url https://gitlab.example.com \
  --registration-token YOUR_REGISTRATION_TOKEN \
  --executor docker \
  --docker-image docker:24-dind \
  --docker-privileged \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock \
  --tag-list docker,linux

7) Set up automated backups

GitLab home contains repositories, configuration, and registry data. A nightly backup script exports the database, archives configuration, and rotates old dumps.

cat > /opt/gitlab/backups/backup-gitlab.sh << 'EOF'
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/opt/gitlab/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"

cd /opt/gitlab/compose

# Database dump
docker compose exec -T postgres pg_dump \
  -U gitlab -d gitlabhq_production > "$BACKUP_DIR/gitlab_db_$TIMESTAMP.sql"

# GitLab application backup (repositories, uploads, registry metadata)
docker compose exec gitlab gitlab-backup create

# Copy the latest backup tarball out of the container
LATEST=$(docker compose exec gitlab ls -t /var/opt/gitlab/backups | head -n 1)
docker compose cp "gitlab:/var/opt/gitlab/backups/$LATEST" "$BACKUP_DIR/"

# Configuration backup
tar czf "$BACKUP_DIR/gitlab_config_$TIMESTAMP.tar.gz" -C /var/lib/gitlab config

# Rotate local backups (keep 7 days)
find "$BACKUP_DIR" -name '*.sql' -mtime +7 -delete
find "$BACKUP_DIR" -name '*.tar.gz' -mtime +7 -delete
find "$BACKUP_DIR" -name '*.tar' -mtime +7 -delete
EOF
chmod +x /opt/gitlab/backups/backup-gitlab.sh

# Schedule nightly at 02:00
(sudo crontab -l 2>/dev/null; echo "0 2 * * * /opt/gitlab/backups/backup-gitlab.sh") | sudo crontab -

Configuration and secrets handling

Treat the .env file as a secret. It contains database passwords, Redis credentials, and the root password. Set permissions to 600, exclude the directory from version control, and rotate the root password through the GitLab UI after first login. Never store CI/CD variables or API tokens in job definitions; use GitLab's built-in CI/CD variables with masked and protected scopes.

For TLS, Caddy manages certificates automatically and stores them in the caddy_data volume. Because Caddy handles HTTPS at the edge, GitLab is configured to listen on plain HTTP and trust the forwarded proto headers. This simplifies the internal network and avoids certificate management inside the GitLab container. Runner images should be treated as immutable infrastructure. Build a custom image that contains your exact toolchain versions, push it to the private registry, and reference that image in .gitlab-ci.yml. Enable container image retention policies under Admin Area → Settings → CI/CD to prevent unbounded registry growth.

Verification

  • Open https://gitlab.example.com and confirm the certificate is valid and the padlock shows no warnings.
  • Sign in with the root user and verify the dashboard loads without mixed-content errors.
  • Create a test project, initialize it with a README, and verify Git clone over HTTPS succeeds.
  • Push a simple .gitlab-ci.yml file with a shell step that runs echo "CI is healthy", and verify the pipeline passes on the registered runner.
  • Run the backup script manually and confirm the resulting SQL dump and tarball contain repository data and configuration.
  • Reboot the host and verify that all services start automatically via the restart policies.
  • Check docker compose ps and confirm all services show Up status and Caddy is listening on 80 and 443.

Common issues and fixes

GitLab fails to start with database connection errors

If the GitLab container exits immediately, check that PostgreSQL is healthy before GitLab starts. The Compose file uses depends_on with health conditions, but if the database password contains special characters, verify it is properly escaped in .env. Fix by regenerating the password with openssl rand -base64 32 | tr -d '/+' and recreating the containers.

Runner registration fails with invalid token

Registration tokens expire and are scoped to groups or projects. If registration fails, generate a new token from the correct scope and retry. For a shared runner, use the token from Admin Area → CI/CD → Runners.

Container registry pushes fail with TLS errors

Ensure the registry subdomain DNS record points to the same server and Caddy has obtained a certificate for it. If Docker clients still reject the certificate, verify the client trusts the Let's Encrypt root CA.

Out of memory during repository imports

GitLab's memory footprint scales with concurrent users and repository size. If imports fail, increase the host RAM or add swap space. For the container itself, ensure the host has at least 8 GB RAM allocated. Monitor memory usage with docker stats and scale vertically if the working set consistently exceeds available memory.

Caddy shows 502 Bad Gateway after restart

This usually means GitLab is still starting when Caddy tries to proxy. Caddy retries automatically, but if GitLab takes more than a few minutes on first boot, ensure the restart policy is set and verify the logs for migration errors. Manually restart Caddy after GitLab is healthy if necessary.

Backups are large and slow

Exclude the container image layers from backups if you only need repository data and configuration. The registry volume can be backed up separately with a shorter rotation. Add --exclude='registry/docker' to the backup tar command if you rebuild images in CI and do not need to retain the layers long-term.

FAQ

Can I use an external object store instead of local registry storage?

Yes. GitLab supports Amazon S3, MinIO, and other S3-compatible backends for the container registry, job artifacts, and backups. Configure the object store endpoint in gitlab_rails under the registry and artifacts sections.

How do I add LDAP or SSO authentication?

GitLab CE supports LDAP and SAML integration. Edit the configuration to define the LDAP server URI, bind DN, and base DN. For OAuth providers, configure the application credentials in Admin Area → Settings → Integrations and map external groups to GitLab access levels.

What is the safest upgrade process for GitLab CE?

Always back up the database and configuration before upgrading. Pin the image tag to a specific CE version rather than latest. Review the upgrade path on the official GitLab documentation to ensure you do not skip intermediate versions. Test the upgrade in a staging container first. Roll back by reverting the tag and restoring the backup if errors appear.

Can I run multiple runners on different hosts?

Yes. Install the GitLab Runner binary or container on separate worker hosts and register each one with the same GitLab URL and registration token. Use runner tags to route jobs to specific hosts based on resource requirements or network location.

How do I restrict which users can create groups and projects?

Navigate to Admin Area → Settings → General and set Default projects limit and Default groups limit per user to low numbers. You can also disable project creation entirely for non-admin users.

What is the recommended retention policy for CI/CD artifacts and container images?

Set the artifact expiration in .gitlab-ci.yml to a short window such as 7 days. For the container registry, enable cleanup policies under Admin Area → Settings → CI/CD to delete untagged images and old tags after a defined retention period.

Internal links

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

Production Guide: Deploy Jenkins with Docker Compose + Caddy on Ubuntu
Deploy Jenkins on Ubuntu with Docker Compose and Caddy for production CI/CD with HTTPS, dynamic agents, and automated backups.