Docker Registry (el proyecto distribution) es el servidor que almacena y sirve imágenes de contenedores. Por defecto guarda las capas en el disco local, pero puede usar cualquier almacenamiento compatible con S3, lo que separa el servicio de los datos y facilita crecer o replicar. En este tutorial desplegarás en Ubuntu 24.04 un registro privado con Docker Compose que guarda las imágenes en un bucket S3 autoalojado, lo publicarás con HTTPS mediante Caddy, protegerás el acceso con usuario y contraseña y programarás la recolección de basura.

Durante años la opción habitual para el backend S3 era MinIO, pero el repositorio de la edición comunitaria está archivado y ya no se publican binarios ni imágenes nuevas. Esta guía usa Garage, un servidor de objetos compatible con S3, ligero y mantenido activamente, que ofrece imagen oficial de contenedor. La configuración del registro es la misma para cualquier backend S3, así que si ya tienes otro servicio compatible solo tendrás que cambiar el endpoint y las credenciales del paso 4.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 2 GB de RAM y espacio en disco suficiente para tus imágenes.
  • Un usuario no root con privilegios sudo.
  • Docker Engine y el plugin Docker Compose instalados desde el repositorio oficial de Docker.
  • Un dominio, por ejemplo registry.your_domain, con un registro DNS A que apunte a la IP pública del servidor (your_server_ip).
  • Los puertos 80 y 443 abiertos hacia el servidor.

A lo largo del tutorial sustituye registry.your_domain por tu dominio real.

Paso 1: Preparar el directorio del proyecto

Todo el despliegue vivirá en /opt/registry: la configuración de Garage, la del registro, el fichero de usuarios y los datos del bucket. Crea la estructura y asígnala a tu usuario para no necesitar sudo en cada edición:

sudo mkdir -p /opt/registry/{auth,garage/meta,garage/data}
sudo chown -R "$USER":"$USER" /opt/registry
cd /opt/registry

Instala también apache2-utils, que incluye htpasswd para generar el fichero de usuarios del registro, y openssl para crear secretos aleatorios:

sudo apt update
sudo apt install -y apache2-utils openssl

Paso 2: Configurar Garage

Garage necesita un secreto compartido para su protocolo interno (RPC), aunque solo tengas un nodo. Genéralo:

openssl rand -hex 32

Copia el valor y crea el fichero de configuración de Garage:

nano /opt/registry/garage.toml

Pega el siguiente contenido y sustituye your_rpc_secret por el valor generado:

metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"

replication_factor = 1

rpc_bind_addr = "0.0.0.0:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "your_rpc_secret"

[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"

replication_factor = 1 indica que hay una sola copia de cada objeto, lo correcto para un único servidor. La API S3 escucha en el puerto 3900 solo dentro de la red de Docker: no la publicarás al exterior porque el registro será el único cliente.

Protege el fichero, ya que contiene el secreto:

chmod 600 /opt/registry/garage.toml

Paso 3: Arrancar Garage y crear el bucket

Crea el fichero de Docker Compose. De momento solo define Garage; el registro se añade en el paso 5.

nano /opt/registry/compose.yaml
services:
  garage:
    image: dxflrs/garage:v2.1.0
    restart: unless-stopped
    volumes:
      - ./garage.toml:/etc/garage.toml:ro
      - ./garage/meta:/var/lib/garage/meta
      - ./garage/data:/var/lib/garage/data

Arranca el contenedor:

docker compose up -d garage

La imagen de Garage no incluye shell; el binario está en /garage. Comprueba el estado del nodo:

docker compose exec garage /garage status
==== HEALTHY NODES ====
ID                Hostname      Address         Tags  Zone  Capacity          DataAvail
3f2a9c81d7e4b605  8c1d2e3f4a5b  127.0.0.1:3901              NO ROLE ASSIGNED

El nodo funciona pero todavía no forma parte del clúster. Asígnale una zona y la capacidad que quieres dedicar al almacenamiento, usando el principio del ID que aparece en tu salida:

docker compose exec garage /garage layout assign -z dc1 -c 50G 3f2a9c81d7e4b605
docker compose exec garage /garage layout apply --version 1

Vuelve a ejecutar status: la columna Zone mostrará dc1 y la capacidad asignada.

Crea ahora el bucket y una clave de acceso exclusiva para el registro, y dale permisos de lectura y escritura sobre ese bucket:

docker compose exec garage /garage bucket create docker-registry
docker compose exec garage /garage key create registry-key
==== ACCESS KEY INFORMATION ====
Key ID:              GK1a2b3c4d5e6f7a8b9c0d1e2f
Key name:            registry-key
Secret key:          4f8e2d...c91a

Guarda el Key ID y el Secret key: los necesitarás en el siguiente paso. Después concede el acceso:

docker compose exec garage /garage bucket allow --read --write --owner docker-registry --key registry-key

Comprueba que la clave aparece asociada al bucket:

docker compose exec garage /garage bucket info docker-registry

La salida debe listar registry-key con permisos RWO.

Paso 4: Configurar Docker Registry

El registro se configura con un fichero YAML. Genera primero un secreto para firmar el estado de las subidas:

openssl rand -hex 32

Crea el fichero de configuración:

nano /opt/registry/config.yml

Sustituye your_key_id, your_secret_key y your_http_secret por tus valores:

version: 0.1

log:
  level: info
  formatter: json

storage:
  s3:
    accesskey: your_key_id
    secretkey: your_secret_key
    region: garage
    regionendpoint: http://garage:3900
    forcepathstyle: true
    bucket: docker-registry
    rootdirectory: /registry
  delete:
    enabled: true
  redirect:
    disable: true
  maintenance:
    uploadpurging:
      enabled: true
      age: 168h
      interval: 24h
      dryrun: false

http:
  addr: :5000
  secret: your_http_secret
  headers:
    X-Content-Type-Options: [nosniff]

auth:
  htpasswd:
    realm: Docker Registry
    path: /auth/htpasswd

Las claves importantes son:

  • region debe coincidir con s3_region de Garage y regionendpoint apunta al servicio garage por la red interna de Compose.
  • forcepathstyle: true usa URLs del tipo http://garage:3900/docker-registry/..., que Garage acepta sin configurar DNS por bucket.
  • redirect.disable: true es imprescindible aquí: sin ella, el registro redirige a los clientes a URLs firmadas del backend S3 para descargar capas, y garage:3900 no es accesible desde fuera del servidor.
  • delete.enabled: true permite borrar manifiestos por API, requisito para liberar espacio con la recolección de basura.

Protege el fichero, que contiene credenciales:

chmod 600 /opt/registry/config.yml

Crea el primer usuario del registro. La opción -B usa bcrypt, el único formato que acepta el registro; htpasswd te pedirá la contraseña:

htpasswd -Bc /opt/registry/auth/htpasswd your_user

Para añadir más usuarios después, repite el comando sin -c, que sobrescribiría el fichero.

Paso 5: Arrancar el registro

Añade el servicio registry al fichero Compose:

nano /opt/registry/compose.yaml

El fichero completo queda así:

services:
  garage:
    image: dxflrs/garage:v2.1.0
    restart: unless-stopped
    volumes:
      - ./garage.toml:/etc/garage.toml:ro
      - ./garage/meta:/var/lib/garage/meta
      - ./garage/data:/var/lib/garage/data

  registry:
    image: registry:3
    restart: unless-stopped
    depends_on:
      - garage
    ports:
      - "127.0.0.1:5000:5000"
    volumes:
      - ./config.yml:/etc/distribution/config.yml:ro
      - ./auth:/auth:ro

El puerto 5000 se publica solo en 127.0.0.1. Es importante porque Docker añade sus propias reglas de iptables y un puerto publicado en todas las interfaces quedaría expuesto aunque UFW lo bloquee. El acceso público llegará por HTTPS a través de Caddy.

Arranca el registro y revisa el log:

docker compose up -d registry
docker compose logs registry --tail 20

Busca una línea con listening on [::]:5000. Comprueba que responde y exige autenticación:

curl -i http://127.0.0.1:5000/v2/
HTTP/1.1 401 Unauthorized
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Basic realm="Docker Registry"

Paso 6: Publicar el registro con HTTPS mediante Caddy

El cliente de Docker exige HTTPS para cualquier registro que no sea local. Caddy obtiene y renueva automáticamente un certificado de Let's Encrypt, así que es la forma más sencilla de ponerlo delante. Instálalo desde su repositorio oficial:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

Sustituye el contenido del Caddyfile:

sudo nano /etc/caddy/Caddyfile
registry.your_domain {
	reverse_proxy 127.0.0.1:5000
}

Caddy envía la cabecera X-Forwarded-Proto, con la que el registro genera correctamente las URLs https:// de las subidas. No hay límite de tamaño de petición por defecto, así que las capas grandes no dan problemas.

Abre los puertos en el cortafuegos y recarga Caddy:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo systemctl reload caddy

Comprueba que el certificado se ha emitido y el registro responde por HTTPS:

curl -I https://registry.your_domain/v2/
HTTP/2 401
docker-distribution-api-version: registry/2.0
www-authenticate: Basic realm="Docker Registry"

Si la petición falla, revisa sudo journalctl -u caddy --no-pager -n 50: el error más habitual es que el registro DNS todavía no apunte al servidor.

Paso 7: Subir y descargar una imagen

Desde tu equipo o desde el propio servidor, inicia sesión con el usuario del paso 4:

docker login registry.your_domain
Login Succeeded

Descarga una imagen pequeña, etiquétala con el nombre de tu registro y súbela:

docker pull alpine:3.20
docker tag alpine:3.20 registry.your_domain/demo/alpine:3.20
docker push registry.your_domain/demo/alpine:3.20

Comprueba que el registro la conoce:

curl -u your_user https://registry.your_domain/v2/_catalog
{"repositories":["demo/alpine"]}

Y que las capas están realmente en el bucket de Garage y no en el disco del contenedor:

cd /opt/registry
docker compose exec garage /garage bucket info docker-registry

El campo de objetos y el tamaño deben ser mayores que cero. Para cerrar la prueba, borra la imagen local y descárgala desde el registro:

docker rmi registry.your_domain/demo/alpine:3.20
docker pull registry.your_domain/demo/alpine:3.20

Paso 8: Programar la recolección de basura

Borrar una etiqueta solo elimina la referencia al manifiesto; las capas siguen ocupando espacio en el bucket hasta que ejecutas el recolector de basura. El recolector no debe correr mientras alguien sube imágenes, porque podría borrar capas de una subida en curso, así que el procedimiento seguro es parar el registro, recolectar y volver a arrancarlo.

Para borrar una etiqueta usa skopeo, que resuelve el digest del manifiesto por ti:

sudo apt install -y skopeo
skopeo delete --creds your_user docker://registry.your_domain/demo/alpine:3.20

Crea un script que haga la recolección:

sudo nano /usr/local/sbin/registry-gc
#!/usr/bin/env bash
set -euo pipefail

cd /opt/registry

docker compose stop registry
trap 'docker compose start registry' EXIT

docker compose run --rm registry garbage-collect --delete-untagged /etc/distribution/config.yml

El trap garantiza que el registro vuelve a arrancar aunque la recolección falle. Hazlo ejecutable y pruébalo una vez a mano:

sudo chmod 755 /usr/local/sbin/registry-gc
sudo /usr/local/sbin/registry-gc

La salida lista los manifiestos y blobs marcados y termina con líneas Deleting blob si había capas sin referencias. Para programarlo cada domingo de madrugada, crea un servicio y un temporizador de systemd:

sudo nano /etc/systemd/system/registry-gc.service
[Unit]
Description=Docker Registry garbage collection
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/registry-gc
sudo nano /etc/systemd/system/registry-gc.timer
[Unit]
Description=Weekly Docker Registry garbage collection

[Timer]
OnCalendar=Sun *-*-* 04:00:00
Persistent=true

[Install]
WantedBy=timers.target

Activa el temporizador y comprueba la próxima ejecución:

sudo systemctl daemon-reload
sudo systemctl enable --now registry-gc.timer
systemctl list-timers registry-gc.timer

Los resultados de cada ejecución quedan en journalctl -u registry-gc.

Solución de problemas

  • no basic auth credentials o unauthorized al hacer push: ejecuta docker login registry.your_domain antes de subir y comprueba que el usuario se creó con htpasswd -B. Las contraseñas en MD5 o SHA no funcionan.
  • Las subidas se quedan en Retrying o el log muestra s3aws: ... connection refused: Garage no está listo o el nodo no tiene layout. Revisa docker compose exec garage /garage status y que regionendpoint sea http://garage:3900.
  • SignatureDoesNotMatch o AccessDenied: la region del registro no coincide con s3_region de Garage, o la clave no tiene permisos sobre el bucket (garage bucket info docker-registry).
  • Los docker pull fallan con timeouts a garage:3900: falta redirect.disable: true en config.yml.
  • El espacio no baja tras la recolección: solo se liberan capas de manifiestos borrados. Borra primero las etiquetas con skopeo delete y vuelve a ejecutar registry-gc.

Conclusión

Ya tienes un registro privado de imágenes con HTTPS y autenticación que guarda las capas en un bucket S3 autoalojado con Garage, y un temporizador que libera el espacio de las imágenes borradas cada semana. Como el registro solo depende de la API S3, puedes cambiar el backend editando el bloque storage.s3.

Como siguientes pasos puedes:

  • Añadir más nodos de Garage en otros servidores con replication_factor = 3 para tolerar la caída de uno.
  • Copiar el bucket a otra ubicación con rclone como copia de seguridad.
  • Configurar el registro como caché de Docker Hub con el bloque proxy en una instancia aparte, para acelerar las descargas de tus servidores.