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.
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:
- A healthcheck's
localhostruns inside the container's own network namespace — it checks the container against itself, not against the host. Looks correct, silently isn't. env_file: path: /.env(absolute) silently pointed at the wrong location — needed to be the relative.envinstead.- Build context had to be the solution root (
context: ..), not the project subfolder, since the Dockerfile references files outside its own folder.
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
- Redis: active cache invalidation on write, not just TTL expiry
- MinIO: presigned URLs — expiry mechanics, why they're safer than permanent public links
- Docker: what actually changes between this local setup and production — biggest open gap of the three right now