A production-grade, fully self-provisioning GitLab Community Edition
deployment using Docker Compose. One docker compose up gives you a complete,
ready-to-use GitLab with the Container Registry enabled, MinIO object storage
for all large blobs, a fleet of 5 CI/CD runners auto-registered, and a
shared S3 cache — no manual steps.
Zero-touch: drop the whole stack (
make destroy) and bring it back up (make up) and it comes back fully configured. You just log in to the dashboard and start working — no registering runners, no creating buckets.
| File | Purpose |
|---|---|
docker-compose.yml |
All service definitions (healthchecks, limits, tuning) |
.env / .env.example |
Every configurable setting (ports, URL, resources, secrets) |
Makefile |
Convenience commands (make up, make runners-list, …) |
scripts/bootstrap-runners.sh |
Idempotent runner auto-provisioner |
scripts/register-runner.sh |
Register one runner container |
scripts/unregister-runner.sh |
Remove runners from GitLab |
scripts/minio-setup.sh |
Idempotent MinIO bucket provisioner |
examples/.gitlab-ci.yml |
Monorepo-tuned pipeline example |
The stack runs these services on the gitlab-ce-net network:
| Service | Container(s) | Purpose |
|---|---|---|
gitlab |
gitlab-ce |
GitLab CE + bundled PostgreSQL, Redis + Container Registry (5050) |
gitlab-runner … -04 |
gitlab-runner, gitlab-runner-01…-04 |
Fleet of 5 CI/CD runners (Docker executor + dind) |
minio |
gitlab-minio |
S3 object storage — GitLab blobs + runner cache |
minio-setup |
gitlab-minio-setup |
One-shot: creates all MinIO buckets, then exits |
runner-bootstrap |
gitlab-runner-bootstrap |
One-shot: auto-registers the runner fleet, then exits |
State (GitLab config, logs, data, each runner's config, cache) is persisted in named Docker volumes rather than host bind mounts — see Why named volumes.
On first up, with no human action:
- GitLab CE boots and reconfigures itself (PostgreSQL, Redis, Nginx, Gitaly…).
- The Container Registry is enabled on port
5050(image layers in MinIO). - MinIO starts and
minio-setupcreates all buckets (artifacts, LFS, uploads, packages, registry, runner cache, …). runner-bootstrapwaits for GitLab's API to be ready, mints a runner authentication token for each of the 5 runners, and registers them with the Docker executor + shared cache. It is idempotent — re-runningupnever creates duplicates.
Then you just open the dashboard and use it.
- Docker Engine 20.10+ and Docker Compose v2+
- Minimum: 4 vCPU / 4 GB RAM — Recommended: 4+ vCPU / 8 GB RAM
- ~10 GB free disk to start (grows with repositories, CI artifacts, backups)
# 1. Configure (edit URL, ports, password, resources) - or keep the defaults
cp .env.example .env
$EDITOR .env
# 2. (optional) Validate the config
make config # or: docker compose config
# 3. Launch EVERYTHING (GitLab + registry + 5 runners + cache)
make up # or: docker compose up -d
# 4. Watch it come up (first boot takes ~4–8 minutes: reconfigure + migrations)
make health # prints: starting → healthy
make bootstrap-logs # follow the auto-provisioner registering the 5 runnersThat's it. When health is healthy and the bootstrap logs say
Runner provisioning complete, open the URL from GITLAB_EXTERNAL_URL
(default http://localhost:8929) and log in. The 5 runners are already active
under Admin → CI/CD → Runners.
First boot timing: the container becomes
healthybefore the web API is fully serving. Therunner-bootstrapservice handles this for you — it waits for the API, then registers the fleet. You don't need to time anything.
-
Username:
root -
Password: the value of
GITLAB_ROOT_PASSWORDin.env. If you left it blank, GitLab generated one — read it with:make password # docker compose exec gitlab cat /etc/gitlab/initial_root_passwordThat file is auto-deleted 24 hours after the first boot, so log in and change the password promptly.
GitLab is not "just a web app" — it depends on several backend services. In this
stack they all run inside the gitlab-ce Omnibus container, managed by
gitlab-ctl / runit:
| Component | Role | Bundled in this stack? | Data location (gitlab-ce-data volume) |
|---|---|---|---|
| PostgreSQL | Primary database (users, projects, issues, CI metadata, permissions…) | ✅ Yes | /var/opt/gitlab/postgresql/data |
| Redis | Cache, job queues (Sidekiq), sessions, rate limiting, ActionCable | ✅ Yes | /var/opt/gitlab/redis |
| Gitaly | Git RPC — clone, push, fetch, repo storage | ✅ Yes | /var/opt/gitlab/git-data/repositories |
| Puma | Rails web application server | ✅ Yes | — |
| Sidekiq | Background jobs (CI scheduling, emails, webhooks…) | ✅ Yes | — |
| Workhorse + Nginx | Reverse proxy, Git HTTP, large uploads | ✅ Yes | — |
| Container Registry | Docker/OCI image storage (layers in MinIO) | ✅ Yes (enabled) | MinIO bucket gitlab-registry |
| Object storage | CI artifacts, LFS, uploads, packages (large blobs) | ✅ MinIO (external) | MinIO buckets (see below) |
| MinIO (separate service) | S3-compatible store for GitLab blobs and runner cache | Separate container | gitlab-ce-minio-data volume |
Inspect the bundled datastores:
make db-status # gitlab-ctl status postgresql redis
make psql # open a psql shell (gitlab-psql) to the GitLab databaseInside the container you can also run:
docker exec gitlab-ce gitlab-rails runner 'c=ActiveRecord::Base.connection; puts "adapter=#{c.adapter_name}"'
docker exec gitlab-ce gitlab-ctl status # all Omnibus servicesDefault rule: for a single-node GitLab (dev, staging, or a small/medium
team on one host), keep PostgreSQL and Redis bundled. That is GitLab's
officially recommended Omnibus layout. The image ships version-matched
PostgreSQL and Redis, applies the right tuning, runs migrations, and integrates
backups — you do not run separate postgres or redis containers unless you
have a concrete scaling or HA requirement.
| Scenario | PostgreSQL | Redis | Object storage | Gitaly |
|---|---|---|---|---|
| This stack — single node, dev/staging, large monorepo CI | Bundled ✅ | Bundled ✅ | MinIO ✅ (enabled) | Bundled ✅ |
| Production, moderate, still 1 node, ≲500 users | Bundled | Bundled | MinIO / S3 | Bundled |
| HA / large (GitLab Reference Architectures, 2k+ users) | External (managed or Patroni) | External (Sentinel / managed, often split by role) | External | Gitaly Cluster |
This repository intentionally uses bundled PostgreSQL and Redis. Adding them as separate Compose services would:
- require you to match GitLab's exact PostgreSQL version and extensions,
- duplicate tuning GitLab Omnibus already applies,
- add backup/upgrade/migration work you do not need on a single host, and
- increase fragility without improving performance at this scale.
Consider externalizing only when you outgrow a single node:
- High availability — multiple GitLab app nodes behind a load balancer; the database and Redis must be shared and fault-tolerant.
- Horizontal scaling — GitLab Reference Architectures (2k, 5k, 10k+ users) assume external PostgreSQL, split Redis, object storage, and often Gitaly Cluster.
- Managed ops — you want RDS/Cloud SQL/ElastiCache to handle backups, patching, and failover instead of Omnibus.
Until then, bundled is simpler, supported, and correct.
- GitLab stores all application state in PostgreSQL: users, groups, projects, issues/MRs, CI pipeline metadata, permissions, settings, etc.
- Omnibus runs a real PostgreSQL server (not SQLite). Version is tied to the GitLab release (e.g. PostgreSQL 17.x on GitLab 19.x).
- Required extensions include
pg_trgm,btree_gist, andplpgsql— Omnibus enables these automatically. - Backups: use
make backup(application backup includes the DB). Also back up the config volume (gitlab-secrets.jsonis critical). - External PostgreSQL notes (if you migrate later): must meet GitLab's
requirements; Amazon
Aurora is not supported for the main GitLab database; you become responsible
for version upgrades, extensions, and
gitlab:db:configure.
Redis is used for several distinct purposes inside GitLab:
| Redis role | Purpose |
|---|---|
| Cache | Fragment/cache layers |
| Queues | Sidekiq background jobs |
| Shared state | Cross-process coordination |
| Sessions | User session data |
| Rate limiting | API/request throttling |
| ActionCable | Real-time features (if enabled) |
On a single Omnibus node, one bundled Redis instance handles all of these. At large scale, GitLab recommends splitting Redis by function and using Sentinel or a managed service (ElastiCache, Memorystore, etc.) — that complexity is unnecessary for this stack.
Large blobs (CI artifacts, LFS objects, uploads, package registry files, registry
image layers) grow much faster than PostgreSQL data. This stack externalizes
all of them to MinIO — the recommended single-node optimization. PostgreSQL
and Redis stay bundled; only blobs move out, keeping the gitlab-ce-data volume
lean.
MinIO is configured automatically in docker-compose.yml via Omnibus
gitlab_rails['object_store'] and registry['storage']. Buckets are created by
minio-setup before GitLab starts — no manual steps.
| Bucket | Purpose |
|---|---|
gitlab-artifacts |
CI/CD job artifacts |
gitlab-lfs |
Git LFS objects |
gitlab-uploads |
Avatars, attachments, project uploads |
gitlab-packages |
Package Registry (npm, Maven, …) |
gitlab-registry |
Container Registry image layers |
gitlab-dependency-proxy |
Dependency Proxy cache |
gitlab-terraform-state |
Terraform state files |
gitlab-ci-secure-files |
CI secure files |
gitlab-pages |
GitLab Pages content |
gitlab-external-diffs |
External merge request diffs |
runner-cache |
GitLab Runner distributed cache (shared across fleet) |
Endpoints and credentials:
- S3 API: http://localhost:9000 (internal:
http://minio:9000) - Console: http://localhost:9001
- Credentials:
MINIO_ROOT_USER/MINIO_ROOT_PASSWORDin.env - Bucket names are configurable via
GITLAB_OBJECT_BUCKET_*in.env
Inspect object storage:
make minio-buckets # list all buckets
make object-store-status # show Omnibus object-store config
make minio-provision # (re)create buckets idempotentlyMigrating from local disk: if you already have blobs on the GitLab data volume from before object storage was enabled, they stay on disk until you run GitLab's object storage migration. Fresh installs (
make destroy+make up) store everything in MinIO from day one.
Git repositories live on disk via Gitaly, also bundled. For multi-node or very large repo counts, GitLab offers Gitaly Cluster ( Praefect + multiple Gitaly nodes). Not needed for a single Docker host.
| Question | Answer for this stack |
|---|---|
Do we need a separate postgres service? |
No — bundled PostgreSQL is correct |
Do we need a separate redis service? |
No — bundled Redis is correct |
| Is the Container Registry enabled? | Yes — port 5050, layers stored in MinIO gitlab-registry bucket |
| Is MinIO used for GitLab's DB? | No — PostgreSQL is bundled; MinIO stores blobs (artifacts, LFS…) |
| Where do CI artifacts / LFS / uploads go? | MinIO — auto-configured, buckets auto-created |
What if I make destroy and make up? |
Everything reprovisions; blobs in gitlab-ce-minio-data, DB in GitLab vol |
If you move to production HA or cloud, typical order of externalization:
Object storage (MinIO/S3)— already done in this stack.- Managed PostgreSQL — RDS, Cloud SQL, or self-hosted Patroni cluster.
- Managed / split Redis — Sentinel or cloud Redis with separate instances per GitLab Redis role.
- Gitaly Cluster — when repo storage or Git RPC becomes the bottleneck.
Each step is documented in GitLab's Reference Architectures and Omnibus external services guides. Object storage is already externalized; PostgreSQL/Redis externalization is the next step when you need HA.
Everything is driven by .env. The most common settings:
| Variable | Default | Notes |
|---|---|---|
GITLAB_VERSION |
latest |
Pin to a version (e.g. 17.10.0-ce.0) for production |
GITLAB_EXTERNAL_URL |
http://localhost:8929 |
Scheme + host + port users browse to |
GITLAB_HTTP_PORT |
8929 |
Host HTTP port (matches the URL port) |
GITLAB_HTTPS_PORT |
8443 |
Host HTTPS port |
GITLAB_SSH_PORT |
2224 |
Host port for Git-over-SSH |
GITLAB_ROOT_PASSWORD |
— | Initial root password (min 8 chars) |
GITLAB_MEM_LIMIT |
8g |
Hard memory ceiling for the container |
GITLAB_PUMA_WORKERS |
2 |
Web workers; raise for more concurrent users |
GITLAB_REGISTRY_PORT |
5050 |
Host/registry port (plain HTTP) |
RUNNER_CONCURRENT |
4 |
Parallel jobs per runner (×5 runners for the fleet) |
RUNNER_TAGS |
docker,monorepo |
Tags applied to every runner |
RUNNER_DEFAULT_IMAGE |
docker:27-cli |
Default job image |
MINIO_ROOT_USER / _PASSWORD |
gitlab-minio / … |
MinIO credentials (GitLab blobs + runner cache) |
GITLAB_OBJECT_BUCKET_ARTIFACTS |
gitlab-artifacts |
CI artifacts bucket |
GITLAB_OBJECT_BUCKET_REGISTRY |
gitlab-registry |
Container Registry layers bucket |
RUNNER_CACHE_BUCKET |
runner-cache |
Runner distributed cache bucket |
Advanced Omnibus settings live inline in docker-compose.yml under
GITLAB_OMNIBUS_CONFIG. After changing them:
make up # recreate with the new environment
# or, to apply without recreating:
make reconfigureThe container's SSH (port 22) is published on GITLAB_SSH_PORT (default 2224).
Clone URLs will therefore include the port:
git clone ssh://git@localhost:2224/your-group/your-project.gitThis stack ships a ready-to-use CI/CD setup optimized for a large monorepo: a fleet of 5 auto-registered Docker-executor runners, the built-in Container Registry, and a shared MinIO (S3) cache.
Five runner containers are defined and registered automatically on up:
gitlab-runner gitlab-runner-01 gitlab-runner-02 gitlab-runner-03 gitlab-runner-04
There is no manual registration step. The runner-bootstrap service:
- waits for GitLab's HTTP API to be ready,
- mints a modern authentication token (
glrt-…) for each runner via the API — GitLab 18+ removed the legacyregistration_tokenflow, so this is the supported approach, - registers each runner (Docker executor + shared cache),
- is idempotent: already-registered runners are skipped, so re-running
make upormake runners-provisionnever creates duplicates.
Because all runners enable run_untagged, they immediately pick up jobs with no
tags — which is exactly what fixes:
"This job is stuck because you don't have any active runners that can run this job."
Manage the fleet:
make runners-list # list runners in every container
make runners-verify # confirm GitLab sees them online
make runners-logs # follow logs from all 5 runners
make runners-provision # (re)provision - idempotent, safe anytime
make bootstrap-logs # view the auto-provisioner's output
make runners-unregister # remove the whole fleet from GitLabAdd a brand-new runner (e.g. a 6th):
-
In
docker-compose.yml, copy a runner block to a new servicegitlab-runner-05(give itcontainer_name,hostname, and a newgitlab-runner-config-05volume), and add that volume undervolumes:. -
Bring it up and register just that one — idempotent, touches nothing else:
docker compose up -d gitlab-runner-05 make runner-add NAME=gitlab-runner-05
To include it in the auto-provisioner for future resets, also add its name to
RUNNER_CONTAINERS in the runner-bootstrap service.
Remove a runner: ./scripts/unregister-runner.sh gitlab-runner-05, then
delete its service/volume from docker-compose.yml.
More parallelism without more containers: raise RUNNER_CONCURRENT in
.env and make runners-provision (or make up). Total parallel jobs ≈
5 × RUNNER_CONCURRENT.
- Executor:
docker, spawning sibling job containers via the host Docker socket, attached togitlab-ce-netso jobs resolvegitlab,gitlab.localandminio. - Privileged +
/certs/client: lets pipelines usedocker:dindto build and push images. concurrent = RUNNER_CONCURRENTper container.clone_url = http://gitlab:8929: jobs clone over the internal network regardless of the browser-facing URL.- Shared S3 cache on MinIO (
runner-cachebucket), so all 5 runners share one cache.
The registry is enabled on port 5050 over plain HTTP and advertised as
gitlab.local:5050.
Make gitlab.local resolvable on your host (one-time, needed for browser
links and docker login from the host — CI jobs already resolve it via the
Docker network):
echo "127.0.0.1 gitlab.local" | sudo tee -a /etc/hostsTrust the insecure (HTTP) registry so your host Docker can push/pull. In Docker Desktop → Settings → Docker Engine, add:
{ "insecure-registries": ["gitlab.local:5050"] }Then, from the host:
docker login gitlab.local:5050 -u root -p '<your-root-password>'Check the endpoint any time:
make registry-status # HTTP 401 = up (registry requires auth)Inside CI, the standard variables just work:
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"Because the registry is HTTP, the
docker:dindservice must trust it:services: - name: docker:27-dind command: ["--insecure-registry=gitlab.local:5050"]
MinIO stores all GitLab large blobs (artifacts, LFS, uploads, packages,
registry layers) and the runner distributed cache. Everything is
auto-provisioned — buckets are created by minio-setup on every up.
- S3 API: http://localhost:9000 • Console: http://localhost:9001
- Credentials:
MINIO_ROOT_USER/MINIO_ROOT_PASSWORDin.env - See Object storage & MinIO for the full bucket list
make minio-buckets # list buckets
make object-store-status # verify GitLab Omnibus configSee examples/.gitlab-ci.yml for a working example.
Key techniques for large monorepos:
- Only build what changed with
rules: changes:per component/service. - Shallow, fast checkout:
GIT_DEPTH,GIT_STRATEGY: fetch,FF_USE_FASTZIP. - Per-component cache keys (
cache:key:files) for high cache-hit rates. needs:to build a DAG so independent components run in parallel.- Scale throughput by raising
RUNNER_CONCURRENTand/or adding runner containers (see Add or remove runners later).
make status # container + health status
make check # GitLab's built-in self-diagnostics (gitlab:check)
make backup # application backup (make backup-pull to copy it out)
make upgrade # pull newest image + recreate (see upgrade note below)
make down # stop (data preserved in volumes)
make destroy # DANGER: stop + delete ALL persisted volumesThe whole stack is designed to be disposable and self-provisioning:
make destroy # remove containers + ALL volumes (full wipe)
make up # fresh install: GitLab + registry + 5 runners + cacheAfter make up, wait for make health = healthy and
make bootstrap-logs = Runner provisioning complete. Everything is
re-created automatically — you never re-register runners or re-create the cache
bucket by hand.
If you only make down (volumes kept) and make up again, the runners are
already registered; the bootstrap simply confirms and skips them.
The GitLab Omnibus image enforces strict filesystem ownership during
reconfigure (for example /var/opt/gitlab/git-data/repositories must be
owned by git:git). Docker Desktop bind mounts on macOS/Windows cannot honor
those uid/gid ownerships, so reconfigure fails with:
Failed asserting that ownership of "/var/opt/gitlab/git-data/repositories" was git:git
Named volumes live on the Docker VM's native Linux filesystem, where ownership is preserved correctly, so GitLab boots cleanly. This is the recommended way to run GitLab in Docker on a Mac/Windows host.
make backup creates a tarball inside the data volume
(/var/opt/gitlab/backups). Copy it to the host with:
make backup-pull # -> ./backups/Note that GitLab's application backup does not include the
configuration/secrets — also back up the config volume (especially
gitlab-secrets.json and gitlab.rb):
docker cp gitlab-ce:/etc/gitlab ./backups/configGitLab must be upgraded through supported version paths — do not skip major
versions. When jumping several releases, pin GITLAB_VERSION to each required
stop on the upgrade path,
running make upgrade at each step and waiting for healthy before continuing.
This setup is tuned for a small/medium instance:
- Bundled Prometheus/Grafana monitoring is disabled to save ~500 MB RAM.
- Puma workers, Sidekiq concurrency and PostgreSQL
shared_buffersare configurable via.env. shm_sizeis raised to 256 MB (PostgreSQL needs more than Docker's 64 MB default).
On a constrained host, lower GITLAB_PUMA_WORKERS=1,
GITLAB_SIDEKIQ_CONCURRENCY=5, GITLAB_MEM_LIMIT=4g. On a busy instance, raise
workers and memory.
-
Stuck in
starting/unhealthyfor a while: normal on first boot; the reconfigure step can take several minutes. Followmake logs. -
could not resize shared memoryin PostgreSQL logs: ensureshm_sizeis set (it is here) and the host allows it. -
502 in the browser right after start: GitLab is still booting; wait for the healthcheck to report
healthy. -
Port already in use: change
GITLAB_HTTP_PORT/GITLAB_SSH_PORT/GITLAB_REGISTRY_PORT/MINIO_PORTin.env. -
Runners log
config.toml: no such file or directoryon first boot: this was a harmless race — runners started beforerunner-bootstrapcreated their config. Fixed byscripts/runner-entrypoint.sh, which waits for bootstrap then starts. Aftermake up, you should only see[runner-entrypoint] waiting for runner-bootstrap…followed byconfig ready, starting gitlab-runner. If it persists past ~10 minutes, checkmake bootstrap-logs. -
Runners didn't register / job still stuck: the auto-provisioner waits for the API, but if it exited early (
make bootstrap-logsshows an error), just re-run it — it's idempotent:make runners-provision make runners-verify
-
docker login/push fails from the host: you skipped the/etc/hostsentry or the insecure-registry setting (see Container Registry). CI jobs are unaffected — they resolvegitlab.localon the Docker network.
- GitLab Docker install: https://docs.gitlab.com/ee/install/docker/
- Omnibus configuration: https://docs.gitlab.com/omnibus/settings/
- System requirements (PostgreSQL, Redis): https://docs.gitlab.com/ee/install/requirements.html
- External PostgreSQL: https://docs.gitlab.com/omnibus/settings/database.html
- External Redis: https://docs.gitlab.com/omnibus/settings/redis.html
- Object storage: https://docs.gitlab.com/administration/object_storage/
- Reference Architectures (scaling): https://docs.gitlab.com/administration/reference_architectures/
