Skip to content

Instantly share code, notes, and snippets.

@LuisAlejandro
Last active December 3, 2025 00:05
Show Gist options
  • Select an option

  • Save LuisAlejandro/e73e7b6435e6c346104950f5587b8d3a to your computer and use it in GitHub Desktop.

Select an option

Save LuisAlejandro/e73e7b6435e6c346104950f5587b8d3a to your computer and use it in GitHub Desktop.
Docker Hub Untagged Image Cleanup Script
DH_USERNAME=
DH_PASSWORD=
DOCKER_HUB_COOKIE=""
#!/bin/bash
# Docker Hub Untagged Image Cleanup Script
# Finds and deletes untagged Docker images from Docker Hub repositories
set -e
source .env
# Configuration
USERNAME=${DH_USERNAME}
PASSWORD=${DH_PASSWORD}
REPO=${REPO:-""}
DOCKER_HUB_COOKIE=${DOCKER_HUB_COOKIE:-""}
MAX_UNTAGGED_LIMIT=${MAX_UNTAGGED_LIMIT:-100}
MAX_PAGINATION_REQUESTS=${MAX_PAGINATION_REQUESTS:-10}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1" >&2
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1" >&2
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
# Function to get bearer token for Registry API
get_token() {
local repo=$1
local token=$(curl -s -u "$USERNAME:$PASSWORD" \
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:$repo:pull,push,delete" |
jq -r .token)
if [ "$token" = "null" ] || [ -z "$token" ]; then
print_error "Failed to get authentication token for $repo. Check your credentials."
exit 1
fi
echo "$token"
}
# Function to list all tags for the repository
list_tags() {
local token=$1
local repo=$2
local tags=$(curl -s -H "Authorization: Bearer $token" \
"https://registry-1.docker.io/v2/$repo/tags/list" | jq -r '.tags[]?' 2>/dev/null)
echo "$tags"
}
# Function to get digest for a specific tag
get_digest_for_tag() {
local token=$1
local repo=$2
local tag=$3
local digest=$(curl -sI -H "Authorization: Bearer $token" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json" \
"https://registry-1.docker.io/v2/$repo/manifests/$tag" |
grep -i docker-content-digest | tr -d '\r' | awk '{print $2}')
echo "$digest"
}
# Function to check rate limits and wait if necessary
check_rate_limit() {
local headers_file=$1
local request_num=$2
# Extract rate limit headers
local limit=$(grep -i "x-ratelimit-limit" "$headers_file" | awk '{print $2}' | tr -d '\r')
local remaining=$(grep -i "x-ratelimit-remaining" "$headers_file" | awk '{print $2}' | tr -d '\r')
local reset=$(grep -i "x-ratelimit-reset" "$headers_file" | awk '{print $2}' | tr -d '\r')
if [ -n "$limit" ] && [ -n "$remaining" ] && [ -n "$reset" ]; then
echo "Request $request_num: Rate limit status - $remaining/$limit remaining" >&2
# Check if we're approaching the rate limit (less than 5 requests remaining)
if [ "$remaining" -le 5 ]; then
local current_time=$(date +%s)
local wait_time=$((reset - current_time))
if [ $wait_time -gt 0 ]; then
print_warning "Rate limit nearly exhausted ($remaining/$limit remaining)"
print_warning "Waiting for rate limit reset in $wait_time seconds..."
# Show countdown
while [ $wait_time -gt 0 ]; do
local minutes=$((wait_time / 60))
local seconds=$((wait_time % 60))
printf "\rTime remaining: %02d:%02d" $minutes $seconds
sleep 1
wait_time=$((wait_time - 1))
done
echo ""
print_status "Rate limit reset. Continuing..."
else
print_status "Rate limit should have already reset. Continuing..."
fi
fi
else
echo "Request $request_num: No rate limit headers found" >&2
fi
}
# Function to get all manifests using Docker Hub's internal API
list_all_manifests() {
local repo=$1
local request_num=1
local all_manifests=""
local last_key=""
while true; do
local response
local headers_file="/tmp/dockerhub_headers_$$"
if [ $request_num -eq 1 ]; then
# First request: GET without pagination
echo "Request $request_num: Initial GET request" >&2
response=$(curl -s -D "$headers_file" -H "Cookie: $DOCKER_HUB_COOKIE" \
-H "X-Requested-With: XMLHttpRequest" \
-H "Accept: application/json" \
"https://hub.docker.com/repository/docker/$repo/image-management.data?sortField=last_pushed&sortOrder=asc")
else
# Subsequent requests: POST with lastEvaluatedKey
echo "Request $request_num: POST request with pagination" >&2
response=$(curl -s -D "$headers_file" -X POST \
-H "Cookie: $DOCKER_HUB_COOKIE" \
-H "X-Requested-With: XMLHttpRequest" \
-H "Accept: application/json" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-raw "intent=paginate&lastEvaluatedKey=$last_key" \
"https://hub.docker.com/repository/docker/$repo/image-management.data?sortField=last_pushed&sortOrder=asc")
fi
# Check rate limits and wait if necessary
check_rate_limit "$headers_file" "$request_num"
# Clean up headers file
rm -f "$headers_file"
# Extract manifest digests from the flat array response
local manifests=$(echo "$response" | jq -r '.[] | select(type == "string" and (startswith("sha256:") or startswith("sha1:") or startswith("md5:"))) // empty' 2>/dev/null)
# Debug: show what we found on this request
if [ -n "$manifests" ]; then
echo "Request $request_num: Found $(echo "$manifests" | wc -w) digest(s)" >&2
all_manifests="$all_manifests $manifests"
else
echo "Request $request_num: No digests found" >&2
fi
# Check if there's a lastEvaluatedKey for pagination
local last_key_index=$(echo "$response" | jq -r 'to_entries | map(select(.value == "lastEvaluatedKey")) | .[0].key // empty' 2>/dev/null)
last_key=""
if [ -n "$last_key_index" ] && [ "$last_key_index" != "null" ]; then
# Get the next element after "lastEvaluatedKey"
last_key=$(echo "$response" | jq -r ".[$((last_key_index + 1))] // empty" 2>/dev/null)
fi
if [ -z "$last_key" ] || [ "$last_key" = "null" ]; then
echo "Request $request_num: No more pages (no lastEvaluatedKey found)" >&2
break
else
echo "Request $request_num: Found lastEvaluatedKey, continuing to next request..." >&2
fi
request_num=$((request_num + 1))
# Safety break to prevent infinite loops
if [ $request_num -gt $MAX_PAGINATION_REQUESTS ]; then
echo "Reached maximum requests ($MAX_PAGINATION_REQUESTS). Stopping." >&2
break
fi
done
echo "$all_manifests" | tr ' ' '\n' | sort | uniq | grep -v '^$'
}
# Function to delete manifest by digest
delete_manifest() {
local token=$1
local repo=$2
local digest=$3
local response=$(curl -s -w "%{http_code}" -X DELETE \
-H "Authorization: Bearer $token" \
"https://registry-1.docker.io/v2/$repo/manifests/$digest")
local http_code="${response: -3}"
if [ "$http_code" = "202" ] || [ "$http_code" = "204" ]; then
print_status "Successfully deleted manifest: $digest"
return 0
else
print_error "Failed to delete manifest: $digest (HTTP: $http_code)"
return 1
fi
}
# Function to find untagged manifests
find_untagged_manifests() {
local token=$1
local repo=$2
local limit=${3:-0}
print_status "Getting tagged manifests..."
local tags=$(list_tags "$token" "$repo")
local tagged_digests=""
if [ -n "$tags" ]; then
while IFS= read -r tag; do
if [ -n "$tag" ]; then
local digest=$(get_digest_for_tag "$token" "$repo" "$tag")
if [ -n "$digest" ]; then
tagged_digests="$tagged_digests $digest"
fi
fi
done <<<"$tags"
fi
print_status "Getting all manifests (including untagged)..."
local all_manifests=$(list_all_manifests "$repo")
print_status "Finding untagged manifests..."
local untagged_manifests=""
local count=0
while IFS= read -r manifest; do
if [ -n "$manifest" ]; then
if ! echo "$tagged_digests" | grep -q "$manifest"; then
untagged_manifests="$untagged_manifests $manifest"
count=$((count + 1))
# Stop if we've reached the limit
if [ "$limit" -gt 0 ] && [ "$count" -ge "$limit" ]; then
print_warning "Reached limit of $limit untagged manifests. There may be more."
break
fi
fi
fi
done <<<"$all_manifests"
echo "$untagged_manifests" | tr ' ' '\n' | grep -v '^$'
}
# Function to show usage
show_usage() {
cat <<EOF
Docker Hub Untagged Image Cleanup Script
Usage: $0 COMMAND
Commands:
list-untagged List untagged manifests (digests)
delete-untagged Delete all untagged manifests
-h, --help Show this help message
Environment Variables:
DH_USERNAME Docker Hub username (required)
DH_PASSWORD Docker Hub password or personal access token (required)
DOCKER_HUB_COOKIE Session cookie from logged-in browser (required)
REPO Space-separated list of repositories (required)
MAX_UNTAGGED_LIMIT Maximum number of untagged manifests to list (default: 100)
MAX_PAGINATION_REQUESTS Maximum number of pagination requests to make (default: 10)
Examples:
REPO="dockershelf/python" $0 list-untagged
REPO="dockershelf/python dockershelf/node" $0 delete-untagged
# With custom repositories:
REPO="dockershelf/python dockershelf/node dockershelf/go" $0 list-untagged
Getting the session cookie:
1. Open Docker Hub in your browser and log in
2. Open Developer Tools (F12)
3. Go to Network tab and refresh the page
4. Click on any request to hub.docker.com
5. In Request Headers, copy the entire 'Cookie:' value
6. Add it to your .env file as DOCKER_HUB_COOKIE="your-cookie-here"
EOF
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h | --help)
show_usage
exit 0
;;
list-untagged | delete-untagged)
COMMAND="$1"
shift
;;
*)
print_error "Unknown option: $1"
show_usage
exit 1
;;
esac
done
# Validate required parameters
if [ -z "$USERNAME" ] || [ -z "$PASSWORD" ]; then
print_error "Missing required credentials. Please set DH_USERNAME and DH_PASSWORD in .env file."
exit 1
fi
if [ -z "$REPO" ]; then
print_error "Missing required REPO variable. Please set REPO in .env file or as environment variable."
print_error "Example: REPO=\"dockershelf/python dockershelf/node dockershelf/go\""
exit 1
fi
if [ -z "$COMMAND" ]; then
print_error "No command specified."
show_usage
exit 1
fi
if [ -z "$DOCKER_HUB_COOKIE" ]; then
print_error "Docker Hub session cookie is required."
print_error "Please provide it using -c/--cookie or DOCKER_HUB_COOKIE env var."
print_error ""
print_error "To get the cookie:"
print_error "1. Open Docker Hub in your browser and log in"
print_error "2. Open Developer Tools (F12)"
print_error "3. Go to Network tab and refresh the page"
print_error "4. Click on any request to hub.docker.com"
print_error "5. In Request Headers, copy the entire 'Cookie:' value"
exit 1
fi
# Validate limit parameter
if ! [[ "$MAX_UNTAGGED_LIMIT" =~ ^[0-9]+$ ]] || [ "$MAX_UNTAGGED_LIMIT" -lt 1 ]; then
print_error "Invalid limit value: $MAX_UNTAGGED_LIMIT. Must be a positive integer."
exit 1
fi
# Check if jq is installed
if ! command -v jq &>/dev/null; then
print_error "jq is required but not installed. Please install jq first."
exit 1
fi
# Execute the requested command for each repository
for current_repo in $REPO; do
print_status "Working with repository: $current_repo"
# Get authentication token for this repository
TOKEN=$(get_token "$current_repo")
case $COMMAND in
list-untagged)
print_status "Listing untagged manifests (limit: $MAX_UNTAGGED_LIMIT)..."
untagged=$(find_untagged_manifests "$TOKEN" "$current_repo" "$MAX_UNTAGGED_LIMIT")
if [ -n "$untagged" ]; then
echo "=== $current_repo ==="
echo "$untagged"
echo ""
print_status "Found $(echo "$untagged" | wc -w) untagged manifest(s) in $current_repo"
else
print_warning "No untagged manifests found in $current_repo."
fi
echo ""
;;
delete-untagged)
# For deletion, get ALL untagged manifests (no limit)
print_status "Getting complete list of untagged manifests for deletion..."
untagged=$(find_untagged_manifests "$TOKEN" "$current_repo" 0)
if [ -n "$untagged" ]; then
deleted_count=0
failed_count=0
total_count=$(echo "$untagged" | wc -w)
print_status "Deleting $total_count untagged manifest(s) from $current_repo..."
while IFS= read -r digest; do
if [ -n "$digest" ]; then
if delete_manifest "$TOKEN" "$current_repo" "$digest"; then
deleted_count=$((deleted_count + 1))
else
failed_count=$((failed_count + 1))
fi
fi
done <<<"$untagged"
print_status "Deletion complete for $current_repo. Deleted: $deleted_count, Failed: $failed_count"
else
print_warning "No untagged manifests found to delete in $current_repo."
fi
echo ""
;;
esac
done
print_status "Operation completed."

Docker Hub Untagged Image Cleanup Script

A simple, focused script for finding and deleting untagged Docker images from Docker Hub repositories.

What It Does

Two simple operations:

  • list-untagged - Shows untagged image manifests (digests)
  • delete-untagged - Deletes all untagged image manifests

Why Use This?

Untagged images are created when:

  1. You push a new image with the same tag (old image becomes untagged)
  2. You delete a tag but the manifest remains
  3. Multi-architecture builds create intermediate manifests
  4. Build processes create temporary manifests

These untagged images:

  • ✅ Still consume storage space and count towards limits
  • ✅ Are invisible in Docker Hub web interface
  • ✅ Can only be cleaned up via API (like this script)

Prerequisites

  1. jq - JSON processor

    # macOS
    brew install jq
    
    # Ubuntu/Debian  
    apt-get install jq
  2. Docker Hub account - Username and password (or personal access token)

  3. Session cookie - From your logged-in browser (required for finding untagged images)

Setup

1. Get Session Cookie

This is required to access Docker Hub's internal API that lists untagged images:

  1. Log into Docker Hub in your web browser
  2. Open Developer Tools (F12)
  3. Go to Network tab and refresh the page
  4. Click on any request to hub.docker.com
  5. Find Request Headers and copy the entire Cookie: value

Example: Cookie: sessionid=abc123; csrftoken=def456; ...

2. Configure Environment

Create a .env file in the parent directory:

DH_USERNAME=your-docker-username
DH_PASSWORD=your-docker-password-or-token
DOCKER_HUB_COOKIE="sessionid=abc123;csrftoken=def456;..."
REPO="dockershelf/python dockershelf/node dockershelf/go dockershelf/debian dockershelf/latex"
MAX_UNTAGGED_LIMIT=100
MAX_PAGINATION_REQUESTS=10

Note: The DOCKER_HUB_COOKIE value must be in quotes.

Security Note: While you can use your regular Docker Hub password, it's recommended to use a personal access token instead for better security. Create one with Read, Write, Delete permissions and use it as the DH_PASSWORD value.

Usage

List Untagged Images

# List up to 100 untagged manifests (default limit)
REPO="dockershelf/python" ./delete-stale.sh list-untagged

# List up to 50 untagged manifests (set limit via environment)
REPO="dockershelf/python" MAX_UNTAGGED_LIMIT=50 ./delete-stale.sh list-untagged

# Use multiple repositories
REPO="dockershelf/python dockershelf/node" ./delete-stale.sh list-untagged

Delete Untagged Images

# Delete all untagged manifests
REPO="dockershelf/python dockershelf/node" ./delete-stale.sh delete-untagged

What happens:

  1. Shows preview of untagged manifests (up to limit)
  2. Deletes ALL untagged manifests (not just preview)
  3. Shows progress and final count

Configuration

All configuration is done via environment variables:

Variable Description Default Required
DH_USERNAME Docker Hub username -
DH_PASSWORD Docker Hub password or personal access token -
DOCKER_HUB_COOKIE Session cookie from browser -
REPO Space-separated list of repositories -
MAX_UNTAGGED_LIMIT Max manifests to show in preview 100
MAX_PAGINATION_REQUESTS Max pagination requests 10

Rate Limiting

The script automatically handles Docker Hub's rate limits:

  • Monitors rate limit headers (x-ratelimit-remaining)
  • Waits when approaching limit (≤5 requests remaining)
  • Shows countdown until rate limit resets
  • Continues automatically after reset

Example output:

Request 175: Rate limit status - 5/180 remaining
[WARNING] Rate limit nearly exhausted (5/180 remaining)
[WARNING] Waiting for rate limit reset in 90 seconds...
Time remaining: 01:30
[INFO] Rate limit reset. Continuing...

How It Works

  1. Gets tagged manifests - Uses Docker Registry API to get digests for all tagged images
  2. Gets all manifests - Uses Docker Hub's internal API to get all manifests (including untagged)
  3. Finds untagged - Compares the two lists to identify untagged manifests
  4. Deletes by digest - Uses Docker Registry API to delete untagged manifests

Example Session

$ REPO="dockershelf/python" ./delete-stale.sh list-untagged
[INFO] Working with repository: dockershelf/python
[INFO] Getting tagged manifests...
[INFO] Getting all manifests...
Request 1: Initial GET request
Request 1: Rate limit status - 179/180 remaining
Request 1: Found 15 digest(s)
[INFO] Finding untagged manifests...
sha256:c4593a5a1aa4fe979b7c4b4f4c1fb279188875c6c939706cbb9b4cc5da9c6bc8
sha256:055b3dfe0a1653ed28037fe2a898726d06684749e2f48d2afe08a01eea14715c
sha256:7d016e05e7c564919f29e5f21e93c28d15c9eccd6aa53a8919755adfbb38d220

[INFO] Found 3 untagged manifest(s)

$ REPO="dockershelf/python" ./delete-stale.sh delete-untagged
[INFO] Getting complete list of untagged manifests for deletion...
[INFO] Deleting 3 untagged manifest(s) from dockershelf/python...
[INFO] Successfully deleted manifest: sha256:c4593a5a1aa4fe979b7c4b4f4c1fb279188875c6c939706cbb9b4cc5da9c6bc8
[INFO] Successfully deleted manifest: sha256:055b3dfe0a1653ed28037fe2a898726d06684749e2f48d2afe08a01eea14715c
[INFO] Successfully deleted manifest: sha256:7d016e05e7c564919f29e5f21e93c28d15c9eccd6aa53a8919755adfbb38d220
[INFO] Deletion complete for dockershelf/python. Deleted: 3, Failed: 0

Troubleshooting

Session Cookie Issues

  • Cookie expired: Get a fresh cookie from browser
  • Access denied: Ensure you have access to the repository
  • Invalid format: Copy the entire Cookie: header value

Authentication Errors

  • Invalid credentials: Check username and password/personal access token
  • Insufficient permissions: If using a personal access token, ensure it has Read, Write, Delete permissions

Rate Limit Issues

  • Too many requests: Script automatically waits for reset
  • Long wait times: Docker Hub limits are typically 180 requests per hour

Important Notes

⚠️ Always test first on a non-production repository

⚠️ Untagged ≠ Unused - Some untagged images might still be referenced

⚠️ No undo - Deleted manifests cannot be recovered

⚠️ Cookie expires - You may need to refresh the session cookie periodically

⚠️ Configuration via environment only - All settings must be configured through environment variables or the .env file


Need help? Open an issue with your error message and (sanitized) command output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment