MinIO: Il Tuo S3 Personale sul Tuo Server
- infrastructure
- self-hosted
- storage
- devops
Non hai bisogno di AWS per ottenere uno storage a oggetti compatibile con S3. Un container, la stessa API, zero costi di egress e una via pulita verso il cloud quando ne avrai davvero bisogno.
Ogni progetto accumula media. NearYou archivia miniature di posizioni, avatar utenti, banner di eventi. Il riflesso condizionato è ricorrere a S3, pagare la tassa sulle richieste e accettare che ogni immagine servita da un'origine diversa costi un CORS preflight e un budget di latenza. Esiste un'alternativa migliore per la fase iniziale di un prodotto: MinIO, in esecuzione nello stesso stack Docker Compose della tua applicazione, che parla nativamente l'API S3.
L'API S3 non è una proprietà di Amazon. È uno standard de facto. Il pacchetto @aws-sdk/client-s3 non si preoccupa se l'endpoint è s3.amazonaws.com o localhost:9000. Punti a MinIO e le stesse chiamate PutObjectCommand e GetObjectCommand funzionano in modo identico. Quando NearYou passerà a un bucket gestito, la migrazione sarà una sola variabile d'ambiente.

Un Solo Container nello Stack
Lo snippet Compose è ridotto. MinIO pubblica due porte: 9000 per l'API S3 e 9001 per la console browser. La console è utile durante lo sviluppo. Monti un volume locale affinché i dati sopravvivano ai riavvii del container.
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:All'interno del container api, MinIO è raggiungibile a http://minio:9000 perché entrambi i servizi condividono la rete Compose predefinita. Non è necessaria alcuna esposizione pubblica per le scritture interne. Il nome del bucket è semplicemente una stringa che crei una volta tramite la console o la CLI del client MinIO (mc mb myminio/nearyou-media).
Caricamento da TypeScript
Il percorso di upload in NearYou è un sottile wrapper attorno all'AWS SDK v3. L'intuizione chiave è forcePathStyle: true. AWS utilizza URL in stile virtual-hosted (bucket.host/key). MinIO in un ambiente locale usa lo stile path (host/bucket/key). Senza quel flag otterrai errori di risoluzione DNS non immediatamente evidenti.
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},
);
}Il campo region è una peculiarità. MinIO non applica le region, ma il client AWS SDK si rifiuterà di firmare le richieste senza una. us-east-1 è convenzionale e inoffensivo. Quando in seguito punti lo stesso codice a un vero bucket AWS, la region deve corrispondere a quella effettiva del bucket.
Proxy delle Letture Pubbliche tramite /api/media/*
Servire i file direttamente da http://minio:9000 significa che ogni richiesta del client lascia il dominio della tua applicazione. Perdi: caching same-origin, intestazioni Cache-Control che controlli tu, verifiche di accesso prima della consegna e la possibilità di riscrivere le chiavi senza cambiare gli URL. La route proxy costa un hop aggiuntivo nel server ma recupera tutto questo.
In un Route Handler di Next.js, il pattern è semplice. L'handler recupera da MinIO tramite la rete interna, trasmette in streaming la risposta al client e inoltra il content type. Puoi bloccare l'accesso con il tuo normale middleware di autenticazione prima che la fetch venga mai eseguita.
// 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',
},
});
}L'intestazione Cache-Control: immutable significa che browser e CDN non richiederanno nuovamente un asset una volta memorizzato nella cache. Funziona solo se le tue chiavi sono content-addressed (includono un hash o un segmento di versione). Per i media di NearYou, le chiavi seguono il pattern events/{eventId}/{hash}.webp. L'eventId è stabile, l'hash cambia quando cambia l'immagine.
Nessun Contatore di Egress
Il motivo per cui lo storage self-hosted ha senso nella fase iniziale di un prodotto non è la latenza o il controllo, anche se entrambi aiutano. È la prevedibilità dei costi. AWS S3 addebita per richiesta e per GB trasferiti. Quando si itera velocemente, si aggiungono formati di immagine, si ridimensiona al volo e si servono anteprime nelle dashboard, il conteggio delle richieste sale prima che tu te ne accorga. MinIO su un server che stai già pagando aggiunge zero a quella voce.
NearYou gira su un server dedicato Hetzner. Il costo mensile di quella macchina è fisso. Ogni immagine servita tramite la route proxy è banda interna. L'unica banda esterna è quella che il collegamento Hetzner porta all'utente finale, misurata separatamente ma in modo generoso. Per un prodotto nella fascia delle centinaia di utenti, lo storage self-hosted è il default corretto.
Non hai bisogno di storage con contatore di egress finché la tua bolletta di storage non diventa una frazione significativa del tuo fatturato. Prima di quel punto, la compatibilità con l'API S3 di MinIO ti offre il percorso di migrazione senza i costi.
Percorso di Migrazione verso un Bucket Cloud
Poiché il codice applicativo usa l'API S3 in modo uniforme, la migrazione a un vero bucket S3, R2 o Tigris è una modifica alle variabili d'ambiente. Le tre cose da verificare prima di fare il cambio sono:
- 01forcePathStyle deve essere
falseper AWS S3 e R2 (usano URL in stile virtual-hosted). - 02Region deve corrispondere alla region effettiva del bucket per AWS. R2 e Tigris usano un placeholder fisso.
- 03Credentials devono essere sostituite con chiavi IAM con scope limitato al bucket, non le credenziali root.
La migrazione dei dati è un comando mc mirror. MinIO Client (mc) parla l'API S3 e può eseguire il mirror tra due endpoint: mc mirror myminio/nearyou-media s3prod/nearyou-media. Eseguilo con --watch per svuotare il backlog mentre il traffico colpisce ancora MinIO, poi fai il cutover. Il downtime è un singolo riavvio del container API con le nuove variabili d'ambiente.

Far Operare un Agente AI su Questo Senza Farlo Impazzire
Un agente AI di coding che lavora su NearYou in più sessioni non ha memoria persistente per impostazione predefinita. Dimenticherà che MinIO è nello stack, dimenticherà il requisito forcePathStyle e dimenticherà che esiste la route proxy. La soluzione è mantenere nella repository un documento di infrastruttura breve e preciso che l'agente legga all'inizio di ogni sessione.
Il documento si trova in AGENTS.md o in un dedicato docs/infra/storage.md. Non è un tutorial. È una tabella di riferimento per le cose su cui l'agente deve agire correttamente: nomi dei servizi, convenzioni sulle porte, pattern delle chiavi, problemi noti e comandi per le operazioni comuni. L'agente lo legge, ricostruisce il contesto e opera in modo affidabile senza che tu debba rispiegare la configurazione nella finestra di chat.
Ecco la sezione storage dei documenti infra di NearYou. È tenuta breve di proposito. L'agente non ha bisogno di prosa: ha bisogno di fatti su cui può agire.
# 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 containerQuando inizia una nuova sessione e all'agente viene chiesto di aggiungere un nuovo tipo di media o di debuggare un 403 su un upload, legge prima questo file. Sa che il singleton del client è in src/lib/s3.ts, conosce lo schema delle chiavi e sa di non toccare il flag forcePathStyle senza verificare l'endpoint corrente. Questo è l'intero punto: il file markdown è la memoria a lungo termine dell'agente per questo sottosistema.
Lo stesso pattern si scala ad altri componenti infrastrutturali. Un file per sottosistema, tenuto breve e fattuale. L'agente accumula conoscenza operativa nella repository, non nella cronologia della chat.
Cosa Ti Porta Tutto Questo
- Compatibilità API S3 senza vendor lock-in: sostituisci l'endpoint, cambia una variabile d'ambiente.
- Zero fatturazione egress per i media interni durante lo sviluppo iniziale e la crescita.
- Proxy same-origin con
Cache-Control: immutableaffinché browser e CDN si comportino correttamente. - Controllo degli accessi al livello proxy, prima che qualsiasi byte lasci il server.
- Migrazione prevedibile:
mc mirrorpiù un riavvio del container, nessuna modifica al codice. - Infra operabile da agenti tramite un conciso documento markdown che ricostruisce il contesto in modo affidabile.
La configurazione predefinita per un nuovo prodotto dovrebbe essere la cosa più semplice che funziona e non si mette in mezzo. MinIO in uno stack Compose con una route proxy è esattamente questo. Sparisce nello sfondo, non costa nulla in più e non chiude nessuna porta.