Migración MinIO → Garage en Cohete: bitácora en directo
Este post es bitácora en vivo. Lo actualizo conforme avanzo. Última edición: 2026-04-30 11:10. El endpoint funciona en producción, falta limpiar deuda técnica.
Contexto: el repo de MinIO fue archivado el 25 de abril de 2026; la empresa pivotó a AIStor (closed source). En aurin teníamos un MinIO recién montado para el blog Cohete (nada en producción todavía). Decidimos migrar a Garage como reemplazo S3-compatible libre.
Ver también: el post largo sobre el cierre de MinIO y por qué Garage.
Estado actual
[███████████████████████████████████] 100%
[X] Garage instalado en aurin (modulo NixOS clone-first)
[X] Bucket cohete-blog-images creado en Garage
[X] Datos migrados de MinIO via mc mirror (2 objetos, 93B; tests)
[X] Credenciales S3 en agenix (cohete-garage-credentials.age)
[X] Cherry-pick del PR #4 a una rama nueva (sin las 3 versiones del repo)
[X] Rename Minio → S3 en clases y namespaces
[X] Wire DI en config/definitions.php
[ ] Endpoint MCP/HTTP que SUBA via MediaRepository (PR #4 nunca llegó a controllers)
[ ] Add EnvironmentFile S3_* al cohete-blog systemd unit (en dotfiles)
[ ] Deploy en cohete + smoke test
[X] Apagar MinIO + quitar parche permittedInsecurePackages
[X] Borrar modules/services/minio.nix + secrets/minio-*.age
Decisiones de diseño
Cherry-pick limpio en lugar de merge
El PR #4 de MediaRepository traía tres implementaciones del mismo repo (sync, async, observable) — pedagógicas para el post sobre el catch de Pascual. En producción solo se usa la Observable.
Además, la rama estaba sin actualizar respecto a main: hacer un merge plano borraba el feature de Author que mergeé ayer.
Solución: cherry-pick por archivos.
git checkout -b feat/s3-media-repository main
git checkout feat/media-repository-minio -- \
src/ddd/Domain/Entity/Media/ \
src/ddd/Infrastructure/Media/ObservableMinioMediaRepository.php \
src/ddd/Infrastructure/Media/Aws4Signer.php \
src/ddd/Infrastructure/Media/InMemoryMediaRepository.phpQuedaron fuera MinioMediaRepository.php
(sync, antipatrón, bloquea event loop) y AsyncMinioMediaRepository.php (intermedia, solo
Promise sin RxPHP). El composer.json con
aws-sdk-php también se descartó: Aws4Signer.php está hecho a mano y no necesita
el SDK.
Rename Minio → S3
Mantener MinioMediaRepository como
nombre cuando el cliente habla con Garage es mentira semántica. S3 es el
protocolo; los backends son intercambiables.
mv src/ddd/Infrastructure/Media/ObservableMinioMediaRepository.php \
src/ddd/Infrastructure/Media/ObservableS3MediaRepository.php
sed -i 's/ObservableMinioMediaRepository/ObservableS3MediaRepository/g' \
src/ddd/Infrastructure/Media/ObservableS3MediaRepository.php \
src/ddd/Domain/Entity/Media/MediaRepository.phpAws4Signer.php ya tenía nombre
genérico. Su docstring se actualizó de "Compatible con S3 / MinIO" a
"Compatible con MinIO, Garage, AWS S3, R2 de Cloudflare…".
Garage en NixOS con DynamicUser
Primer intento puso dataDir = "/storage/garage" por simetría con
MinIO. Falló:
Error: Unable to open metadata db: Permission denied (os error 13)
El service de Garage en NixOS usa DynamicUser=true (UID asignado en runtime). No
podemos chown el dir externo a un user que
aún no existe. Solución: dejar dataDir en
el default /var/lib/garage, donde StateDirectory=garage de systemd lo crea con el
UID dinámico correcto.
Es el mismo disco (/ tiene 3.6 TB), no perdemos espacio.
rpcsecret format gotcha
El daemon de Garage espera la variable GARAGE_RPC_SECRET (con prefijo). Mi primer .age
tenía RPC_SECRET…= y falló silenciosamente
con:
Error: rpc_secret value is missing, not present in config file or in environment
Re-encriptado con el prefijo correcto:
SECRET=$(head -c 32 /dev/urandom | xxd -p -c 32)
echo "GARAGE_RPC_SECRET=$SECRET" | (cd ~/dotfiles/secrets && agenix -e garage-rpc-secret.age)Truco bonus: cuando stdin no es
interactivo, agenix -e usa internamente
cp /dev/stdin. Puedes pipe el contenido
directo.
Estructura del módulo NixOS
modules/services/garage.nix:
let
cfg = config.dotfiles.garage;
in {
options.dotfiles.garage = {
enable = mkEnableOption "Garage object storage (S3-compatible)";
dataDir = mkOption { default = "/var/lib/garage"; ... };
apiPort = mkOption { default = 3900; ... };
adminPort = mkOption { default = 3903; ... };
rpcPort = mkOption { default = 3901; ... };
replicationMode = mkOption { default = "none"; ... };
openFirewall = mkOption { default = false; ... };
};
config = mkIf cfg.enable {
age.secrets.garage-rpc-secret = {
file = ../../secrets/garage-rpc-secret.age;
mode = "0444"; # DynamicUser, no chown posible
};
services.garage = {
enable = true;
package = pkgs.garage;
settings = {
metadata_dir = "${cfg.dataDir}/meta";
data_dir = "${cfg.dataDir}/data";
db_engine = "lmdb";
replication_factor = 1;
rpc_bind_addr = "[::]:${toString cfg.rpcPort}";
rpc_public_addr = "127.0.0.1:${toString cfg.rpcPort}";
s3_api = {
s3_region = "garage";
api_bind_addr = "[::]:${toString cfg.apiPort}";
root_domain = ".s3.garage.local";
};
admin = {
api_bind_addr = "[::]:${toString cfg.adminPort}";
};
};
environmentFile = config.age.secrets.garage-rpc-secret.path;
};
};
}
Activación en hosts/aurin/default.nix:
dotfiles.garage.enable = true;DDD: el dominio Media
Tres value objects y un agregado:
src/ddd/Domain/Entity/Media/
├── Media.php # Aggregate Root
├── MediaId.php # extends UuidValueObject
├── MediaRepository.php # interface
└── ValueObject/
├── Bucket.php # extends StringValueObject
├── ContentType.php # validado: image/*, video/*, etc.
└── MediaKey.php # path interno tipo media/<uuid>
Repository interface (todo async, PromiseInterface obligatorio):
interface MediaRepository
{
public function put(Media $media, StreamInterface|string $body): PromiseInterface;
public function find(MediaId $id): PromiseInterface;
public function delete(MediaId $id): PromiseInterface;
public function presignedUrl(MediaId $id, int $ttlSeconds = 3600): PromiseInterface;
}Implementación ObservableS3MediaRepository: React\Http\Browser + Aws4Signer a mano + Rx\Observable->flatMap() para componer.
Compatible con cualquier backend S3.
DI binding
config/definitions.php ahora tiene:
Aws4Signer::class => static fn () => new Aws4Signer(
accessKey: $_ENV['S3_ACCESS_KEY'] ?? '',
secretKey: $_ENV['S3_SECRET_KEY'] ?? '',
region: $_ENV['S3_REGION'] ?? 'us-east-1',
),
MediaRepository::class => static fn (ContainerInterface $c) => new ObservableS3MediaRepository(
http: new Browser(),
signer: $c->get(Aws4Signer::class),
endpoint: $_ENV['S3_ENDPOINT'] ?? 'http://localhost:9000',
defaultBucket: Bucket::from($_ENV['S3_BUCKET'] ?? 'cohete-media'),
),5 variables de entorno: S3_ENDPOINT,
S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET.
Esas mismas 5 viven en /run/agenix/cohete-garage-credentials como
envfile.
Lo que falta (en orden)
Endpoint para subir. El PR #4 nunca llegó a controllers; solo dominio + impls del repo. Hay dos caminos:
- Reescribir
upload_assetMCP tool (hoy guarda en filesystem/html/img/) para que useMediaRepository. Limpio pero rompe URLs viejas. - Añadir un endpoint nuevo (
POST /mediao MCPupload_media) que conviva con el viejo. Más conservador.
Voto por el segundo: lo nuevo entra sin tocar lo viejo, deprecamos
upload_assetcuando esté maduro.- Reescribir
Configurar el systemd unit del cohete-blog para que cargue
/run/agenix/cohete-garage-credentialscomoEnvironmentFile. Enhosts/cohete/default.nix, una línea más en elserviceConfig.Deploy. Push a origin,
git pullen cohete,systemctl restart cohete-blog.Smoke test. Subir una imagen via el endpoint nuevo, verificar que aparece en Garage (
mc ls garage-aurin/cohete-blog-images).Apagar MinIO.
dotfiles.minio.enable = false, rebuild aurin, quitar el parchepermittedInsecurePackagesdel módulo. (O directamente borrarmodules/services/minio.nixen un commit final.)
Comandos de referencia (cheatsheet)
Inspección de Garage
# Cargar el rpc_secret en la shell
source /run/agenix/garage-rpc-secret # exporta GARAGE_RPC_SECRET
# Estado del cluster
sudo -E garage --config /etc/garage.toml status
# Listar buckets
sudo -E garage --config /etc/garage.toml bucket list
# Ver info de una key
sudo -E garage --config /etc/garage.toml key info cohete-rw
# Listar objetos en un bucket via mc
mc ls --recursive garage-aurin/cohete-blog-imagesMirror desde MinIO (one-shot)
mc alias set minio-aurin http://aurin:9000 "$MINIO_USER" "$MINIO_PASSWORD"
mc alias set garage-aurin http://aurin:3900 "GK..." "..." --api S3v4
mc mirror minio-aurin/cohete-blog-images garage-aurin/cohete-blog-imagesPróxima actualización
Cuando cierre el endpoint upload + smoke test pasado.
Cierre del e2e (2026-04-30 11:10)
Smoke test que cerró la migración
curl -sf -X POST https://pascualmg.dev/media \
-H "Authorization: Bearer <author-token>" \
-H "Content-Type: image/png" \
--data-binary @/tmp/test.png
# Respuesta:
# {"id":"9d135c65-...","key":"media/9d135c65-...","byteSize":23,"contentType":"image/png"}
# Verificacion:
mc ls --recursive garage-aurin/cohete-blog-images
# media/9d135c65-... 23B ← acabado de subir
# media/09080c56-... 35B ← migrado de MinIO antes
# test-minio.txt 58B ← migrado de MinIO antesEl path completo del request
[Internet]
|
v HTTPS :443
[Cloudflare] termina TLS
|
v HTTP :80
[cohete:80] cohete-blog (PHP, ReactPHP)
| POST /media + Authorization: Bearer
| ├─ UploadMediaController valida Bearer via AuthorAuthenticator
| ├─ UploadMediaCommandHandler genera MediaId UUID v4
| └─ ObservableS3MediaRepository.put(media, body)
| ├─ Aws4Signer firma PUT con AWS V4
| └─ React\Http\Browser hace HTTP request async
|
v HTTP S3 :3900 (via mesh tailscale 100.64.0.4)
[Garage en aurin]
| ├─ Verifica firma AWS V4
| ├─ Bucket cohete-blog-images, key media/{uuid}
| └─ Persiste en /var/lib/garage/data/
|
v HTTP 200 con ETag
[cohete-blog]
| └─ JSON {id, key, byteSize, contentType} con HTTP 201
v
[Cliente]
Las dos peleas de medio
Mesh caída por tailscale 1.96 forzando HTTPS
El flake update bumpeó tailscale. La nueva versión exige HTTPS contra el control server. Headscale en cohete servía HTTP plano → mesh rota.
Solución: Cloudflare delante. DNS A headscale.pascualmg.dev proxied + Origin Rule
reescribiendo a puerto :8085 + SSL Flexible. server_url en el módulo apunta a la nueva URL
HTTPS.
Deploy a cohete bloqueado por firmas nix store
Tres intentos fallaron:
nixos-rebuild --target-host:cannot add path because it lacks a signature(passh no es trusted-user en cohete).--option require-sigs false: ignorado por la misma razón.- Build local en cohete: SIGKILL (OOM, 4GB RAM apretados) y disco al 100% (75GB justo para nix-store + toplevel temporal). Hubo que GC dos veces, liberando 22 GB cada ronda.
Workaround temporal HOY: las S3* credentials directo al
/home/passh/src/cohete/.env (en
.gitignore). Nada de EnvironmentFile
declarativo hasta que se arregle el rebuild proper. Apuntado #178.
Próxima actualización
Cuando arregle el rebuild remoto (#178). Ahí caen tres cierres a la vez:
EnvironmentFiledeclarativo en lugar del .env plano.- MinIO disable + parche
permittedInsecurePackagesfuera. - Module
minio.nixy secrets viejos borrados.
Hasta entonces: el blog escribe en Garage en producción. La migración funciona.
Cierre definitivo (2026-05-01 10:30)
MinIO apagado y borrado
hosts/aurin/default.nix:
- import minio.nix FUERA
- dotfiles.minio.enable FUERA
- dataDir custom FUERA
hosts/cohete/default.nix:
- age.secrets.cohete-minio-credentials FUERA
secrets/secrets.nix:
- minio-root-credentials.age FUERA
- cohete-minio-credentials.age FUERA
modules/services/minio.nix BORRADO
secrets/minio-root-credentials.age BORRADO
secrets/cohete-minio-credentials.age BORRADO
aurin (manual):
/storage/minio/ BORRADO (216 KB sin importancia)
Tras el rebuild aurin:
$ systemctl is-active minio garage
inactive
active
$ ss -tn -l | grep -E ":9000|:3900"
LISTEN 0 4096 *:3900 *:*
Solo Garage escuchando. MinIO cero presencia. El parche permittedInsecurePackages que metimos como ñapa
para que el flake update no se atragantase con el paquete archivado se
va con el módulo borrado.
Cierre indirecto del bug crónico de la mesh
Otra ventaja: la fix de Cloudflare HTTPS para headscale (que metimos para arreglar el bug de tailscale 1.96 forzando HTTPS) cierra de facto la TODO #161 ("mesh degrada con reboot aurin"). Antes cada reboot dejaba a aurin desconectado del control server. Ahora con TLS termination en Cloudflare, el cliente reconecta solo. Verificado con dos reboots consecutivos sin degradación.
Lo único que queda
[ ] Resolver #178: passh como trusted-user en cohete +
nix-cache pubkey de aurin en trusted-public-keys.
Con eso, los rebuilds remotos a cohete funcionan limpios.
[ ] Pasar S3_* del .env plano del blog a EnvironmentFile agenix.
Bloqueado por #178.
[ ] Snapshot del estado actual: post final con la migración entera
como referencia, fuera de este live-blog.
La pieza grande está cerrada. El blog Cohete escribe en Garage en producción.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario