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 38: DevLab Postgres and MinIO

"Two services. Six buckets. Half of DevLab's storage problem."


Why

Postgres and MinIO are the two most-used services in DevLab. Postgres is GitLab's database. MinIO is the object store backing five different consumers: GitLab artifacts, GitLab LFS, the Vagrant box registry, the NuGet feed, the container registry, and the backup framework. Both deserve their own part because both have configuration choices that matter — for performance (Postgres tuning) and for layout (MinIO buckets and lifecycle).

The thesis of this part is: Postgres ships with a tuning template generated from the VM's RAM, and MinIO ships with a fixed bucket layout, lifecycle policies, and credentials sourced from the secret store. Both are compose contributors. Both are tested.


Postgres: shape and tuning

[Injectable(ServiceLifetime.Singleton)]
public sealed class PostgresComposeContributor : IComposeFileContributor
{
    private readonly HomeLabConfig _config;

    public string TargetVm => "data";

    public void Contribute(ComposeFile compose)
    {
        var vm = _config.Vos.MachinesByRole["data"];
        var sharedBuffers   = $"{vm.Memory / 4} MB";              // 25%
        var effectiveCache  = $"{vm.Memory / 2} MB";              // 50%
        var workMem         = $"{vm.Memory / 64} MB";             // ~1.5%
        var maintenanceMem  = $"{vm.Memory / 16} MB";             // ~6%

        compose.Services["postgres"] = new ComposeService
        {
            Image = $"postgres:{_config.Postgres.Version ?? "16-alpine"}",
            Restart = "always",
            Hostname = "postgres",
            Environment = new()
            {
                ["POSTGRES_DB"]            = "gitlabhq_production",
                ["POSTGRES_USER"]          = "gitlab",
                ["POSTGRES_PASSWORD_FILE"] = "/run/secrets/postgres_password",
                ["POSTGRES_INITDB_ARGS"]   = "--encoding=UTF8 --locale=C",
                ["POSTGRES_SHARED_BUFFERS"]              = sharedBuffers,
                ["POSTGRES_EFFECTIVE_CACHE_SIZE"]        = effectiveCache,
                ["POSTGRES_WORK_MEM"]                    = workMem,
                ["POSTGRES_MAINTENANCE_WORK_MEM"]        = maintenanceMem,
            },
            Volumes = new()
            {
                "postgres_data:/var/lib/postgresql/data",
                "./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro",
                "./postgres/init:/docker-entrypoint-initdb.d:ro"
            },
            Networks = new() { "data-net", "platform" },
            Secrets = new() { "postgres_password" },
            HealthCheck = new ComposeHealthcheck
            {
                Test = new[] { "CMD-SHELL", "pg_isready -U gitlab -d gitlabhq_production" },
                Interval = "10s",
                Timeout = "5s",
                Retries = 5,
                StartPeriod = "30s"
            },
            Ports = new() { "5432:5432" },   // for backup tools running on the host
            Command = "postgres -c config_file=/etc/postgresql/postgresql.conf"
        };

        compose.Volumes["postgres_data"] ??= new ComposeVolume { Driver = "local" };
        compose.Networks["data-net"] ??= new ComposeNetwork { Driver = "bridge" };
        compose.Secrets["postgres_password"] ??= new ComposeSecret { File = "./secrets/postgres_password" };
    }
}

The tuning constants are computed from the VM's RAM. A 4 GB data VM gets shared_buffers = 1024 MB, an 8 GB data VM gets shared_buffers = 2048 MB. The values are not optimal for every workload (a real DBA would tune them for the specific query patterns), but they are sensible defaults that beat the postgres image's out-of-the-box config by a wide margin.

The init scripts under ./postgres/init/ are also generated. They:

  1. Create the gitlab role with the password from the secret
  2. Create the gitlabhq_production database
  3. Install the pg_trgm and btree_gist extensions GitLab requires
  4. Set up replication slots for HA topology (only when topology: ha)

MinIO: shape and buckets

[Injectable(ServiceLifetime.Singleton)]
public sealed class MinioComposeContributor : IComposeFileContributor
{
    public string TargetVm => "data";

    public void Contribute(ComposeFile compose)
    {
        compose.Services["minio"] = new ComposeService
        {
            Image = "minio/minio:RELEASE.2025-01-15T00-00-00Z",
            Restart = "always",
            Hostname = "minio",
            Environment = new()
            {
                ["MINIO_ROOT_USER_FILE"]     = "/run/secrets/minio_access_key",
                ["MINIO_ROOT_PASSWORD_FILE"] = "/run/secrets/minio_secret_key",
                ["MINIO_BROWSER_REDIRECT_URL"] = $"https://minio.{_config.Acme.Tld}",
                ["MINIO_SERVER_URL"]         = $"https://s3.{_config.Acme.Tld}",
            },
            Volumes = new() { "minio_data:/data" },
            Networks = new() { "data-net", "platform" },
            Secrets = new() { "minio_access_key", "minio_secret_key" },
            HealthCheck = new ComposeHealthcheck
            {
                Test = new[] { "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" },
                Interval = "30s",
                Timeout = "5s",
                Retries = 3
            },
            Command = "server /data --console-address \":9001\""
        };

        compose.Services["minio-init"] = new ComposeService
        {
            // One-shot job that creates the buckets and applies lifecycle policies
            Image = "minio/mc:latest",
            Restart = "no",
            Networks = new() { "data-net" },
            Secrets = new() { "minio_access_key", "minio_secret_key" },
            DependsOn = new() { ["minio"] = new() { Condition = "service_healthy" } },
            Volumes = new() { "./minio/init.sh:/init.sh:ro" },
            Entrypoint = new() { "/bin/sh", "/init.sh" }
        };

        compose.Volumes["minio_data"] ??= new ComposeVolume { Driver = "local" };
        compose.Secrets["minio_access_key"] ??= new ComposeSecret { File = "./secrets/minio_access_key" };
        compose.Secrets["minio_secret_key"] ??= new ComposeSecret { File = "./secrets/minio_secret_key" };
    }
}

The minio-init sidecar is a one-shot container that runs after MinIO is healthy. It creates the buckets and applies the lifecycle policies. The init script is generated from typed config:

public static class MinioInitScriptGenerator
{
    public static string Generate(MinioBucketLayout layout) => $$"""
        #!/bin/sh
        set -eux

        # Wait for MinIO to be reachable
        until mc alias set local http://minio:9000 "$(cat /run/secrets/minio_access_key)" "$(cat /run/secrets/minio_secret_key)"; do
            sleep 1
        done

        # Create buckets idempotently
        {{string.Join("\n        ", layout.Buckets.Select(b => $"mc mb --ignore-existing local/{b.Name}"))}}

        # Apply lifecycle policies
        {{string.Join("\n        ", layout.Buckets
            .Where(b => b.LifecycleDays.HasValue)
            .Select(b => $"mc ilm rule add --expire-days {b.LifecycleDays} local/{b.Name}"))}}

        # Apply public-read where requested
        {{string.Join("\n        ", layout.Buckets
            .Where(b => b.PublicRead)
            .Select(b => $"mc anonymous set download local/{b.Name}"))}}

        echo 'minio init complete'
        """;
}

The bucket layout is also typed:

public sealed record MinioBucketLayout
{
    public IReadOnlyList<MinioBucket> Buckets { get; init; } = new[]
    {
        new MinioBucket("gitlab-artifacts", LifecycleDays: 30),
        new MinioBucket("gitlab-lfs",       LifecycleDays: null),
        new MinioBucket("vagrant-boxes",    LifecycleDays: null),
        new MinioBucket("nuget-packages",   LifecycleDays: null),
        new MinioBucket("registry",         LifecycleDays: null),
        new MinioBucket("backups",          LifecycleDays: 90),
    };
}

public sealed record MinioBucket(string Name, int? LifecycleDays = null, bool PublicRead = false);

Six buckets. Two with lifecycle (artifacts expire after 30 days, backups after 90). The rest persist forever. Adding a new bucket is one entry in the list — no shell script edit, no manual MinIO web UI click, no drift.


The wiring

Both contributors are picked up by the per-VM compose generator from Part 31 and run on the data VM in multi-VM topology, or on the only VM in single-VM topology. The init script is rendered into ./postgres/init/01-create-gitlab-db.sql and ./minio/init.sh by the bundle writer.

GitLab consumes both: its gitlab.rb (generated by Part 24) configures the database connection to point at postgres:5432 and the artifact storage to point at https://minio.frenchexdev.lab. The hostnames resolve via PiHole (in multi-VM) or via the host network (in single-VM).


The test

public sealed class PostgresMinioContributorTests
{
    [Fact]
    public void postgres_contributor_computes_shared_buffers_from_data_vm_memory()
    {
        var compose = new ComposeFile();
        var config = StandardConfig() with
        {
            Vos = new() { MachinesByRole = new() { ["data"] = new() { Memory = 4096 } } }
        };
        new PostgresComposeContributor(Options.Create(config)).Contribute(compose);

        compose.Services["postgres"].Environment["POSTGRES_SHARED_BUFFERS"].Should().Be("1024 MB");
    }

    [Fact]
    public void minio_contributor_creates_six_default_buckets_via_init_sidecar()
    {
        var compose = new ComposeFile();
        new MinioComposeContributor(/* ... */).Contribute(compose);

        compose.Services.Should().ContainKey("minio");
        compose.Services.Should().ContainKey("minio-init");
        compose.Services["minio-init"].DependsOn["minio"].Condition.Should().Be("service_healthy");
    }

    [Fact]
    public void minio_init_script_creates_all_buckets()
    {
        var script = MinioInitScriptGenerator.Generate(new MinioBucketLayout());

        script.Should().Contain("mc mb --ignore-existing local/gitlab-artifacts");
        script.Should().Contain("mc mb --ignore-existing local/gitlab-lfs");
        script.Should().Contain("mc mb --ignore-existing local/vagrant-boxes");
        script.Should().Contain("mc mb --ignore-existing local/nuget-packages");
        script.Should().Contain("mc mb --ignore-existing local/registry");
        script.Should().Contain("mc mb --ignore-existing local/backups");
        script.Should().Contain("mc ilm rule add --expire-days 30 local/gitlab-artifacts");
        script.Should().Contain("mc ilm rule add --expire-days 90 local/backups");
    }
}

What this gives you that bash doesn't

A bash script that sets up Postgres + MinIO is two docker run commands followed by a sequence of psql and mc calls that create the database, the user, the buckets, and the lifecycle rules. Every line is fragile. Every parameter is interpolated. The first time you change a bucket name, you have to find every reference.

Typed Postgres and MinIO contributors give you, for the same surface area:

  • Tuning computed from VM RAM (no hand-tuned constants)
  • Six buckets declared once in a typed list, generated into the init script
  • Lifecycle policies declared per bucket
  • Secrets sourced from ISecretStore at runtime
  • Healthchecks that gate dependent services via depends_on
  • Tests that lock the bucket list and the tuning math

The bargain pays back the first time you change Postgres major versions and only have to change one config field.


⬇ Download