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);
}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";
}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;
}
}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). Thegitlab-configvolume containsgitlab-secrets.jsonwhich 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_periodprevents 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 withimage: 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;
}
}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.WithGitlabRails(r => r
.WithDbHost("postgresql")
.WithDbPassword("${GITLAB_DB_PASSWORD}")
.WithDbDatabase("gitlabhq_production")
.WithDbUsername("gitlab"))
.WithPostgresql(p => p
.WithEnable(false)) // Disable bundled PostgreSQL3. 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;
}
}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}")).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;
}
}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.tomlin therunner-configvolume. 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;
}
}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)))).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);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
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);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);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.