Desde hace un tiempo quería tener mi propio almacenamiento tipo S3 en casa: un lugar donde guardar archivos de trabajo y personales desde cualquiera de mis equipos, con permisos bien definidos y sin depender de la nube. Una carpeta compartida se queda corta cuando quieres permisos distintos por equipo o usar herramientas de respaldo que ya hablan S3, y un servicio comercial implica que los datos vivan en hardware ajeno y pagar por almacenamiento y tráfico. En esta bitácora cuento cómo lo monté con VaultS3 sobre el RAID 1 que ya tenía en mi Intel NUC, paso a paso y con los errores que fueron apareciendo.

Este es el primer artículo de dos. Aquí cubro la instalación, el cifrado y la operación diaria del RAID. En el segundo artículo explico cómo organicé el acceso desde varios equipos, las restricciones por IP y el respaldo de la clave de cifrado.

¿Qué es un almacenamiento de objetos S3?

Un almacenamiento de objetos guarda la información como objetos dentro de buckets. Cada objeto es un archivo más sus metadatos, y se identifica por una clave (por ejemplo informes/q3.pdf). Aunque la clave parezca una ruta, no hay carpetas reales: las barras son parte del nombre, y los clientes las muestran como carpetas por comodidad. No hay discos compartidos ni carpetas montadas: todo se hace por HTTP con una API.

S3 nació como el servicio de Amazon, pero su API se volvió un estándar de facto. Hoy muchísimas herramientas hablan “S3”: la AWS CLI, rclone, restic, aplicaciones de backup, librerías como boto3, clientes gráficos, etc. Tener un servidor compatible con S3 en casa significa poder usar todo ese ecosistema apuntando a tu propio hardware.

Las ventajas que me interesaban:

  • Compatibilidad: muchas herramientas compatibles con S3 se pueden usar apuntando al endpoint propio. Ojo: la compatibilidad concreta depende de las operaciones que use cada herramienta, así que conviene probar los flujos que realmente vas a usar.
  • Acceso por red desde cualquier equipo, sin montar carpetas compartidas.
  • Permisos granulares: usuarios, llaves de acceso y alcance por bucket.
  • Cifrado en reposo y otras funciones de nivel empresarial que ofrecen muchos servidores S3 (versionado, auditoría, ciclo de vida).
  • Sin tarifas de un proveedor de nube por almacenamiento, peticiones o transferencia, y los datos se quedan en mi hardware. Eso no significa gratis: hay costos de hardware, electricidad y tiempo de mantenimiento.

Una diferencia importante frente a una carpeta compartida (SMB/NFS): los objetos no se editan “en el lugar”. Se suben y se reemplazan completos. Para respaldos, archivos y documentos es perfecto; para trabajar sobre un archivo abierto todo el día, no es la herramienta.

¿Qué es VaultS3?

VaultS3 es un servidor de almacenamiento de objetos compatible con S3, escrito en Go y distribuido como un único binario o como imagen Docker (unos 66 MB según docker image ls en la versión que usé). Lo que ofrece, según su documentación:

  • Consume muy poca memoria en reposo.
  • Trae dashboard web incluido: navegador de archivos, IAM, auditoría y estadísticas.
  • IAM con usuarios, grupos, políticas y llaves con alcance propio.
  • Cifrado en reposo AES-256-GCM.
  • Endpoints /health y /ready, y métricas Prometheus en /metrics.
  • Licencia AGPL-3.0.

De todo eso, en este montaje configuré y verifiqué: el cifrado en reposo, el dashboard, los endpoints de salud y, en el segundo artículo, IAM con llaves por equipo, la auditoría y las restricciones por IP. Funciones como el versionado o el ciclo de vida no las activé.

Algo a tener en cuenta: el propio autor advierte en el README que es un proyecto mantenido por una sola persona y recomienda siempre tener un backup independiente. Un RAID 1 protege contra la falla de un disco, no reemplaza un respaldo.

La arquitectura

 MacBook Air M5 ─────┐
 Mac mini M4 ────────┼── LAN 192.168.1.0/24 ──►  Intel NUC (NucI5) :9000
 Equipo corporativo ─┘                           └─ Docker: contenedor vaults3
                                                   │  /data  /metadata
                                                   ▼
                                    /mnt/caja-raid  (ext4 sobre /dev/md0)
                                                   │
                                    Caja USB JMicron JMS56x (2 bahías)
                                    ├─ Seagate ST2000DL003  (2 TB)
                                    └─ Hitachi HUA723020ALA641 (2 TB)
  • Servidor: Intel NUC con EndeavourOS, hostname NucI5, usuario jmmv.
  • Almacenamiento: RAID 1 por software con mdadm (/dev/md0, ext4, ~1,8 TB), montado en /mnt/caja-raid. Cómo lo armé está en mi artículo sobre el RAID
  • Lo que el RAID 1 no cubre: protege contra la falla de un disco, pero no contra borrados accidentales, corrupción lógica, ransomware ni una falla de la caja completa. Además, los dos discos comparten la misma caja, el mismo puente USB y la misma fuente de poder.
  • Detalle clave: la caja USB no está siempre encendida. El arreglo se ensambla y se monta a mano (noauto,nofail en /etc/fstab), porque el ensamblaje automático por udev lo degradaba a [_U]: arrancaba antes de que los dos discos USB terminaran de enumerarse. Esto condicionó varias decisiones del montaje.

Las versiones con las que monté todo:

ComponenteVersión
Sistema operativo del NUCEndeavourOS (Arch Linux)
Docker Engine / Docker Compose29.8 / 5.6
VaultS35.0.2
AWS CLI en macOS2.37
RAIDmdadm RAID 1, ext4, ~1,8 TB

Ojo con tu red: en todo el artículo uso 192.168.1.x como ejemplo. Tu segmento puede ser otro (192.168.0.x, 10.0.0.x…); verifícalo con ip -4 addr en Linux o ipconfig getifaddr en0 en macOS antes de copiar comandos.

Paso 1: revisar el estado del RAID

Antes de instalar nada, revisé el estado del arreglo:

cat /proc/mdstat
sudo mdadm --detail /dev/md0
findmnt /mnt/caja-raid; df -h /mnt/caja-raid
lsblk -o NAME,SIZE,TYPE,MODEL,SERIAL,MOUNTPOINT
docker --version; docker compose version
free -h

Dos sorpresas:

md0 : active raid1 sdc[1] sdb[0]
      1953382464 blocks super 1.2 [2/2] [UU]
      [==========>..........]  resync = 52.3% (1023471744/1953382464) finish=142.8min speed=108480K/sec
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p2  914G   17G  851G   2% /
  1. El arreglo estaba resincronizando (52 %, unas 2 horas y media restantes).
  2. /mnt/caja-raid no estaba montado. df mostraba la raíz del sistema (/), es decir, el NVMe.

Para entender el resync revisé el log del kernel:

sudo journalctl -k -b | grep -iE 'md0|md/raid1' | tail -20
kernel: md/raid1:md0: not clean -- starting background reconstruction
kernel: md/raid1:md0: active with 2 out of 2 mirrors
kernel: md: resync of RAID array md0

El kernel detectó que el arreglo no estaba marcado como limpio (not clean) y empezó una resincronización. Es lo que ocurre después de una parada no limpia, y en mi caso cuadraba: había apagado o desconectado la caja sin mdadm --stop. El mensaje por sí solo no garantiza que no haya daño, así que revisé que el arreglo tuviera sus dos miembros activos y que no hubiera errores de E/S en el log antes de seguir. Se puede montar y usar durante el resync; solo va más lento.

El segundo punto era el más peligroso: si VaultS3 arranca sin el RAID montado, escribe en el disco del sistema sin que uno se dé cuenta. Más adelante resolví esto con un “candado”.

Monté el RAID:

sudo mount /mnt/caja-raid
findmnt /mnt/caja-raid
TARGET         SOURCE   FSTYPE OPTIONS
/mnt/caja-raid /dev/md0 ext4   rw,relatime

Paso 2: carpetas y usuario de la imagen

Decidí separar configuración y datos:

  • Configuración (docker-compose.yml, .env) en ~/Proyectos/vaults3, en el NVMe.
  • Datos y metadatos en /mnt/caja-raid/vaults3/{data,metadata}, en el RAID.
sudo mkdir -p /mnt/caja-raid/vaults3/{data,metadata}
mkdir -p ~/Proyectos/vaults3
docker pull eniz1806/vaults3:latest
docker image inspect eniz1806/vaults3:latest --format 'User={{.Config.User}} | Volumes={{json .Config.Volumes}} | Ports={{json .Config.ExposedPorts}}'
docker run --rm --entrypoint id eniz1806/vaults3:latest
User=vaults3 | Volumes=null | Ports={"9000/tcp":{}}
uid=1000(vaults3) gid=1000(vaults3) groups=1000(vaults3)

¿Por qué un docker run si todo va con Compose? Ese docker run --rm no es el despliegue: arranca un contenedor desechable solo para preguntarle a la imagen con qué usuario corre, y se borra al terminar. Saber el UID antes permite dejar bien los permisos de las carpetas.

La imagen corre como UID 1000, no como root. Asigné ese dueño a las carpetas del RAID:

sudo chown -R 1000:1000 /mnt/caja-raid/vaults3
sudo chmod 750 /mnt/caja-raid/vaults3 /mnt/caja-raid/vaults3/{data,metadata}
ls -ln /mnt/caja-raid/vaults3

En mi caso, mi usuario jmmv también es UID 1000, así que puedo inspeccionar esas carpetas sin sudo.

Paso 3: secretos en .env

Generé las credenciales del administrador y la clave de cifrado directamente en el archivo, con permisos restrictivos y sin que pasen por la pantalla:

cd ~/Proyectos/vaults3
umask 077
cat > .env <<EOF
VAULTS3_ACCESS_KEY=mauro-admin
VAULTS3_SECRET_KEY=$(openssl rand -base64 30 | tr -d '/+=')
VAULTS3_ENCRYPTION_KEY=$(openssl rand -hex 32)
EOF
ls -l .env
-rw------- 1 jmmv jmmv 179 oct  8 15:44 .env

Para verificar que quedaron bien, sin mostrar los valores, revisé solo las longitudes:

awk -F= '{print $1, length($2)}' .env
VAULTS3_ACCESS_KEY 11
VAULTS3_SECRET_KEY 40
VAULTS3_ENCRYPTION_KEY 64

El tr -d '/+=' quita caracteres que pueden dar problemas en algunos clientes, a cambio de una longitud variable (40 en mi caso). Una alternativa más simple es openssl rand -hex 32 también para el secret.

VAULTS3_ENCRYPTION_KEY (64 caracteres hexadecimales) activa el cifrado en reposo. Lo decidí desde el inicio porque:

  • A favor: si un disco o la caja salen de mi casa, los objetos no se pueden leer.
  • En contra: si pierdo esa clave, pierdo los datos, aunque el RAID esté perfecto. Por eso el respaldo de la clave tiene su propia sección en el segundo artículo.

Paso 4: el docker-compose.yml

services:
  vaults3:
    image: eniz1806/vaults3@sha256:c14f48e6543c9310261eec322fffb8fb04b3fd7b39b85570a7dc91dbb35d54c7
    container_name: vaults3
    env_file: .env
    environment:
      VAULTS3_LOG_LEVEL: info
    ports:
      - "9000:9000"          # más adelante lo cambié: ver "Revisión posterior"
    volumes:
      - type: bind
        source: /mnt/caja-raid/vaults3/data
        target: /data
        bind:
          create_host_path: false
      - type: bind
        source: /mnt/caja-raid/vaults3/metadata
        target: /metadata
        bind:
          create_host_path: false
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:9000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

Lo que hace cada parte:

  • image: …@sha256:…: fijé la imagen por digest. VaultS3 publica etiquetas por versión (por ejemplo 5.0.2) además de latest; yo partí de latest y no quería que una actualización me cambiara el servicio sin aviso. [CORRECCIÓN: decía “VaultS3 publica principalmente la etiqueta latest”; cada release publica también la imagen con su etiqueta de versión (docker pull eniz1806/vaults3:5.0.2). Fuente: GitHub, release v5.0.2 de Kodiqa-Solutions/VaultS3, consultado el 11/10/2026] Si sigues esta guía, usa el digest de la imagen que tú descargues (docker pull lo muestra).
  • Bind mounts con create_host_path: false: este es el candado. Impide que Docker cree automáticamente las rutas de origen si no existen. Como con el RAID desmontado esas rutas no existen, Docker se niega a crear el contenedor en lugar de crear las carpetas en el NVMe. Por sí sola, la opción no verifica que una ruta existente pertenezca al volumen correcto; por eso la complementé con la verificación del montaje en raid-on.sh y con un segundo candado (ver “Revisión posterior”).
  • ports: "9000:9000": publica el puerto en todas las interfaces del NUC. Lo dejé así al principio y lo corregí en la revisión posterior.
  • cap_drop: ALL y no-new-privileges: endurecimiento básico. Como corre sin root y en un puerto mayor a 1024, no necesita capabilities.
  • healthcheck: usa el endpoint /health de VaultS3.

¿compose.yaml o docker-compose.yml? La especificación actual de Compose prefiere compose.yaml, pero docker-compose.yml sigue funcionando. Yo uso el segundo por costumbre. Lo importante es tener uno solo: si existen los dos, Compose usa compose.yaml.

Validé el archivo y revisé que el puerto estuviera libre:

docker compose config --quiet && echo "compose OK"
sudo ss -tlnp | grep ':9000' || echo "puerto 9000 libre"

Paso 5: levantar el servicio

docker compose up -d
docker compose ps
docker compose logs --tail=30
curl -s -o /dev/null -w "health=%{http_code}\n" http://localhost:9000/health
curl -s -o /dev/null -w "ready=%{http_code}\n"  http://localhost:9000/ready
ls -la /mnt/caja-raid/vaults3/metadata
vaults3   ...   Up 18 seconds (healthy)   0.0.0.0:9000->9000/tcp
level=INFO msg="SSE-S3 encryption enabled" algorithm=AES-256-GCM
level=INFO msg="per-bucket encryption key management enabled"
level=INFO msg="VaultS3 starting" addr=0.0.0.0:9000 data_dir=/data metadata_dir=/metadata
level=INFO msg="encryption enabled" algorithm=AES-256-GCM
health=200
ready=200
-rw------- 1 jmmv jmmv 16805888 oct  8 15:50 vaults3.db

Todo en orden:

  • El contenedor quedó healthy.
  • El cifrado está activo.
  • La base de metadatos (vaults3.db, un BoltDB) se escribe en el RAID, no en el NVMe.
  • El endurecimiento con cap_drop: ALL no rompió nada.

Paso 6: probar el candado

Con el servicio vacío era el momento ideal para comprobar que el candado funciona:

docker compose down
sudo umount /mnt/caja-raid
docker compose up -d
✘ service:vaults3:1  Error response from daemon: invalid mount config for type "bind":
  bind source path does not exist: /mnt/caja-raid/vaults3/data
docker ps -a --filter name=vaults3   # no aparece ningún contenedor
ls /mnt/caja-raid                    # vacío: no se creó nada en el NVMe
sudo mount /mnt/caja-raid
docker compose up -d                 # ahora sí: healthy

Funciona tal como esperaba: sin RAID no hay VaultS3, y el disco del sistema queda intacto. Un efecto secundario útil: si el NUC se reinicia con la caja apagada, Docker intenta arrancar VaultS3, falla y queda detenido hasta que yo lo active. Ojo: restart: unless-stopped solo se ocupa del contenedor; no sabe nada del RAID. Y ante un corte de luz con la caja encendida, el arreglo no se detiene limpiamente, así que lo esperable es un resync la próxima vez que se ensamble.

Paso 7: el dashboard y el primer bucket

El dashboard queda en http://192.168.1.40:9000/dashboard/ y se ingresa con la access key y el secret del administrador.

Dashboard de VaultS3 recién instalado

Para probar desde mi MacBook Air instalé la AWS CLI (brew install awscli) y creé un perfil dedicado, para que las credenciales no queden en el historial del shell:

aws configure --profile vaults3

Después había que agregar el endpoint del NUC al perfil en ~/.aws/config.

Error: el heredoc que nunca terminaba

Mi primer intento fue agregar las líneas con un heredoc copiado de una lista indentada:

~ cat >> ~/.aws/config <<'EOF'
   endpoint_url = http://192.168.1.40:9000
   s3 =
     addressing_style = path
   EOF
heredoc> tail -6 ~/.aws/config
heredoc>
  • Causa: al copiar, el EOF de cierre quedó con espacios delante, así que el shell no lo reconoce como fin del heredoc y sigue esperando. Además, en el formato del archivo de configuración de AWS, una línea que empieza con espacios se interpreta como continuación de la anterior, así que aunque hubiera cerrado, el archivo habría quedado mal.
  • Solución: Ctrl + C para cancelar (no se escribe nada) y editar el archivo con un editor. Así debe quedar, sin espacios al inicio, salvo addressing_style:
[profile vaults3]
region = us-east-1
output = json
endpoint_url = http://192.168.1.40:9000
s3 =
  addressing_style = path
  • addressing_style = path hace que las URLs sean http://servidor:9000/bucket/objeto, en lugar de http://bucket.servidor:9000/objeto, que necesitaría DNS comodín.

Con eso, la primera prueba de punta a punta:

aws --profile vaults3 s3 mb s3://prueba
echo "hola vaults3 $(date)" > /tmp/prueba.txt
aws --profile vaults3 s3 cp /tmp/prueba.txt s3://prueba/
aws --profile vaults3 s3 ls s3://prueba/
make_bucket: prueba
upload: ../../tmp/prueba.txt to s3://prueba/prueba.txt
2026-10-08 16:01:33         57 prueba.txt

Paso 8: verificar el cifrado en disco

No quería confiar solo en el log que decía “encryption enabled”. Busqué el texto en el disco:

sudo grep -r "hola vaults3" /mnt/caja-raid/vaults3/data && echo "⚠️ EN CLARO" || echo "✅ cifrado"
✅ cifrado

Que grep no lo encuentre también podría deberse a compresión, no a cifrado. Así que miré los primeros bytes del archivo:

sudo find /mnt/caja-raid/vaults3/data -type f -name '*prueba*' \
  -exec sh -c 'file "$1"; head -c 32 "$1" | xxd' _ {} \;
/mnt/caja-raid/vaults3/data/prueba/prueba.txt: data
00000000: 5653 3353 0200 0000 0000 1000 00b2 23dd  VS3S..........#.
00000010: 9b63 ab1f b819 949b ccea 9d6a f645 3851  .c.........j.E8Q
  • Empieza con VS3S, que parece la cabecera del formato cifrado de VaultS3, seguida de bytes aleatorios.
  • No empieza con 1f 8b, que sería gzip.

Y la ida y vuelta devolvió el texto original:

aws --profile vaults3 s3 cp s3://prueba/prueba.txt -
hola vaults3 jueves,  8 de octubre de 2026, 16:01:24 -05

Con esto tengo tres tipos de evidencia, de distinta fuerza:

  1. El servicio dice que el cifrado está activo (los logs).
  2. El archivo en disco tiene el formato esperado y no contiene el texto en claro.
  3. La lectura por la API devuelve el contenido original.

Es una verificación práctica de que el cifrado está funcionando, no una auditoría criptográfica.

Un límite importante: el cifrado protege el contenido, no los nombres. En disco se ve en claro la ruta data/prueba/prueba.txt, es decir, el bucket y el nombre del objeto. Si el nombre de un archivo es sensible, conviene usar nombres neutros o cifrarlo antes de subirlo (por ejemplo, con restic, que cifra también los nombres).

Paso 9: scripts para activar y desactivar el RAID

Como la caja se enciende solo cuando la necesito, el ciclo manual tenía varios pasos que había que hacer en orden:

  • Activar: ensamblar md0, montar y levantar VaultS3.
  • Desactivar: bajar VaultS3, desmontar y ejecutar mdadm --stop.

Olvidar el último paso es justamente lo que provocó el resync del Paso 1. Así que lo automaticé en dos scripts.

raid-on.sh:

  • Espera hasta 30 s a que aparezcan los dos discos (por serial en /dev/disk/by-id/), lo que evita el arreglo degradado por la carrera de udev.
  • Ensambla, verifica [UU] y aborta si el arreglo está degradado.
  • Monta y confirma que el montaje sea sobre md0.
  • Levanta VaultS3 y espera a que esté healthy; si no lo logra, termina con error.
#!/usr/bin/env bash
# raid-on.sh — Activa el RAID 1 (md0) de la caja USB y levanta VaultS3.
# Uso: raid-on.sh            (aborta si falta un disco o el arreglo queda degradado)
#      FORCE=1 raid-on.sh    ⚠ SOLO EMERGENCIAS: continúa aunque falte un disco o el
#                            arreglo esté degradado [_U]. Se trabaja SIN redundancia:
#                            si falla el disco restante, se pierden los datos.
# Código de salida: 0 = todo OK; 1 = algo falló (el mensaje indica en qué estado quedó).
set -euo pipefail

# --- Configuración ---------------------------------------------------------
MD_DEV="/dev/md0"
MNT="/mnt/caja-raid"
COMPOSE_FILE="$HOME/Proyectos/vaults3/docker-compose.yml"
CONTAINER="vaults3"
DISK_SERIALS=("5YD9K4R1" "YFG7T2WQ")   # Seagate ST2000DL003 / Hitachi HUA723020ALA641
WAIT_DISKS=30                          # segundos máx. esperando a que aparezcan los discos
WAIT_HEALTH=60                         # segundos máx. esperando a que VaultS3 esté healthy
# ---------------------------------------------------------------------------

info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
ok()   { printf '\033[1;32m ✔\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31m ✘\033[0m %s\n' "$*" >&2; exit 1; }

# true si existe /dev/disk/by-id/*_<serial> (el disco completo, no sus particiones)
disk_present() { compgen -G "/dev/disk/by-id/*_$1" >/dev/null; }

# Pedir sudo una sola vez al inicio
sudo -v || die "Se necesita sudo."

# 1) Esperar a que ambos discos estén enumerados (evita el arreglo degradado [_U])
info "Esperando los discos de la caja (máx. ${WAIT_DISKS}s)..."
for ((i = 0; i < WAIT_DISKS; i++)); do
  missing=()
  for s in "${DISK_SERIALS[@]}"; do disk_present "$s" || missing+=("$s"); done
  [[ ${#missing[@]} -eq 0 ]] && break
  sleep 1
done
if [[ ${#missing[@]} -gt 0 ]]; then
  [[ "${FORCE:-0}" == "1" ]] || die "Faltan discos con serial: ${missing[*]}. ¿Está encendida la caja? (FORCE=1 para continuar)"
  warn "Faltan discos: ${missing[*]} — continuando por FORCE=1"
else
  ok "Ambos discos presentes."
fi

# 2) Ensamblar el arreglo si no está activo
if grep -q "^${MD_DEV##*/} : active" /proc/mdstat; then
  ok "${MD_DEV} ya estaba activo."
else
  info "Ensamblando ${MD_DEV}..."
  sudo mdadm --assemble --scan || true
  grep -q "^${MD_DEV##*/} : active" /proc/mdstat || die "No se pudo ensamblar ${MD_DEV}. Revisa: cat /proc/mdstat"
  ok "${MD_DEV} ensamblado."
fi

# 3) Verificar estado del arreglo
status_line=$(grep -A1 "^${MD_DEV##*/} :" /proc/mdstat | tail -1)
if [[ "$status_line" != *"[UU]"* ]]; then
  [[ "${FORCE:-0}" == "1" ]] || die "Arreglo DEGRADADO: ${status_line}. No se monta. (FORCE=1 para montar igual)"
  warn "Arreglo degradado: ${status_line} — montando por FORCE=1"
else
  ok "Arreglo sano [UU]."
fi
grep -qE 'resync|recovery' /proc/mdstat && warn "Hay un resync/recovery en curso; funciona, pero más lento."

# 4) Montar
if findmnt -rn -S "$MD_DEV" -T "$MNT" >/dev/null 2>&1; then
  ok "${MNT} ya estaba montado."
else
  info "Montando ${MNT}..."
  sudo mount "$MNT"
  [[ "$(findmnt -rn -o SOURCE -T "$MNT")" == "$MD_DEV" ]] || die "${MNT} no quedó montado sobre ${MD_DEV}."
  ok "${MNT} montado."
fi

# 5) Levantar VaultS3
info "Levantando VaultS3..."
docker compose -f "$COMPOSE_FILE" up -d
health="none"
for ((i = 0; i < WAIT_HEALTH; i += 3)); do
  health=$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo "none")
  [[ "$health" == "healthy" ]] && break
  sleep 3
done
if [[ "$health" != "healthy" ]]; then
  warn "El RAID quedó MONTADO y el contenedor puede seguir iniciado."
  warn "Diagnóstico: docker compose -f $COMPOSE_FILE logs --tail=50"
  warn "Para revertir todo: raid-off.sh"
  die "VaultS3 no llegó a 'healthy' en ${WAIT_HEALTH}s (estado: '${health}')."
fi
ok "VaultS3 healthy."

# 6) Resumen
echo
grep -A3 "^${MD_DEV##*/}" /proc/mdstat
df -h "$MNT" | tail -1
docker compose -f "$COMPOSE_FILE" ps

raid-off.sh:

  • Se niega a continuar si hay un resync en curso.
  • Baja VaultS3, desmonta (y si algo bloquea el desmontaje, muestra qué proceso es) y detiene el arreglo.
#!/usr/bin/env bash
# raid-off.sh — Detiene VaultS3, desmonta y detiene el RAID 1 (md0)
#               para poder apagar la caja USB o el NUC de forma segura.
# Uso: raid-off.sh           (aborta si hay un resync/recovery en curso)
#      FORCE=1 raid-off.sh   ⚠ detiene aunque haya un resync en curso. Con el
#                            write-intent bitmap suele retomarse donde quedó, pero
#                            no es un procedimiento normal: úsalo solo si es necesario.
set -euo pipefail

# --- Configuración ---------------------------------------------------------
MD_DEV="/dev/md0"
MNT="/mnt/caja-raid"
COMPOSE_FILE="$HOME/Proyectos/vaults3/docker-compose.yml"
# ---------------------------------------------------------------------------

info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
ok()   { printf '\033[1;32m ✔\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31m ✘\033[0m %s\n' "$*" >&2; exit 1; }

sudo -v || die "Se necesita sudo."

# 1) No interrumpir un resync salvo que se fuerce
if grep -A3 "^${MD_DEV##*/} :" /proc/mdstat 2>/dev/null | grep -qE 'resync|recovery'; then
  progress=$(grep -A3 "^${MD_DEV##*/} :" /proc/mdstat | grep -oE '(resync|recovery) = [0-9.]+%.*finish=[0-9.]+min' || true)
  [[ "${FORCE:-0}" == "1" ]] || die "Resync/recovery en curso (${progress}). Espera a que termine o usa FORCE=1."
  warn "Interrumpiendo resync por FORCE=1 (${progress})."
fi

# 2) Detener VaultS3
info "Deteniendo VaultS3..."
docker compose -f "$COMPOSE_FILE" down
ok "VaultS3 detenido."

# 3) Desmontar
if findmnt -rn -T "$MNT" -S "$MD_DEV" >/dev/null 2>&1; then
  info "Sincronizando y desmontando ${MNT}..."
  sync
  if ! sudo umount "$MNT"; then
    warn "Algo sigue usando ${MNT}:"
    sudo fuser -vm "$MNT" || true
    die "No se pudo desmontar. Cierra esos procesos (o contenedores) y vuelve a ejecutar."
  fi
  ok "${MNT} desmontado."
else
  ok "${MNT} no estaba montado."
fi

# 4) Detener el arreglo
if [[ -e "$MD_DEV" ]] && grep -q "^${MD_DEV##*/} :" /proc/mdstat; then
  info "Deteniendo ${MD_DEV}..."
  sudo mdadm --stop "$MD_DEV"
  ok "${MD_DEV} detenido."
else
  ok "${MD_DEV} no estaba activo."
fi

# 5) Verificación final
if grep -q "^${MD_DEV##*/} :" /proc/mdstat; then
  die "${MD_DEV} sigue apareciendo en /proc/mdstat."
fi
echo
ok "Listo. Ya puedes apagar la caja o el NUC."

Ambos piden sudo una sola vez. Con FORCE=1 delante del comando se saltan las protecciones, y no es un procedimiento normal:

  • En raid-on.sh, significa montar aunque falte un disco o el arreglo esté degradado: se trabaja sin redundancia, y si falla el disco restante, se pierden los datos.
  • En raid-off.sh, significa interrumpir un resync en curso.

raid-on.sh termina con código de salida distinto de cero si algo falla, incluso si VaultS3 no llega a healthy. En ese caso avisa que el RAID quedó montado, para que el diagnóstico quede en manos de quien lo opera.

Instalación:

mkdir -p ~/bin
mv raid-on.sh raid-off.sh ~/bin/
chmod +x ~/bin/raid-on.sh ~/bin/raid-off.sh

Los seriales de los discos del script son de ejemplo. Obtén los tuyos con lsblk -o NAME,MODEL,SERIAL.

Prueba: la protección contra el resync

El resync del Paso 1 seguía corriendo, así que la primera prueba fue ver si raid-off.sh se negaba:

 ✘ Resync/recovery en curso (resync = 72.8% (1423784064/1953382464) finish=124.9min). Espera a que termine o usa FORCE=1.

Prueba: el ciclo completo, forzando la salida

Después lo forcé a propósito, para ver qué pasaba con el resync:

$ FORCE=1 ./raid-off.sh
 ! Interrumpiendo resync por FORCE=1 (resync = 73.1% ...).
==> Deteniendo VaultS3...
 ✔ VaultS3 detenido.
==> Sincronizando y desmontando /mnt/caja-raid...
 ✔ /mnt/caja-raid desmontado.
==> Deteniendo /dev/md0...
mdadm: stopped /dev/md0
 ✔ Listo. Ya puedes apagar la caja o el NUC.

$ ./raid-on.sh
==> Esperando los discos de la caja (máx. 30s)...
 ✔ Ambos discos presentes.
==> Ensamblando /dev/md0...
mdadm: /dev/md0 has been started with 2 drives.
 ✔ Arreglo sano [UU].
 ! Hay un resync/recovery en curso; funciona, pero más lento.
 ✔ /mnt/caja-raid montado.
 ✔ VaultS3 healthy.

md0 : active raid1 sdb[0] sdc[1]
      [==============>......]  resync = 73.1% ...

Lo más interesante: el resync se reanudó en 73,1 %, no volvió a 0 %. Eso se debe al write-intent bitmap interno del arreglo (Intent Bitmap : Internal en mdadm --detail), que registra qué zonas faltan por sincronizar. En mi prueba funcionó bien, pero no lo generalizaría: el bitmap limita lo que hay que resincronizar, no convierte cualquier interrupción en algo inocuo. FORCE=1 queda para emergencias.

Al día siguiente el arreglo estaba en State : clean, con los dos discos en active sync.

Revisión posterior: endureciendo el montaje

Con el artículo en borrador, pedí una segunda opinión técnica. Varias observaciones eran válidas y justificaban cambios en el NUC, no solo en el texto. Para entonces ya había configurado el acceso que cuento en el segundo artículo: por eso en estas pruebas aparecen los buckets personal y trabajo y el perfil con la llave de mi equipo.

El puerto 9000 escuchaba en todas las interfaces

"9000:9000" publica el puerto en 0.0.0.0 (y en IPv6), es decir, en todas las interfaces del NUC. Dos detalles lo hacen más delicado de lo que parece:

  • Los puertos que publica Docker no pasan por el firewall del host como uno esperaría: Docker inserta sus propias reglas, así que una regla de ufw o firewalld no los bloquea necesariamente.
  • La cuenta de administrador no tiene restricción por IP (la restricción del segundo artículo es para el usuario de los equipos), y el dashboard comparte el puerto con la API.

Como el NUC tiene IP fija, publiqué el puerto solo en la IP de la LAN:

    ports:
      - "192.168.1.40:9000:9000"
docker compose up -d
sudo ss -tlnp | grep ':9000'
curl -s -o /dev/null -w "LAN=%{http_code}\n"       --max-time 5 http://192.168.1.40:9000/health
curl -s -o /dev/null -w "localhost=%{http_code}\n" --max-time 5 http://localhost:9000/health
LISTEN 0  4096  192.168.1.40:9000  0.0.0.0:*  users:(("docker-proxy",...))
LAN=200
localhost=000

Ya no escucha en 0.0.0.0 ni en IPv6. El healthcheck interno, vaults3-cli vía docker exec y los scripts siguen funcionando igual; lo único que cambia es que el dashboard ya no responde en localhost desde el propio NUC. Si tu servidor no tiene IP fija, no hagas esto: el contenedor no arrancaría al cambiar la IP.

Error: docker compose down -v por costumbre

Para recrear el contenedor ejecuté docker compose down -v. No pasó nada porque uso bind mounts al RAID, pero -v borra los volúmenes con nombre: en un stack con volúmenes Docker (una base de datos, por ejemplo) habría borrado los datos. Para aplicar un cambio de puertos basta con docker compose up -d. Lo verifiqué igual:

docker exec vaults3 vaults3-cli bucket list
NAME      CREATED
personal  2026-10-08 21:12:46
trabajo   2026-10-08 21:12:55

raid-on.sh no fallaba si VaultS3 no quedaba sano

La primera versión mostraba una advertencia si el contenedor no llegaba a healthy, pero terminaba con código 0. Ahora termina con error y deja claro el estado. Este es el mensaje que muestra en ese caso:

 ! El RAID quedó MONTADO y el contenedor puede seguir iniciado.
 ! Diagnóstico: docker compose -f .../docker-compose.yml logs --tail=50
 ! Para revertir todo: raid-off.sh
 ✘ VaultS3 no llegó a 'healthy' en 60s (estado: 'unhealthy').

No hice que desmonte automáticamente: prefiero que falle de forma explícita y decidir yo. Los scripts de la sección anterior ya son esta versión.

Un segundo candado: chattr +i

create_host_path: false evita que Docker cree las rutas, pero si alguien (o un script) creara /mnt/caja-raid/vaults3/data en el NVMe con el RAID desmontado, el candado no lo detectaría. La solución clásica es hacer inmutable el punto de montaje vacío:

./raid-off.sh
ls -la /mnt/caja-raid               # vacío
sudo chattr +i /mnt/caja-raid
lsattr -d /mnt/caja-raid
----i---------e------- /mnt/caja-raid

Las pruebas negativas:

$ sudo mkdir -p /mnt/caja-raid/vaults3/data
mkdir: cannot create directory '/mnt/caja-raid/vaults3': Operation not permitted

$ docker compose up -d
Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /mnt/caja-raid/vaults3/data

Ni siquiera root puede crear nada ahí sin quitar antes el atributo. Y montar encima funciona normal:

$ ./raid-on.sh
 ✔ Ambos discos presentes.
 ✔ Arreglo sano [UU].
 ✔ /mnt/caja-raid montado.
 ✔ VaultS3 healthy.

$ lsattr -d /mnt/caja-raid
--------------e------- /mnt/caja-raid

Después de montar, lsattr ya no muestra la i porque está mostrando la raíz del RAID; el directorio del NVMe sigue inmutable por debajo.

Prueba de persistencia

Había probado que los buckets sobrevivían a los reinicios, pero no un objeto. Aproveché el mismo ciclo de apagado para probarlo con un archivo aleatorio de 5 MB.

Error: head -c 5M en macOS

$ head -c 5M /dev/urandom > /tmp/persistencia.bin
head: illegal byte count -- 5M
  • Causa: el head de macOS (BSD) no acepta sufijos como 5M; eso es de la versión GNU de Linux.
  • Solución: indicar los bytes exactos.
head -c 5242880 /dev/urandom > /tmp/persistencia.bin
shasum -a 256 /tmp/persistencia.bin
aws --profile vaults3 s3 cp /tmp/persistencia.bin s3://personal/
23de53c9f7e78878543a56410bbe6defb84c6b4e6aa494185fe0167385c6d2ee  /tmp/persistencia.bin

Después de raid-off.sh, del chattr y de raid-on.sh, lo descargué de nuevo:

aws --profile vaults3 s3 cp s3://personal/persistencia.bin /tmp/persistencia-check.bin
shasum -a 256 /tmp/persistencia-check.bin
23de53c9f7e78878543a56410bbe6defb84c6b4e6aa494185fe0167385c6d2ee  /tmp/persistencia-check.bin

Hash idéntico: el objeto sobrevivió al ciclo completo de detener el servicio, desmontar, detener el arreglo, reensamblar y volver a levantar. Al final borré los archivos de prueba.

La versión fijada por digest

Fijar la imagen por digest da reproducibilidad, pero también puede dejarte en una versión vieja con fallas de seguridad corregidas después. Comparé la versión que muestra el dashboard (5.0.2) con la última publicada en GitHub: era la misma. Cuando salga una nueva, el procedimiento es leer las notas de versión, cambiar el digest y repetir las pruebas de cifrado, persistencia y permisos.

Errores y soluciones

ErrorCausaSolución
Resync inesperado al arrancar (not clean)La caja se apagó sin mdadm --stopUsar siempre raid-off.sh antes de apagar
df muestra / en lugar del RAIDEl RAID no estaba montado (noauto)Montar antes de levantar el servicio y usar el candado create_host_path: false
El heredoc no termina (heredoc>)El EOF de cierre quedó indentado al copiarCtrl + C y editar el archivo con un editor
Riesgo de escribir en el NVMeDocker crea las rutas de bind mount que no existencreate_host_path: false en cada volumen
Una carpeta “impostora” en el NVMe burlaría el candadocreate_host_path: false no verifica a qué disco pertenece una ruta existentechattr +i en el punto de montaje vacío y verificación de findmnt en raid-on.sh
Puerto 9000 en 0.0.0.0"9000:9000" publica en todas las interfacesPublicar solo en la IP de la LAN: "192.168.1.40:9000:9000"
raid-on.sh terminaba en 0 con VaultS3 sin healthyLa espera solo advertíaTerminar con error y explicar en qué estado quedó
Casi un down -v peligroso-v borra los volúmenes con nombreUsar docker compose up -d para aplicar cambios
head: illegal byte count -- 5MEl head de macOS no acepta sufijosUsar bytes exactos: head -c 5242880

Resultado

Al final de esta primera etapa tenía:

  • VaultS3 corriendo en Docker Compose, con la imagen fijada por digest y endurecida, escuchando solo en la LAN.
  • Datos y metadatos en el RAID 1, con dos candados (create_host_path: false y chattr +i) que impiden escribir en el disco del sistema.
  • Cifrado en reposo verificado con tres tipos de evidencia.
  • Persistencia de los objetos comprobada tras un ciclo completo de apagado.
  • Dos scripts que hacen seguro y repetible el ciclo de encender y apagar la caja, y que fallan de forma explícita.

En el segundo artículo cuento cómo organicé el acceso desde mis tres Mac con un solo usuario, cómo limité el equipo corporativo a un bucket, las restricciones por IP y cómo respaldé la clave de cifrado.