MinIO: Propriul tău S3 pe propriul tău server
- infrastructure
- self-hosted
- storage
- devops
Nu ai nevoie de AWS pentru a obține stocare de obiecte compatibilă cu S3. Un singur container, aceeași API, zero taxe de egress și o cale curată spre cloud atunci când chiar ai nevoie.
Orice proiect acumulează fișiere media. NearYou stochează miniaturi de locații, avataruri de utilizatori, bannere de evenimente. Reflexul este să apelezi la S3, să plătești taxa pe cereri și să accepți că fiecare imagine servită dintr-o altă origine costă un CORS preflight și un buget de latență. Există o alternativă mai bună pentru faza incipientă a unui produs: MinIO, rulând în același stack Docker Compose ca aplicația ta, vorbind nativ API-ul S3.
API-ul S3 nu este proprietatea Amazon. Este un standard de facto. Pachetul @aws-sdk/client-s3 nu contează dacă endpoint-ul este s3.amazonaws.com sau localhost:9000. Îl îndrepți spre MinIO și aceleași apeluri PutObjectCommand și GetObjectCommand funcționează identic. Când NearYou va trece în cele din urmă la un bucket gestionat, migrarea este o singură variabilă de mediu.

Un singur container în stack
Fragmentul Compose este mic. MinIO publică două porturi: 9000 pentru API-ul S3 și 9001 pentru consola browser. Consola este utilă în timpul dezvoltării. Montezi un volum local pentru ca datele să supraviețuiască repornirilor containerului.
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:În interiorul containerului api, MinIO este accesibil la http://minio:9000 deoarece ambele servicii partajează rețeaua Compose implicită. Nu este necesară nicio expunere publică pentru scrierile interne. Numele bucket-ului este doar un șir pe care îl creezi o singură dată prin consolă sau prin CLI-ul clientului MinIO (mc mb myminio/nearyou-media).
Încărcare din TypeScript
Calea de upload în NearYou este un wrapper subțire în jurul AWS SDK v3. Ideea cheie este forcePathStyle: true. AWS folosește URL-uri în stilul virtual-hosted (bucket.host/key). MinIO într-un mediu local folosește stilul path (host/bucket/key). Fără acel flag vei primi erori de rezoluție DNS care nu sunt imediat evidente.
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},
);
}Câmpul region este o ciudățenie. MinIO nu aplică regiuni, dar clientul AWS SDK va refuza să semneze cereri fără una. us-east-1 este convențional și inofensiv. Când îndrepți același cod spre un bucket AWS real mai târziu, regiunea trebuie să corespundă cu regiunea efectivă a bucket-ului.
Proxy pentru citirile publice prin /api/media/*
Să servești fișiere direct din http://minio:9000 înseamnă că fiecare cerere a clientului iese din domeniul aplicației tale. Pierzi: caching same-origin, headere Cache-Control pe care le controlezi tu, verificări de acces înainte de livrare și opțiunea de a rescrie cheile fără a schimba URL-urile. Ruta proxy costă un hop suplimentar în serverul tău, dar recuperează toate acestea.
Într-un Route Handler Next.js, modelul este simplu. Handler-ul preia din MinIO prin rețeaua internă, transmite în flux răspunsul către client și redirecționează tipul de conținut. Poți restricționa accesul cu middleware-ul tău obișnuit de autentificare înainte ca fetch-ul să fie declanșat.
// 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',
},
});
}Header-ul Cache-Control: immutable înseamnă că browserele și CDN-urile nu vor solicita din nou un asset odată ce a fost memorat în cache. Funcționează doar dacă cheile tale sunt content-addressed (includ un hash sau un segment de versiune). Pentru media NearYou, cheile urmează modelul events/{eventId}/{hash}.webp. eventId este stabil, hash-ul se schimbă când se schimbă imaginea.
Niciun contor de egress
Motivul pentru care stocarea self-hosted are sens în faza incipientă a unui produs nu este latența sau controlul, deși ambele ajută. Este previzibilitatea costurilor. AWS S3 taxează per cerere și per GB transferat. Când iterezi rapid, adaugi formate de imagine, redimensionezi din mers și servești previzualizări în dashboard-uri, numărul de cereri crește înainte să îți dai seama. MinIO pe un server pentru care deja plătești adaugă zero la acea linie.
NearYou rulează pe un server dedicat Hetzner. Costul lunar al acelei mașini este fix. Fiecare imagine servită prin ruta proxy este bandă internă. Singura bandă externă este ceea ce uplink-ul Hetzner transportă către utilizatorul final, care este măsurată separat, dar generos. Pentru un produs în intervalul sutelor de utilizatori, stocarea self-hosted este valoarea implicită corectă.
Nu ai nevoie de stocare cu contor de egress până când factura ta de stocare nu devine o fracțiune semnificativă din venitul tău. Înainte de acel punct, compatibilitatea API-ului S3 a MinIO îți oferă calea de migrare fără costuri.
Calea de migrare spre un bucket cloud
Deoarece codul aplicației folosește uniform API-ul S3, migrarea la un bucket S3 real, R2 sau Tigris este o modificare a variabilelor de mediu. Cele trei lucruri pe care trebuie să le verifici înainte de a face schimbarea sunt:
- 01forcePathStyle trebuie să fie
falsepentru AWS S3 și R2 (folosesc URL-uri în stilul virtual-hosted). - 02Region trebuie să corespundă cu regiunea efectivă a bucket-ului pentru AWS. R2 și Tigris folosesc un placeholder fix.
- 03Credentials trebuie înlocuite cu chei IAM limitate la bucket, nu credențiale root.
Migrarea datelor în sine este o comandă mc mirror. MinIO Client (mc) vorbește API-ul S3 și poate oglindi între două endpoint-uri: mc mirror myminio/nearyou-media s3prod/nearyou-media. Rulează-l cu --watch pentru a goli restanțele în timp ce traficul încă ajunge la MinIO, apoi fă cutover-ul. Downtime-ul este un singur restart al containerului API cu noile variabile de mediu.

Lăsând un agent AI să opereze asta fără să înnebunească
Un agent AI de coding care lucrează la NearYou în mai multe sesiuni nu are memorie persistentă în mod implicit. Va uita că MinIO este în stack, va uita cerința forcePathStyle și va uita că există ruta proxy. Soluția este să păstrezi în repository un document de infrastructură scurt și precis pe care agentul îl citește la începutul fiecărei sesiuni.
Documentul se află în AGENTS.md sau într-un docs/infra/storage.md dedicat. Nu este un tutorial. Este un tabel de referință pentru lucrurile pe care agentul trebuie să acționeze corect: nume de servicii, convenții de porturi, modele de chei, probleme cunoscute și comenzi pentru operațiuni comune. Agentul îl citește, reconstruiește contextul și operează fiabil fără să fie nevoie să reexplici configurarea în fereastra de chat.
Iată secțiunea de stocare din documentele infra ale NearYou. Este păstrată scurtă în mod deliberat. Agentul nu are nevoie de proză, ci de fapte pe care poate acționa.
# 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 containerCând o nouă sesiune începe și agentului i se cere să adauge un nou tip de media sau să depaneze un 403 la un upload, citește mai întâi acest fișier. Știe că singleton-ul clientului este în src/lib/s3.ts, cunoaște schema cheilor și știe să nu atingă flag-ul forcePathStyle fără a verifica endpoint-ul curent. Acesta este întregul punct: fișierul markdown este memoria pe termen lung a agentului pentru acest subsistem.
Același model se extinde la alte componente de infrastructură. Un fișier per subsistem, păstrat concis și factual. Agentul acumulează cunoștințe operaționale în repository, nu în istoricul chat-ului tău.
Ce câștiguri îți aduce asta
- Compatibilitate API S3 fără vendor lock-in: schimbă endpoint-ul, modifică o variabilă de mediu.
- Zero facturare egress pentru media interne în timpul dezvoltării timpurii și al creșterii.
- Proxy same-origin cu
Cache-Control: immutablepentru ca browserele și CDN-urile să se comporte corect. - Control acces la nivelul proxy, înainte ca orice octet să părăsească serverul.
- Migrare previzibilă:
mc mirrorplus un restart de container, fără modificări de cod. - Infrastructură operabilă de agenți printr-un document markdown concis care reconstruiește contextul fiabil.
Configurarea implicită pentru un produs nou ar trebui să fie cel mai simplu lucru care funcționează și nu se pune în cale. MinIO într-un stack Compose cu o rută proxy este exact asta. Dispare în fundal, nu costă nimic în plus și nu închide nicio ușă.