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
Notafija siempre una versión concreta de la imagen. Consulta la última versión estable en la página de publicaciones de Garage (garagehq.deuxfleurs.fr) antes de desplegar.
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:
regiondebe coincidir cons3_regionde Garage yregionendpointapunta al serviciogaragepor la red interna de Compose.forcepathstyle: trueusa URLs del tipohttp://garage:3900/docker-registry/..., que Garage acepta sin configurar DNS por bucket.redirect.disable: truees imprescindible aquí: sin ella, el registro redirige a los clientes a URLs firmadas del backend S3 para descargar capas, ygarage:3900no es accesible desde fuera del servidor.delete.enabled: truepermite 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.
Notala imagen
registry:3lee la configuración de/etc/distribution/config.yml. En la versión 2 la ruta era/etc/docker/registry/config.yml; si montas el fichero en la ruta antigua, el registro arrancará con su configuración por defecto y guardará las capas en el disco del contenedor.
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 credentialsounauthorizedal hacer push: ejecutadocker login registry.your_domainantes de subir y comprueba que el usuario se creó conhtpasswd -B. Las contraseñas en MD5 o SHA no funcionan.- Las subidas se quedan en
Retryingo el log muestras3aws: ... connection refused: Garage no está listo o el nodo no tiene layout. Revisadocker compose exec garage /garage statusy queregionendpointseahttp://garage:3900. SignatureDoesNotMatchoAccessDenied: laregiondel registro no coincide cons3_regionde Garage, o la clave no tiene permisos sobre el bucket (garage bucket info docker-registry).- Los
docker pullfallan con timeouts agarage:3900: faltaredirect.disable: trueenconfig.yml. - El espacio no baja tras la recolección: solo se liberan capas de manifiestos borrados. Borra primero las etiquetas con
skopeo deletey vuelve a ejecutarregistry-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 = 3para tolerar la caída de uno. - Copiar el bucket a otra ubicación con
rclonecomo copia de seguridad. - Configurar el registro como caché de Docker Hub con el bloque
proxyen una instancia aparte, para acelerar las descargas de tus servidores.
