Montar tu propio S3 con MinIO detras de nginx, y conectarlo a Laravel
Un contenedor, un subdominio con TLS y Laravel guardando archivos como si fuera Amazon. Incluye las cuatro lineas de nginx sin las que las subidas se cortan en 1 MB.
S3 sin AWS: un contenedor de MinIO, un subdominio con TLS y Laravel guardando archivos ahí como si fuera Amazon. Al final tendrás una API S3 pública y una consola de administración que no lo es.
Doy por hecho que ya tienes Docker, nginx de reverse proxy y certbot funcionando en un VPS.
1. El registro DNS, primero
Crea un registro A para s3.tudominio.com apuntando a tu servidor. Hazlo antes que nada: certbot va a necesitar resolverlo y la propagación tarda.
2. El stack
# ~/apps/minio/docker-compose.yml
services:
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
env_file: .env
ports:
- "127.0.0.1:9000:9000" # API S3
- "127.0.0.1:9001:9001" # consola
volumes:
- minio_data:/data
volumes:
minio_data:
Tres decisiones que conviene entender:
La versión está fija y es vieja a propósito. Desde mediados de 2025 la consola de la edición comunitaria quedó reducida a login e información: ya no permite crear buckets ni llaves. Esa release es la última con la interfaz completa. Como no recibe parches, la consola no se publica a internet — verás en el paso 5 que solo se llega por túnel.
Los dos puertos van a loopback. Sin el prefijo 127.0.0.1, Docker escribe reglas de iptables que se saltan tu firewall y publicas MinIO al mundo sin enterarte.
Volumen con nombre, no bind mount. MinIO es quisquilloso con los permisos del directorio de datos; un volumen se los administra él.
El .env, con permisos cerrados:
cd ~/apps/minio
printf 'MINIO_ROOT_USER=admin\nMINIO_ROOT_PASSWORD=%s\n' "$(openssl rand -hex 20)" > .env
chmod 600 .env
docker compose up -d
3. nginx: el detalle que rompe las subidas
# /etc/nginx/conf.d/s3.conf
server {
server_name s3.tudominio.com;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Sin estas cuatro, las subidas se cortan en 1 MB con un 413.
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
chunked_transfer_encoding off;
}
}
Las cuatro líneas del final no son opcionales. Por omisión nginx acumula el cuerpo entero de la petición en disco antes de pasarlo, lo que con archivos grandes es lento y con el límite por defecto directamente falla. Apagando el buffering, nginx transmite mientras recibe.
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d s3.tudominio.com
4. El bucket y sus llaves
MinIO trae mc, su cliente, dentro del contenedor:
docker exec -it minio mc alias set local http://127.0.0.1:9000 admin 'TU_PASSWORD'
docker exec -it minio mc mb local/archivos
No uses las credenciales root en la aplicación. Crea una llave de servicio con acceso solo a ese bucket; si se filtra, se revoca sin tocar nada más.
5. La consola, por túnel
Desde tu máquina, en una ventana que dejas abierta:
ssh -L 9001:127.0.0.1:9001 usuario@tu-servidor
Y en el navegador: http://127.0.0.1:9001.
Escribe 127.0.0.1, no localhost. Chrome y Edge en Windows resuelven localhost a ::1 (IPv6) y el túnel de ssh escucha en IPv4: la página no carga y parece que el túnel falló. Es el error que más tiempo me ha hecho perder de toda esta guía.
6. Laravel
composer require league/flysystem-aws-s3-v3
AWS_ACCESS_KEY_ID=la-llave-de-servicio
AWS_SECRET_ACCESS_KEY=el-secreto
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=archivos
AWS_ENDPOINT=https://s3.tudominio.com
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_USE_PATH_STYLE_ENDPOINT=true es obligatorio. AWS direcciona como bucket.dominio.com; MinIO como dominio.com/bucket. Sin la bandera, el SDK construye URLs de un subdominio que no existe y todo falla con errores de DNS que no parecen tener nada que ver.
La región da igual, pero tiene que estar: el SDK la exige para firmar.
Storage::disk('s3')->put('facturas/2026/f-001.pdf', $contenido);
Cuidado con FILESYSTEM_DISK
Es tentador poner FILESYSTEM_DISK=s3 y olvidarse. Eso cambia el disco por omisión de toda la aplicación, y los archivos que ya estaban en storage/app no se migran solos: quedan invisibles. En una aplicación que ya tiene datos, mejor pedir el disco explícitamente donde lo quieras.
Prueba de extremo a extremo
Esta sola valida dominio, certificado, nginx y llaves de un golpe:
docker exec -it minio mc alias set publico https://s3.tudominio.com 'LLAVE' 'SECRETO'
docker exec -it minio mc cp /etc/hostname publico/archivos/prueba.txt
docker exec -it minio mc ls publico/archivos
Si eso responde, ya tienes tu S3.