Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part VII: The Five Contributors

Five contributors, five services, one ComposeFile. Each contributor adds its service definition -- image, volumes, healthcheck, environment, ports -- to a shared Docker Compose model. Together they produce a complete GitLab stack.

The Contributor Pattern

The IComposeFileContributor interface comes from the Docker Compose Bundle. It has a single method:

public interface IComposeFileContributor
{
    void Contribute(ComposeFile composeFile);
}

Each contributor adds services, volumes, and configuration to the shared ComposeFile model. The model is the typed representation of a docker-compose.yml file, generated from 32 merged Compose schema versions. Contributors are composable -- you pick which ones you need, and the final ComposeFile reflects exactly that combination.

Image Constants

All five contributors reference Docker images through a shared constants class:

public static class GitLabDockerImages
{
    public const string GitLabCe       = "gitlab/gitlab-ce";
    public const string GitLabEe       = "gitlab/gitlab-ee";
    public const string GitLabRunner   = "gitlab/gitlab-runner";
    public const string Postgres       = "postgres";
    public const string Redis          = "redis";
    public const string Minio          = "minio/minio";

    public const string DefaultGitLabTag  = "latest";
    public const string DefaultPostgresTag = "16-alpine";
    public const string DefaultRedisTag    = "7-alpine";
    public const string DefaultRunnerTag   = "alpine";
    public const string DefaultMinioTag    = "latest";
}

1. GitLab Contributor (Core)

The main contributor. It creates the gitlab service with the rendered GITLAB_OMNIBUS_CONFIG injected as an environment variable.

public sealed class GitLabComposeContributor : IComposeFileContributor
{
    private readonly GitLabOmnibusConfig? _omnibusConfig;
    private readonly string _image;
    private readonly string _tag;
    private readonly string _hostname;
    private readonly string? _rootPassword;
    private readonly string? _timezone;
    private readonly string _shmSize;
    private readonly string _memoryLimit;

    public GitLabComposeContributor(
        GitLabOmnibusConfig? omnibusConfig = null,
        string image = GitLabDockerImages.GitLabCe,
        string tag = GitLabDockerImages.DefaultGitLabTag,
        string hostname = "gitlab.example.com",
        string? rootPassword = null,
        string? timezone = null,
        string shmSize = "256m",
        string memoryLimit = "8G",
        Dictionary<string, string?>? labels = null,
        List<string>? networks = null) { /* ... */ }

    public void Contribute(ComposeFile composeFile)
    {
        // Environment
        var env = new Dictionary<string, string?>();
        if (_omnibusConfig is not null)
            env["GITLAB_OMNIBUS_CONFIG"] =
                GitLabRbRenderer.Render(_omnibusConfig);
        if (_rootPassword is not null)
            env["GITLAB_ROOT_PASSWORD"] = _rootPassword;
        if (_timezone is not null)
            env["TZ"] = _timezone;

        // Service
        composeFile.Services["gitlab"] = new ComposeService
        {
            Image = $"{_image}:{_tag}",
            Hostname = _hostname,
            Restart = "always",
            Environment = env.Count > 0 ? env : null,
            Volumes = new List<ComposeServiceVolumesConfig>
            {
                new() { Type = "volume", Source = "gitlab-config",
                         Target = "/etc/gitlab" },
                new() { Type = "volume", Source = "gitlab-logs",
                         Target = "/var/log/gitlab" },
                new() { Type = "volume", Source = "gitlab-data",
                         Target = "/var/opt/gitlab" },
            },
            Ports = new List<ComposeServicePortsConfig>
            {
                new() { Target = 80,  Published = 80 },
                new() { Target = 443, Published = 443 },
                new() { Target = 22,  Published = 22 },
            },
            Healthcheck = new ComposeHealthcheck
            {
                Test = new List<string>
                    { "CMD", "curl", "-f", "http://localhost/-/health" },
                Interval = "30s",
                Timeout = "10s",
                Retries = 5,
                StartPeriod = "300s",
            },
            Labels = _labels,
        };

        // Volumes
        composeFile.Volumes["gitlab-config"] = null;
        composeFile.Volumes["gitlab-logs"] = null;
        composeFile.Volumes["gitlab-data"] = null;
    }
}

Key design decisions:

  • Three named volumes: gitlab-config (secrets, SSL certs), gitlab-logs (service logs), gitlab-data (repositories, database, uploads). The gitlab-config volume contains gitlab-secrets.json which encrypts database columns -- if lost, encrypted data is unrecoverable.
  • Health check with 300s start period: GitLab takes 3-5 minutes to boot. The 300-second start_period prevents Docker from marking the container as unhealthy during initialization.
  • shm_size: 256m: Required by Prometheus and PostgreSQL shared buffers inside the Omnibus container.
  • CE by default: Uses gitlab/gitlab-ce (Community Edition). Switch to EE with image: GitLabDockerImages.GitLabEe.

2. PostgreSQL Contributor

Replaces GitLab's bundled PostgreSQL with an external, independently managed instance:

public sealed class PostgresqlComposeContributor : IComposeFileContributor
{
    public PostgresqlComposeContributor(
        string tag = GitLabDockerImages.DefaultPostgresTag,
        string database = "gitlabhq_production",
        string username = "gitlab",
        string password = "${GITLAB_DB_PASSWORD}") { /* ... */ }

    public void Contribute(ComposeFile composeFile)
    {
        composeFile.Services["postgresql"] = new ComposeService
        {
            Image = $"{GitLabDockerImages.Postgres}:{_tag}",
            Restart = "always",
            Environment = new Dictionary<string, string?>
            {
                ["POSTGRES_DB"] = _database,
                ["POSTGRES_USER"] = _username,
                ["POSTGRES_PASSWORD"] = _password,
            },
            Volumes = new List<ComposeServiceVolumesConfig>
            {
                new() { Type = "volume", Source = "postgresql-data",
                         Target = "/var/lib/postgresql/data" },
            },
            Healthcheck = new ComposeHealthcheck
            {
                Test = new List<string>
                    { "CMD-SHELL", $"pg_isready -U {_username}" },
                Interval = "10s",
                Timeout = "5s",
                Retries = 5,
            },
        };

        composeFile.Volumes["postgresql-data"] = null;
    }
}

The password defaults to ${GITLAB_DB_PASSWORD} -- a Docker Compose variable that reads from the environment or .env file. When using an external PostgreSQL, the GitLab Omnibus config must also be updated:

.WithGitlabRails(r => r
    .WithDbHost("postgresql")
    .WithDbPassword("${GITLAB_DB_PASSWORD}")
    .WithDbDatabase("gitlabhq_production")
    .WithDbUsername("gitlab"))
.WithPostgresql(p => p
    .WithEnable(false))  // Disable bundled PostgreSQL

3. Redis Contributor

Replaces GitLab's bundled Redis:

public sealed class RedisComposeContributor : IComposeFileContributor
{
    public RedisComposeContributor(
        string tag = GitLabDockerImages.DefaultRedisTag,
        string password = "${GITLAB_REDIS_PASSWORD}") { /* ... */ }

    public void Contribute(ComposeFile composeFile)
    {
        composeFile.Services["redis"] = new ComposeService
        {
            Image = $"{GitLabDockerImages.Redis}:{_tag}",
            Restart = "always",
            Command = new List<string>
                { "redis-server", "--requirepass", _password },
            Volumes = new List<ComposeServiceVolumesConfig>
            {
                new() { Type = "volume", Source = "redis-data",
                         Target = "/data" },
            },
            Healthcheck = new ComposeHealthcheck
            {
                Test = new List<string>
                    { "CMD", "redis-cli", "ping" },
                Interval = "10s",
                Timeout = "5s",
                Retries = 5,
            },
        };

        composeFile.Volumes["redis-data"] = null;
    }
}

The --requirepass flag is passed as a command argument to the Redis container. The corresponding Omnibus config disables bundled Redis and points to the external service:

.WithRedis(r => r
    .WithEnable(false))
.WithGitlabRails(r => r
    .WithRedisHost("redis")
    .WithRedisPassword("${GITLAB_REDIS_PASSWORD}"))

4. GitLab Runner Contributor

Adds the CI/CD executor that polls GitLab for jobs:

public sealed class GitLabRunnerComposeContributor : IComposeFileContributor
{
    public GitLabRunnerComposeContributor(
        string tag = GitLabDockerImages.DefaultRunnerTag) { /* ... */ }

    public void Contribute(ComposeFile composeFile)
    {
        composeFile.Services["gitlab-runner"] = new ComposeService
        {
            Image = $"{GitLabDockerImages.GitLabRunner}:{_tag}",
            Restart = "always",
            Volumes = new List<ComposeServiceVolumesConfig>
            {
                new() { Type = "volume", Source = "runner-config",
                         Target = "/etc/gitlab-runner" },
                new() { Type = "bind", Source = "/var/run/docker.sock",
                         Target = "/var/run/docker.sock" },
            },
            DependsOn = new ComposeServiceDependsOnCondition
            {
                Condition = "service_healthy",
            },
        };

        composeFile.Volumes["runner-config"] = null;
    }
}

Key details:

  • Docker socket bind mount: The runner needs access to the Docker daemon to execute jobs in containers (Docker-in-Docker pattern).
  • depends_on: service_healthy: The runner won't start until the GitLab healthcheck passes. This is critical -- the runner needs to register with a running GitLab instance.
  • Configuration persisted in volume: Runner registration creates config.toml in the runner-config volume. After first registration, the runner reconnects automatically on restart.

5. MinIO Contributor

Adds S3-compatible object storage for GitLab artifacts, LFS objects, uploads, packages, and more:

public sealed class MinioComposeContributor : IComposeFileContributor
{
    public MinioComposeContributor(
        string tag = GitLabDockerImages.DefaultMinioTag,
        string rootUser = "${MINIO_ROOT_USER}",
        string rootPassword = "${MINIO_ROOT_PASSWORD}") { /* ... */ }

    public void Contribute(ComposeFile composeFile)
    {
        composeFile.Services["minio"] = new ComposeService
        {
            Image = $"{GitLabDockerImages.Minio}:{_tag}",
            Restart = "always",
            Command = new List<string>
                { "server", "/data", "--console-address", ":9001" },
            Environment = new Dictionary<string, string?>
            {
                ["MINIO_ROOT_USER"] = _rootUser,
                ["MINIO_ROOT_PASSWORD"] = _rootPassword,
            },
            Volumes = new List<ComposeServiceVolumesConfig>
            {
                new() { Type = "volume", Source = "minio-data",
                         Target = "/data" },
            },
            Ports = new List<ComposeServicePortsConfig>
            {
                new() { Target = 9000, Published = 9000 },
                new() { Target = 9001, Published = 9001 },
            },
            Healthcheck = new ComposeHealthcheck
            {
                Test = new List<string>
                    { "CMD", "curl", "-f",
                      "http://localhost:9000/minio/health/live" },
                Interval = "30s",
                Timeout = "10s",
                Retries = 3,
            },
        };

        composeFile.Volumes["minio-data"] = null;
    }
}

Port 9000 is the S3 API endpoint; port 9001 is the web console. The corresponding Omnibus config enables consolidated object storage:

.WithGitlabRails(r => r
    .WithObjectStore(os => os
        .WithEnabled(true)
        .WithConnection(c => c
            .WithProvider("AWS")
            .WithAwsAccessKeyId("${MINIO_ROOT_USER}")
            .WithAwsSecretAccessKey("${MINIO_ROOT_PASSWORD}")
            .WithEndpoint("http://minio:9000")
            .WithPathStyle(true))))

Composing All Five

Here's how all five contributors come together:

var config = new GitLabOmnibusConfigBuilder()
    .WithExternalUrl("https://gitlab.lab")
    .WithNginx(n => n.WithListenPort(80).WithListenHttps(false))
    .WithPostgresql(p => p.WithEnable(false))
    .WithRedis(r => r.WithEnable(false))
    .WithGitlabRails(r => r
        .WithDbHost("postgresql")
        .WithDbPassword("${GITLAB_DB_PASSWORD}")
        .WithRedisHost("redis")
        .WithRedisPassword("${GITLAB_REDIS_PASSWORD}"))
    .WithLetsencrypt(l => l.WithEnable(false))
    .Build();

var composeFile = new ComposeFile();
new GitLabComposeContributor(
    omnibusConfig: config,
    hostname: "gitlab.lab",
    rootPassword: "${GITLAB_ROOT_PASSWORD}",
    timezone: "Europe/Paris"
).Contribute(composeFile);
new PostgresqlComposeContributor().Contribute(composeFile);
new RedisComposeContributor().Contribute(composeFile);
new GitLabRunnerComposeContributor().Contribute(composeFile);
new MinioComposeContributor().Contribute(composeFile);

Service Dependencies

Diagram
The full stack that the five contributors assemble — gitlab-runner waits on gitlab, gitlab connects to postgresql, redis, and minio, and each service is paired with its persistent named volume.

Traefik Integration

When running behind a Traefik reverse proxy (from the Traefik Bundle), the GitLab contributor works with Traefik labels to handle TLS termination:

var config = new GitLabOmnibusConfigBuilder()
    .WithExternalUrl("https://gitlab.lab")
    .WithNginx(n => n
        .WithListenPort(80)              // HTTP internally
        .WithListenHttps(false)           // Traefik handles TLS
        .WithProxySetHeaders(new Dictionary<string, string?>
        {
            ["X-Forwarded-Proto"] = "https",
            ["X-Forwarded-Ssl"] = "on",
        }))
    .WithLetsencrypt(l => l
        .WithEnable(false))               // Traefik manages certs
    .Build();

new GitLabComposeContributor(
    omnibusConfig: config,
    hostname: "gitlab.lab",
    labels: new Dictionary<string, string?>
    {
        ["traefik.enable"] = "true",
        ["traefik.http.routers.gitlab.rule"] = "Host(`gitlab.lab`)",
        ["traefik.http.routers.gitlab.tls"] = "true",
        ["traefik.http.routers.gitlab.tls.certresolver"] = "letsencrypt",
        ["traefik.http.services.gitlab.loadbalancer.server.port"] = "80",
    }
).Contribute(composeFile);

GitLab speaks HTTP on port 80 internally. Traefik handles HTTPS, certificate renewal, and forwards traffic with the correct X-Forwarded-* headers. No double-TLS, no certificate management inside the container.

HomeLab Context

In the HomeLab orchestrator, GitLab.DockerCompose is one of several contributor packages. The orchestrator reads a configuration file and activates the relevant contributors:

var composeFile = new ComposeFile();

// Reverse proxy
new TraefikComposeContributor(/* ... */).Contribute(composeFile);

// GitLab stack
new GitLabComposeContributor(/* ... */).Contribute(composeFile);
new PostgresqlComposeContributor().Contribute(composeFile);
new RedisComposeContributor().Contribute(composeFile);
new GitLabRunnerComposeContributor().Contribute(composeFile);

// Other services from other packages
new NextcloudComposeContributor(/* ... */).Contribute(composeFile);
new GrafanaComposeContributor(/* ... */).Contribute(composeFile);

// Serialize to YAML
var yaml = composeFile.ToYaml();
File.WriteAllText("docker-compose.yml", yaml);

Each contributor is independent. Adding GitLab to the stack is a matter of calling five .Contribute() methods. Removing it is removing those calls. No copy-pasting YAML fragments, no merge conflicts, no version drift.

⬇ Download