MinIO: Dein eigener S3 auf deinem eigenen Server
- infrastructure
- self-hosted
- storage
- devops
Du brauchst kein AWS für S3-kompatiblen Objektspeicher. Ein Container, dieselbe API, keine Egress-Gebühren und ein sauberer Weg in die Cloud, wenn du sie wirklich brauchst.
Jedes Projekt sammelt über die Zeit Mediendateien an. NearYou speichert Standort-Thumbnails, Nutzer-Avatare und Event-Banner. Der Reflex ist, nach S3 zu greifen, die Anfragen-Steuer zu zahlen und zu akzeptieren, dass jedes Bild von einer anderen Origin ein CORS-Preflight und ein Latenzbudget kostet. Für die frühe Phase eines Produkts gibt es eine bessere Voreinstellung: MinIO, betrieben im selben Docker-Compose-Stack wie deine Anwendung, das nativ die S3-API spricht.
Die S3-API ist kein Eigentum von Amazon. Sie ist ein de-facto-Standard. Das Paket @aws-sdk/client-s3 kümmert sich nicht darum, ob der Endpunkt s3.amazonaws.com oder localhost:9000 ist. Du zeigst es auf MinIO und dieselben Aufrufe von PutObjectCommand und GetObjectCommand funktionieren identisch. Wenn NearYou irgendwann zu einem verwalteten Bucket wechselt, ist die Migration eine einzige Umgebungsvariable.

Ein Container im Stack
Das Compose-Snippet ist überschaubar. MinIO veröffentlicht zwei Ports: 9000 für die S3-API und 9001 für die Browser-Konsole. Die Konsole ist während der Entwicklung nützlich. Du bindest ein lokales Volume ein, damit die Daten Container-Neustarts überleben.
services:
minio:
image: quay.io/minio/minio:RELEASE.2025-04-08T15-41-24Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 3
api:
build: .
environment:
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: nearyou-media
depends_on:
minio:
condition: service_healthy
volumes:
minio_data:Innerhalb des api-Containers ist MinIO unter http://minio:9000 erreichbar, weil beide Services das Standard-Compose-Netzwerk teilen. Für interne Schreibvorgänge ist keine öffentliche Exposition notwendig. Der Bucket-Name ist lediglich ein String, den du einmal über die Konsole oder die MinIO-Client-CLI erstellst (mc mb myminio/nearyou-media).
Hochladen aus TypeScript
Der Upload-Pfad in NearYou ist ein dünner Wrapper um das AWS SDK v3. Die zentrale Erkenntnis ist forcePathStyle: true. AWS verwendet virtual-hosted-style-URLs (bucket.host/key). MinIO in einer lokalen Umgebung nutzt path-style (host/bucket/key). Ohne dieses Flag erhältst du DNS-Auflösungsfehler, die nicht sofort offensichtlich sind.
import {S3Client, PutObjectCommand, GetObjectCommand} from '@aws-sdk/client-s3';
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT, // http://minio:9000 locally
region: 'us-east-1', // MinIO ignores this; SDK requires it
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // required for MinIO
});
const BUCKET = process.env.S3_BUCKET ?? 'nearyou-media';
export async function uploadMedia(
key: string,
body: Buffer | Uint8Array,
contentType: string,
): Promise<void> {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
}),
);
}
export async function getPresignedReadUrl(key: string, expiresIn = 3600): Promise<string> {
return getSignedUrl(
s3,
new GetObjectCommand({Bucket: BUCKET, Key: key}),
{expiresIn},
);
}Das Feld region ist eine Eigenheit. MinIO erzwingt keine Regionen, aber der AWS-SDK-Client verweigert die Signierung von Anfragen ohne eine. us-east-1 ist konventionell und harmlos. Wenn du denselben Code später auf einen echten AWS-Bucket richtest, muss die Region mit der tatsächlichen Bucket-Region übereinstimmen.
Öffentliche Lesezugriffe über /api/media/* proxyen
Dateien direkt von http://minio:9000 bereitzustellen bedeutet, dass jede Client-Anfrage die Domain deiner Anwendung verlässt. Du verlierst: Same-Origin-Caching, Cache-Control-Header unter deiner Kontrolle, Zugriffsüberprüfungen vor der Auslieferung und die Möglichkeit, Keys umzuschreiben ohne URLs zu ändern. Die Proxy-Route kostet einen zusätzlichen Hop innerhalb deines Servers, gewinnt aber all das zurück.
In einem Next.js-Route-Handler ist das Muster unkompliziert. Der Handler holt von MinIO über das interne Netzwerk, streamt die Antwort an den Client und leitet den Content-Type weiter. Du kannst den Zugriff mit deiner normalen Auth-Middleware sperren, bevor der Fetch überhaupt ausgelöst wird.
// src/app/api/media/[...key]/route.ts
import {NextRequest, NextResponse} from 'next/server';
import {GetObjectCommand} from '@aws-sdk/client-s3';
import {s3} from '@/lib/s3'; // the client from the snippet above
import {Readable} from 'node:stream';
const BUCKET = process.env.S3_BUCKET ?? 'nearyou-media';
export async function GET(
_req: NextRequest,
{params}: {params: {key: string[]}},
) {
const key = params.key.join('/');
let object;
try {
object = await s3.send(new GetObjectCommand({Bucket: BUCKET, Key: key}));
} catch (err: unknown) {
if ((err as {name?: string}).name === 'NoSuchKey') {
return new NextResponse('Not found', {status: 404});
}
throw err;
}
const contentType = object.ContentType ?? 'application/octet-stream';
const stream = object.Body as Readable;
return new NextResponse(stream as unknown as ReadableStream, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}Der Header Cache-Control: immutable bedeutet, dass Browser und CDNs ein Asset nach dem Cachen nicht erneut anfordern. Das funktioniert nur, wenn deine Keys content-addressed sind (also einen Hash oder ein Versionssegment enthalten). Für NearYou-Media folgen die Keys dem Muster events/{eventId}/{hash}.webp. Die eventId ist stabil, der Hash ändert sich, wenn sich das Bild ändert.
Kein Egress-Zähler
Der Grund, warum Self-hosted-Storage in der frühen Phase eines Produkts sinnvoll ist, liegt nicht an der Latenz oder der Kontrolle, obwohl beides hilft. Es ist die Kostentransparenz. AWS S3 berechnet pro Anfrage und pro übertragenem GB. Wenn man schnell iteriert, Bildformate hinzufügt, on-the-fly skaliert und Vorschauen in Dashboards bereitstellt, steigt die Anfragenanzahl, bevor man es merkt. MinIO auf einem Server, für den du ohnehin zahlst, addiert nichts zu diesem Posten.
NearYou läuft auf einem dedizierten Hetzner-Server. Die monatlichen Kosten für diese Maschine sind fix. Jedes Bild, das über die Proxy-Route bereitgestellt wird, ist interne Bandbreite. Die einzige externe Bandbreite ist das, was der Hetzner-Uplink zum Endnutzer trägt, was separat aber großzügig gemessen wird. Für ein Produkt im Bereich von Hunderten von Nutzern ist Self-hosted-Storage der richtige Standard.
Du brauchst keinen egress-gemessenen Speicher, bis deine Speicherrechnung einen nennenswerten Anteil deines Umsatzes ausmacht. Bis dahin gibt dir die S3-API-Kompatibilität von MinIO den Migrationspfad ohne die Kosten.
Migrationspfad zu einem Cloud-Bucket
Da der Anwendungscode die S3-API einheitlich nutzt, ist die Migration zu einem echten S3-Bucket, R2 oder Tigris eine Änderung von Umgebungsvariablen. Die drei Dinge, die du vor dem Wechsel prüfen musst, sind:
- 01forcePathStyle muss für AWS S3 und R2
falsesein (sie verwenden virtual-hosted-style-URLs). - 02Region muss für AWS mit der tatsächlichen Bucket-Region übereinstimmen. R2 und Tigris verwenden einen festen Platzhalter.
- 03Credentials müssen durch bucket-beschränkte IAM-Keys ersetzt werden, nicht Root-Anmeldedaten.
Die eigentliche Datenmigration ist ein mc mirror-Befehl. MinIO Client (mc) spricht die S3-API und kann zwischen zwei Endpunkten spiegeln: mc mirror myminio/nearyou-media s3prod/nearyou-media. Führe es mit --watch aus, um den Rückstand abzuarbeiten, während der Traffic noch auf MinIO trifft, und mache dann den Cutover. Der Downtime ist ein einzelner Neustart des API-Containers mit den neuen Umgebungsvariablen.

Einen KI-Agenten dabei einsetzen, ohne dass er den Verstand verliert
Ein KI-Coding-Agent, der sitzungsübergreifend an NearYou arbeitet, hat standardmäßig kein persistentes Gedächtnis. Er vergisst, dass MinIO im Stack ist, vergisst die forcePathStyle-Anforderung und vergisst, dass die Proxy-Route existiert. Die Lösung ist, ein kurzes, präzises Infrastruktur-Dokument im Repository zu pflegen, das der Agent zu Beginn jeder Sitzung liest.
Das Dokument lebt in AGENTS.md oder einem dedizierten docs/infra/storage.md. Es ist kein Tutorial. Es ist eine Nachschlagetabelle für die Dinge, die der Agent korrekt ausführen muss: Servicenamen, Port-Konventionen, Key-Muster, bekannte Fallstricke und Befehle für häufige Operationen. Der Agent liest es, rekonstruiert den Kontext und arbeitet zuverlässig, ohne dass du die Einrichtung im Chat-Fenster erneut erklären musst.
Hier ist der Storage-Abschnitt aus NearYous Infra-Dokumentation. Er ist bewusst kurz gehalten. Der Agent braucht keine Prosa, sondern Fakten, auf die er handeln kann.
# Storage (MinIO / S3)
## Service
- Compose service name: `minio`
- Internal S3 endpoint: `http://minio:9000`
- Console (dev only): `http://localhost:9001`
- Bucket: `nearyou-media`
## SDK conventions
- Client: `@aws-sdk/client-s3` v3
- Always set `forcePathStyle: true` for local MinIO
- Set `forcePathStyle: false` if endpoint is AWS or Cloudflare R2
- Region: use `us-east-1` placeholder (MinIO ignores it)
- Client singleton: `src/lib/s3.ts`
## Key schema
- Events: `events/{eventId}/{sha256}.webp`
- Avatars: `avatars/{userId}/{sha256}.webp`
- Keys are content-addressed — never mutate a key after upload
## Public reads
- All reads go through `/api/media/[...key]` route handler
- Cache-Control: `public, max-age=31536000, immutable`
- MinIO port 9000 is NOT exposed publicly on the server
## Common commands
- Create bucket: `mc mb myminio/nearyou-media`
- Mirror to cloud: `mc mirror --watch myminio/nearyou-media s3prod/nearyou-media`
- List bucket: `mc ls myminio/nearyou-media`
## Cloud migration checklist
1. Set `forcePathStyle=false` in env
2. Update `S3_ENDPOINT` to cloud bucket URL
3. Replace root credentials with scoped IAM keys
4. Verify region matches bucket
5. Run `mc mirror`, then restart API containerWenn eine neue Sitzung beginnt und der Agent gebeten wird, einen neuen Medientyp hinzuzufügen oder einen 403-Fehler bei einem Upload zu debuggen, liest er zuerst diese Datei. Er weiß, dass der Client-Singleton in src/lib/s3.ts ist, er kennt das Key-Schema und weiß, das forcePathStyle-Flag nicht anzufassen, ohne den aktuellen Endpunkt zu prüfen. Das ist der ganze Punkt: die Markdown-Datei ist das Langzeitgedächtnis des Agenten für dieses Teilsystem.
Dasselbe Muster lässt sich auf andere Infrastrukturkomponenten skalieren. Eine Datei pro Teilsystem, knapp und sachlich gehalten. Der Agent sammelt operatives Wissen im Repository, nicht in deinem Chat-Verlauf.
Was dir das bringt
- S3-API-Kompatibilität ohne Vendor-Lock-in: Endpunkt tauschen, eine Umgebungsvariable ändern.
- Keine Egress-Abrechnung für interne Medien während der frühen Entwicklung und des Wachstums.
- Same-Origin-Proxy mit
Cache-Control: immutable, damit Browser und CDNs sich korrekt verhalten. - Zugangskontrolle auf der Proxy-Ebene, bevor irgendein Byte den Server verlässt.
- Vorhersehbare Migration:
mc mirrorplus ein Container-Neustart, keine Code-Änderungen. - Agenten-bedienbare Infra durch ein prägnantes Markdown-Dokument, das den Kontext zuverlässig rekonstruiert.
Die Standardkonfiguration für ein neues Produkt sollte das Einfachste sein, das funktioniert und sich nicht in den Weg stellt. MinIO in einem Compose-Stack mit einer Proxy-Route ist genau das. Es verschwindet im Hintergrund, kostet nichts extra und schließt keine Türen.