← .NET / C#
Programming / .NET / C#Learning

Redis, MinIO, Docker — the local dev stack I'm learning

How I've actually wired Redis and MinIO into EventFlow, plus the Compose setup running them locally — solid on the integration code, still shaky on Docker itself.

dotnetredisdockerminiodocker-compose

Why these three together

These three keep showing up together in any real backend project. Not because they solve the same problem — they don't — but because a typical stack needs all three doing different jobs at once: Redis for fast ephemeral state, MinIO for file storage, Docker Compose to run everything locally as one unit. I'm more confident in the Redis and MinIO integration code than I am in Docker itself, and this note is honest about that gap rather than pretending one confidence level across all three.

Redis

What it is: an in-memory key-value store. Data lives in RAM, so reads/writes are extremely fast compared to hitting a real database.

Why it matters: caching to avoid re-querying the database for the same data repeatedly, and sharing state across multiple app instances.

Small flow: app checks Redis first for a key → if found (cache hit), return it → if not (cache miss), fetch from the real database, then store it in Redis with an expiry so it doesn't go stale forever.

Real example — how I wired this into EventFlow

Registering the connection — fail fast if config is missing, but don't let a slow/unavailable Redis block app startup:

var redis_connection_string =
    config["connection_strings:redis"]
    ?? config.GetConnectionString("Redis")
    ?? throw new InvalidOperationException("Redis connection string is missing.");
 
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
    var redis_options = ConfigurationOptions.Parse(redis_connection_string);
    redis_options.AbortOnConnectFail = false;
    redis_options.ConnectRetry = 0;
    redis_options.ConnectTimeout = 1000;
    redis_options.AsyncTimeout = 1000;
    redis_options.SyncTimeout = 1000;
    return ConnectionMultiplexer.Connect(redis_options);
});
 
services.AddSingleton<RedisAvailabilityGate>();
services.AddSingleton<IUserProfileCache, RedisUserProfileCache>();

The cache itself never lets a Redis failure become a user-facing error — every operation is wrapped, and on failure it disables Redis for the rest of the process instead of retrying endlessly:

public async Task<MyProfileData?> GetAsync(Guid user_id, CancellationToken cancellationToken = default)
{
    if (!_availability_gate.IsEnabled) return null;
 
    try
    {
        var cached_value = await _database.StringGetAsync(BuildKey(user_id));
        if (cached_value.IsNullOrEmpty) return null;
 
        var profile_data = JsonSerializer.Deserialize<MyProfileData>(cached_value.ToString());
        if (profile_data == null)
        {
            _logger.LogWarning("Corrupted cache entry detected for user {UserId}. Evicting.", user_id);
            await _database.KeyDeleteAsync(BuildKey(user_id));
        }
        return profile_data;
    }
    catch (Exception ex)
    {
        _availability_gate.DisableUntilRestart($"profile cache read for user {user_id}", ex);
        return null;
    }
}

Why this matters: cache is an optimization, not a dependency the app should die over. If Redis goes down, EventFlow should keep serving requests — just slower, hitting the real database instead. Fail open, not closed, for a cache.

Still fuzzy for me: cache invalidation — what happens when the underlying data changes before the 5-minute TTL window is up. Right now I'm relying purely on expiry, not active invalidation on write.

MinIO

What it is: self-hosted storage that mimics Amazon S3's API. I run it locally so file uploads work the same way locally as they will against real S3/cloud storage later.

Why it matters: no need for real cloud credentials just to test upload/download locally, and the code doesn't change when eventually pointing at real S3.

Small flow: app uploads a file to a MinIO "bucket" via its API → MinIO stores it → app can generate a temporary "presigned URL" so a client can download the file directly, without the app proxying it.

Real example — bucket bootstrap in EventFlow

The actual bug I hit: a bucket's public-read policy can be lost independently of the bucket itself (e.g. after a MinIO restart). My first version only set the policy when creating the bucket — so a pre-existing bucket that lost its policy stayed broken. Fix: always reconcile the policy, decoupled from creation:

private async Task EnsureBucketReadyAsync(CancellationToken ct)
{
    if (_bucket_ready) return;
    await _init_lock.WaitAsync(ct);
    try
    {
        if (_bucket_ready) return;
 
        bool exists = await _minio.BucketExistsAsync(
            new BucketExistsArgs().WithBucket(_settings.bucket), ct);
 
        if (!exists)
        {
            await _minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(_settings.bucket), ct);
        }
 
        // Always reconcile — not just on first creation
        await ReconcilePublicReadPolicyAsync(ct);
        _bucket_ready = true;
    }
    finally { _init_lock.Release(); }
}

Client registration:

services.AddSingleton<IMinioClient>(sp =>
{
    var settings = sp.GetRequiredService<IOptions<AppSettings>>().Value.minio;
    return new MinioClient()
        .WithEndpoint(settings.endpoint)
        .WithCredentials(settings.access_key, settings.secret_key)
        .WithSSL(settings.use_ssl)
        .Build();
});
services.AddScoped<IStorageService, MinioStorageService>();

What I learned: "does the bucket exist" and "is the policy correct" are two separate questions — treating them as one (only checking on creation) is the actual bug I hit.

Still fuzzy for me: presigned URLs — haven't used these yet, need to understand the expiry mechanics and why they're safer than a permanent public link.

Docker (and Docker Compose)

What it is: a way to package an app plus everything it needs to run into one container, so it runs the same way on my machine as anywhere else. Docker Compose defines multiple containers — my API, a database, Redis, MinIO — as one set of services that start together.

Why it matters: one command spins up the whole stack instead of manually installing Postgres, Redis, and MinIO locally.

Small flow: docker compose up reads a compose.yaml, pulls or builds each service's image, starts them on a shared network so they can talk to each other by service name, and wires up any ports/volumes defined.

Real example — EventFlow's compose file

services:
  api:
    build:
      context: ..
      dockerfile: EventFlow.API/Dockerfile
    env_file:
        - path: .env
    ports:
        - "5000:8080"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8080/health/ready || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_started
      minio:
        condition: service_healthy
 
  database:
    image: postgres:16-alpine
    env_file:
        - path: .env
    ports:
        - "5432:5432"
    volumes:
        - EventFlow_pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d ef2 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: [ "CMD", "redis-cli", "ping" ]
      interval: 10s
      timeout: 5s
      retries: 3
 
  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    environment:
        MINIO_ROOT_USER: admin
        MINIO_ROOT_PASSWORD: password123
    ports:
      - "9000:9000"
    volumes:
      - EventFlow_minIO_data:/data
    healthcheck:
        test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"]
        interval: 10s
        timeout: 5s
        retries: 3
        start_period: 15s
 
volumes:
  EventFlow_pg_data:
  EventFlow_minIO_data:

What's worth noticing: depends_on with condition: service_healthy waits for the healthcheck to pass, not just for the container to start — that's why database and minio use service_healthy but redis only needs service_started. Getting this distinction right is what actually prevents the API from booting before Postgres is ready to accept connections.

Bugs I actually hit getting here:

Still fuzzy for me: this is the shakiest of the three. I don't yet have a clear picture of what actually differs between this local Compose setup and a real production deployment — that's a genuinely open question, not something I've answered.

Packages used

| Tool | Package | | -------- | ------------------- | | Redis | StackExchange.Redis | | MinIO | Minio |

What's next