Part VI: Rendering -- From C# Back to Ruby
The builder produces a C# object graph. Docker needs a Ruby string. The renderer bridges that gap: it walks the object tree, converts each property back to the correct Ruby syntax, and produces the exact
GITLAB_OMNIBUS_CONFIGvalue that the GitLab container expects.
The Renderer
GitLabRbRenderer is a static class that converts a GitLabOmnibusConfig into a Ruby string. It handles three categories of output:
- Standalone URLs --
external_url 'https://...'(single-quoted, no prefix) - Per-prefix settings --
prefix['key'] = value(bracket notation) - Roles --
roles ['role1', 'role2'](standalone list)
public static class GitLabRbRenderer
{
public static string Render(GitLabOmnibusConfig config)
{
var sb = new StringBuilder();
// 1. Standalone URLs (single-quoted)
RenderStandaloneUrls(sb, config);
// 2. Per-prefix sections
foreach (var prop in typeof(GitLabOmnibusConfig)
.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (StandaloneUrlMap.ContainsKey(prop.Name)) continue;
if (prop.Name == "Roles") continue;
var value = prop.GetValue(config);
if (value is null) continue;
if (!PrefixMap.TryGetValue(prop.Name, out var prefix))
continue;
sb.AppendLine();
RenderSection(sb, prefix, value, new List<string>());
}
// 3. Roles (special standalone list)
if (config.Roles is not null)
{
sb.AppendLine();
sb.AppendLine($"roles {config.Roles}");
}
return sb.ToString();
}
}public static class GitLabRbRenderer
{
public static string Render(GitLabOmnibusConfig config)
{
var sb = new StringBuilder();
// 1. Standalone URLs (single-quoted)
RenderStandaloneUrls(sb, config);
// 2. Per-prefix sections
foreach (var prop in typeof(GitLabOmnibusConfig)
.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (StandaloneUrlMap.ContainsKey(prop.Name)) continue;
if (prop.Name == "Roles") continue;
var value = prop.GetValue(config);
if (value is null) continue;
if (!PrefixMap.TryGetValue(prop.Name, out var prefix))
continue;
sb.AppendLine();
RenderSection(sb, prefix, value, new List<string>());
}
// 3. Roles (special standalone list)
if (config.Roles is not null)
{
sb.AppendLine();
sb.AppendLine($"roles {config.Roles}");
}
return sb.ToString();
}
}Only non-null properties are rendered. If you don't set Nginx.ListenPort, it doesn't appear in the output -- GitLab uses its default. This is the core design principle: explicit configuration only.
Standalone URLs
GitLab has a handful of settings that use a special syntax -- no bracket notation, just a function call with a quoted string:
external_url 'https://gitlab.example.com'
registry_external_url 'https://registry.example.com'
pages_external_url 'https://pages.example.com'
mattermost_external_url 'https://mattermost.example.com'
gitlab_kas_external_url 'wss://kas.example.com'external_url 'https://gitlab.example.com'
registry_external_url 'https://registry.example.com'
pages_external_url 'https://pages.example.com'
mattermost_external_url 'https://mattermost.example.com'
gitlab_kas_external_url 'wss://kas.example.com'The renderer maps C# property names to Ruby function names:
private static readonly Dictionary<string, (string RubyKey, bool IsStandalone)>
StandaloneUrlMap = new()
{
["ExternalUrl"] = ("external_url", true),
["RegistryExternalUrl"] = ("registry_external_url", true),
["PagesExternalUrl"] = ("pages_external_url", true),
["MattermostExternalUrl"] = ("mattermost_external_url", true),
["GitlabKasExternalUrl"] = ("gitlab_kas_external_url", true),
["RuntimeDir"] = ("runtime_dir", true),
};
private static void RenderStandaloneUrls(StringBuilder sb, GitLabOmnibusConfig config)
{
foreach (var kvp in StandaloneUrlMap)
{
var prop = typeof(GitLabOmnibusConfig).GetProperty(kvp.Key);
var value = prop?.GetValue(config) as string;
if (value is null) continue;
sb.AppendLine($"{kvp.Value.RubyKey} '{value}'");
}
}private static readonly Dictionary<string, (string RubyKey, bool IsStandalone)>
StandaloneUrlMap = new()
{
["ExternalUrl"] = ("external_url", true),
["RegistryExternalUrl"] = ("registry_external_url", true),
["PagesExternalUrl"] = ("pages_external_url", true),
["MattermostExternalUrl"] = ("mattermost_external_url", true),
["GitlabKasExternalUrl"] = ("gitlab_kas_external_url", true),
["RuntimeDir"] = ("runtime_dir", true),
};
private static void RenderStandaloneUrls(StringBuilder sb, GitLabOmnibusConfig config)
{
foreach (var kvp in StandaloneUrlMap)
{
var prop = typeof(GitLabOmnibusConfig).GetProperty(kvp.Key);
var value = prop?.GetValue(config) as string;
if (value is null) continue;
sb.AppendLine($"{kvp.Value.RubyKey} '{value}'");
}
}Standalone URLs are rendered first, before any prefix sections. They use single quotes (Ruby convention for strings without interpolation).
Prefix Resolution
Each C# property on GitLabOmnibusConfig maps to a Ruby prefix. The mapping is built from property type names with manual overrides for edge cases:
private static Dictionary<string, string> BuildPrefixMap()
{
var map = new Dictionary<string, string>();
foreach (var prop in typeof(GitLabOmnibusConfig).GetProperties())
{
var typeName = (Nullable.GetUnderlyingType(prop.PropertyType)
?? prop.PropertyType).Name;
if (!typeName.EndsWith("Config")) continue;
// "NginxConfig" → "nginx", "GitLabRailsConfig" → "gitlab_rails"
var prefix = ToSnakeCase(typeName.Replace("Config", ""));
map[prop.Name] = prefix;
}
// Fix known special cases
map["GitlabRails"] = "gitlab_rails";
map["GitlabWorkhorse"] = "gitlab_workhorse";
map["GitlabShell"] = "gitlab_shell";
map["PagesNginx"] = "pages_nginx";
map["RegistryNginx"] = "registry_nginx";
map["OmnibusGitconfig"] = "omnibus_gitconfig";
// ... 20+ more overrides
return map;
}private static Dictionary<string, string> BuildPrefixMap()
{
var map = new Dictionary<string, string>();
foreach (var prop in typeof(GitLabOmnibusConfig).GetProperties())
{
var typeName = (Nullable.GetUnderlyingType(prop.PropertyType)
?? prop.PropertyType).Name;
if (!typeName.EndsWith("Config")) continue;
// "NginxConfig" → "nginx", "GitLabRailsConfig" → "gitlab_rails"
var prefix = ToSnakeCase(typeName.Replace("Config", ""));
map[prop.Name] = prefix;
}
// Fix known special cases
map["GitlabRails"] = "gitlab_rails";
map["GitlabWorkhorse"] = "gitlab_workhorse";
map["GitlabShell"] = "gitlab_shell";
map["PagesNginx"] = "pages_nginx";
map["RegistryNginx"] = "registry_nginx";
map["OmnibusGitconfig"] = "omnibus_gitconfig";
// ... 20+ more overrides
return map;
}The overrides are necessary because ToSnakeCase("GitLabRails") produces git_lab_rails, not gitlab_rails. The 30+ special cases in the prefix map ensure the rendered Ruby exactly matches what GitLab expects.
Rendering Per-Prefix Sections
For each non-null sub-config object, the renderer walks the property tree and emits prefix['key'] = value lines:
private static void RenderSection(StringBuilder sb, string prefix,
object section, List<string> keyPath)
{
foreach (var prop in section.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var value = prop.GetValue(section);
if (value is null) continue;
var rubyKey = ToSnakeCase(prop.Name);
var currentPath = new List<string>(keyPath) { rubyKey };
// Sub-object → recurse
if (IsConfigClass(prop.PropertyType))
{
RenderSection(sb, prefix, value, currentPath);
continue;
}
// Leaf value → render
var bracketPath = string.Join("",
currentPath.Select(k => $"['{k}']"));
var rubyValue = FormatRubyValue(value);
sb.AppendLine($"{prefix}{bracketPath} = {rubyValue}");
}
}private static void RenderSection(StringBuilder sb, string prefix,
object section, List<string> keyPath)
{
foreach (var prop in section.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var value = prop.GetValue(section);
if (value is null) continue;
var rubyKey = ToSnakeCase(prop.Name);
var currentPath = new List<string>(keyPath) { rubyKey };
// Sub-object → recurse
if (IsConfigClass(prop.PropertyType))
{
RenderSection(sb, prefix, value, currentPath);
continue;
}
// Leaf value → render
var bracketPath = string.Join("",
currentPath.Select(k => $"['{k}']"));
var rubyValue = FormatRubyValue(value);
sb.AppendLine($"{prefix}{bracketPath} = {rubyValue}");
}
}Nested keys produce multiple brackets: a property at path ["object_store", "connection", "provider"] under gitlab_rails renders as:
gitlab_rails['object_store']['connection']['provider'] = "AWS"gitlab_rails['object_store']['connection']['provider'] = "AWS"Type Mapping
The FormatRubyValue method converts C# values to Ruby syntax:
private static string FormatRubyValue(object value)
{
return value switch
{
bool b => b ? "true" : "false",
int i => i.ToString(),
long l => l.ToString(),
double d => d.ToString(CultureInfo.InvariantCulture),
string s => $"\"{EscapeRubyString(s)}\"",
List<string> list => FormatStringList(list),
List<double> list => FormatDoubleList(list),
Dictionary<string, string?> dict => FormatStringDict(dict),
_ => $"\"{value}\""
};
}private static string FormatRubyValue(object value)
{
return value switch
{
bool b => b ? "true" : "false",
int i => i.ToString(),
long l => l.ToString(),
double d => d.ToString(CultureInfo.InvariantCulture),
string s => $"\"{EscapeRubyString(s)}\"",
List<string> list => FormatStringList(list),
List<double> list => FormatDoubleList(list),
Dictionary<string, string?> dict => FormatStringDict(dict),
_ => $"\"{value}\""
};
}| C# Type | C# Value | Ruby Output |
|---|---|---|
bool |
true |
true |
bool |
false |
false |
int |
80 |
80 |
long |
5368709120 |
5368709120 |
double |
0.75 |
0.75 |
string |
"smtp.example.com" |
"smtp.example.com" |
null |
(any) | (not rendered) |
List<string> |
["a", "b"] |
['a', 'b'] |
Dictionary<string, string?> |
{{"k", "v"}} |
{ "k" => "v" } |
Key details:
- Booleans are lowercase. Ruby
true/false, not C#True/False. - Strings are double-quoted with backslash escaping for
\and". - Lists use single quotes for string items (Ruby convention).
- Dictionaries use hash-rocket syntax (
"key" => "value"). Small dictionaries (3 or fewer entries) render on one line; larger ones get multi-line formatting.
Dictionary Formatting
private static string FormatStringDict(Dictionary<string, string?> dict)
{
if (dict.Count == 0) return "{}";
var entries = dict.Select(kvp =>
$"\"{EscapeRubyString(kvp.Key)}\" => " +
$"\"{EscapeRubyString(kvp.Value ?? "")}\"");
if (dict.Count <= 3)
return "{ " + string.Join(", ", entries) + " }";
// Multi-line for readability
var sb = new StringBuilder();
sb.AppendLine("{");
foreach (var entry in entries)
sb.AppendLine($" {entry},");
sb.Append("}");
return sb.ToString();
}private static string FormatStringDict(Dictionary<string, string?> dict)
{
if (dict.Count == 0) return "{}";
var entries = dict.Select(kvp =>
$"\"{EscapeRubyString(kvp.Key)}\" => " +
$"\"{EscapeRubyString(kvp.Value ?? "")}\"");
if (dict.Count <= 3)
return "{ " + string.Join(", ", entries) + " }";
// Multi-line for readability
var sb = new StringBuilder();
sb.AppendLine("{");
foreach (var entry in entries)
sb.AppendLine($" {entry},");
sb.Append("}");
return sb.ToString();
}Builder Input
var config = new GitLabOmnibusConfigBuilder()
.WithExternalUrl("https://gitlab.example.com")
.WithNginx(n => n
.WithListenPort(80)
.WithListenHttps(false)
.WithProxySetHeaders(new Dictionary<string, string?>
{
["X-Forwarded-Proto"] = "https",
["X-Forwarded-Ssl"] = "on",
}))
.WithGitlabRails(r => r
.WithSmtpEnable(true)
.WithSmtpAddress("smtp.example.com")
.WithSmtpPort(587))
.WithRedis(r => r
.WithEnable(false))
.WithLetsencrypt(l => l
.WithEnable(false))
.Build();
var ruby = GitLabRbRenderer.Render(config);var config = new GitLabOmnibusConfigBuilder()
.WithExternalUrl("https://gitlab.example.com")
.WithNginx(n => n
.WithListenPort(80)
.WithListenHttps(false)
.WithProxySetHeaders(new Dictionary<string, string?>
{
["X-Forwarded-Proto"] = "https",
["X-Forwarded-Ssl"] = "on",
}))
.WithGitlabRails(r => r
.WithSmtpEnable(true)
.WithSmtpAddress("smtp.example.com")
.WithSmtpPort(587))
.WithRedis(r => r
.WithEnable(false))
.WithLetsencrypt(l => l
.WithEnable(false))
.Build();
var ruby = GitLabRbRenderer.Render(config);Rendered Ruby Output
external_url 'https://gitlab.example.com'
nginx['listen_port'] = 80
nginx['listen_https'] = false
nginx['proxy_set_headers'] = { "X-Forwarded-Proto" => "https", "X-Forwarded-Ssl" => "on" }
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.example.com"
gitlab_rails['smtp_port'] = 587
redis['enable'] = false
letsencrypt['enable'] = falseexternal_url 'https://gitlab.example.com'
nginx['listen_port'] = 80
nginx['listen_https'] = false
nginx['proxy_set_headers'] = { "X-Forwarded-Proto" => "https", "X-Forwarded-Ssl" => "on" }
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.example.com"
gitlab_rails['smtp_port'] = 587
redis['enable'] = false
letsencrypt['enable'] = falseThis is the exact string that gets injected into the GITLAB_OMNIBUS_CONFIG environment variable. Every line is syntactically valid Ruby. Every value is correctly typed and escaped.
PascalCase to snake_case
The reverse naming conversion handles the edge cases of consecutive uppercase letters:
private static string ToSnakeCase(string pascalCase)
{
var sb = new StringBuilder();
for (int i = 0; i < pascalCase.Length; i++)
{
var c = pascalCase[i];
if (char.IsUpper(c) && i > 0)
{
// Don't insert underscore between consecutive uppercase
// (e.g., "DB" → "db", not "d_b")
if (!char.IsUpper(pascalCase[i - 1]) ||
(i + 1 < pascalCase.Length && char.IsLower(pascalCase[i + 1])))
sb.Append('_');
}
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}private static string ToSnakeCase(string pascalCase)
{
var sb = new StringBuilder();
for (int i = 0; i < pascalCase.Length; i++)
{
var c = pascalCase[i];
if (char.IsUpper(c) && i > 0)
{
// Don't insert underscore between consecutive uppercase
// (e.g., "DB" → "db", not "d_b")
if (!char.IsUpper(pascalCase[i - 1]) ||
(i + 1 < pascalCase.Length && char.IsLower(pascalCase[i + 1])))
sb.Append('_');
}
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}Examples:
ListenPort→listen_portSmtpEnableStarttlsAuto→smtp_enable_starttls_autoDbHost→db_hostProxySetHeaders→proxy_set_headers
The Bridge to Docker Compose
The rendered Ruby string flows into the GitLabComposeContributor, which injects it as an environment variable:
// Inside GitLabComposeContributor.Contribute()
var env = new Dictionary<string, string?>();
if (_omnibusConfig is not null)
env["GITLAB_OMNIBUS_CONFIG"] = GitLabRbRenderer.Render(_omnibusConfig);// Inside GitLabComposeContributor.Contribute()
var env = new Dictionary<string, string?>();
if (_omnibusConfig is not null)
env["GITLAB_OMNIBUS_CONFIG"] = GitLabRbRenderer.Render(_omnibusConfig);The contributor adds this to the service definition, which eventually serializes into docker-compose.yml:
services:
gitlab:
image: gitlab/gitlab-ce:latest
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://gitlab.example.com'
nginx['listen_port'] = 80
nginx['listen_https'] = false
...services:
gitlab:
image: gitlab/gitlab-ce:latest
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://gitlab.example.com'
nginx['listen_port'] = 80
nginx['listen_https'] = false
...The full contributor implementation is covered in the next part.