Bye-bye Docker Hub, time to self-host all of my Docker images

As with all my other services, such as my Git repositories, I've been slowly moving them to my own self-hosted homelab. Today, it is my Docker images' turn.

Gitea includes a built-in Docker registry, so I can host Docker and other OCI-compliant images without relying on Docker Hub or dealing with its rate limits.

The MVP ("most valuable player") of this whole thing is a tool called skopeo. It is a command-line tool for working with container images and registries, and it does not require root access or a running Docker daemon.

I used skopeo and a small Bash script to migrate some of my Docker images from Docker Hub to Gitea. Gitea enables its package registry by default, so this only covers moving the images. The registry operations are not specific to Gitea, although the script uses the Docker Hub API to discover source repositories.

Skopeo cannot fetch the full list of images in a namespace, so the script uses the Docker Hub API to get that list before passing each image to skopeo. It uses skopeo sync rather than skopeo copy because sync copies all tags without another API call to list them.

#!/bin/bash

## Variables to change
# TODO: these shouldn't be hardcoded, but rather passed in as arguments or via env vars
#       but that's a future problem

# Docker Hub credentials
HUB_NAMESPACE="<namespace>" # the user/org namespace on Docker Hub that you want to bring over
HUB_USERNAME="<username>"
HUB_PASSWORD="<password>"

GITEA_DOMAIN="<domain>" # e.g. gitea.example.com
GITEA_PACKAGE_NAMESPACE="<namespace>" # the user/org that you wish to store the images under
GITEA_USERNAME="<username>" # user that has access to the package registry namespace
GITEA_TOKEN="<password>" # token needs the packages:write permission

# Function to check if a command exists
command_exists() {
    command -v "$1" >/dev/null 2>&1
}

# Check if jq and skopeo are installed
if ! command_exists jq || ! command_exists skopeo; then
    echo "either jq or skopeo is missing, please make sure they are both installed" >&2
    exit 1
fi

# login into Docker Hub and fetch an API token
# its possible to do this without the token, but you'll likely run into ratelimits, and it would also only provide public images
TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d '{"username": "'${HUB_USERNAME}'", "password": "'${HUB_PASSWORD}'"}' https://hub.docker.com/v2/users/login/ | jq -r .token)

# Get list 100 of images that the namespace has, if you have more than that then pagination will need to be added
REPOS=$(curl -s -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${HUB_NAMESPACE}/?page_size=100" | jq -r '.results[].name')
# TODO: The fetching of images above is Docker Hub API specific, and so if your source is non-Docker Hub this will need to be adjusted

# Loop through images and pass them to skopeo
for repo in $REPOS; do
    echo "Syncing ${HUB_NAMESPACE}/${repo} to ${GITEA_DOMAIN}/${GITEA_PACKAGE_NAMESPACE}/${repo}"
    skopeo sync \
        --src docker --src-creds ${HUB_USERNAME}:${HUB_PASSWORD} \
        --dest docker --dest-creds ${GITEA_USERNAME}:${GITEA_TOKEN} \
        "docker.io/${HUB_NAMESPACE}/${repo}" \
        "${GITEA_DOMAIN}/${GITEA_PACKAGE_NAMESPACE}"
done

echo "Finished moving images!"

Changing HUB_NAMESPACE lets the same script copy another Docker Hub namespace, such as Bitnami.

The script worked for my namespace, which had fewer than 100 images. It still needs argument or environment-variable handling, error handling, and Docker Hub API pagination for larger namespaces. If you modify it, I'd love to hear about it.

If you want to read more about the skopeo sync options, you can check out the skopeo sync documentation.

Disclaimer: I am a maintainer of Gitea. Skopeo can use other registries, so the migration does not require Gitea as the destination.