--- name: docker-hub-scraper description: List the tags of any public Docker Hub repository as structured records via the Apify Actor arman-bd/docker-hub-scraper. Returns one row per tag with its pull reference, compressed size, manifest digest, runnable architectures and operating systems, per-platform detail, tag status and last-pushed, last-pulled and last-updated timestamps. Use when a task needs base-image freshness audits, multi-architecture support checks before a migration, rebuild detection by digest, or an inventory of what a namespace ships. Not for private repositories, image layers or file contents, pull counts and stars, or registries other than Docker Hub. --- # Docker Hub Scraper Apify Actor `arman-bd/docker-hub-scraper`. Give it image names, get one dataset record per tag: size, digest, platform coverage and freshness. It runs without credentials and reads only public repositories. ## When to use it - Auditing base-image freshness across services, checking `tagLastPushed` against your build dates. - Confirming an image really publishes `linux/arm64` before committing to a migration. - Detecting upstream rebuilds: same tag, changed `digest`, between two scheduled runs. - Taking an inventory of a namespace: how many tags, how big, which platforms. - Comparing compressed image sizes across candidate base images. ## When not to use it - Private repositories. The Actor sends no credentials, so those come back as failures rather than being silently skipped. - Image contents: layers, filesystem, config, labels, entrypoints. None of that is here. - Repository-level statistics. Pull counts, stars and descriptions live on the repository itself, and this Actor emits one row per tag. - Registries other than Docker Hub: no GHCR, Quay, ECR or a self-hosted registry. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/docker-hub-scraper').call({ repositories: ['nginx', 'library/python', 'bitnami/postgresql'], maxTagsPerRepo: 50, architectureFilter: [], includeDigests: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); const stale = items.filter((t) => Date.now() - Date.parse(t.tagLastPushed) > 90 * 864e5); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~docker-hub-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"repositories":["nginx","bitnami/postgresql"],"maxTagsPerRepo":25,"architectureFilter":["arm64"]}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/docker-hub-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `repositories` | string[] | yes | | `namespace/repo`, a bare official name, a Docker Hub URL, or a pull reference with a `:tag`. All normalise to lowercase `namespace/repo`; a single-segment name gets `library/` prefixed for you, and any `:tag` suffix is stripped. Duplicates are removed. | | `maxTagsPerRepo` | integer | no | `100` | Cap per repository, applied **after** `architectureFilter`. `0` means no limit. Maximum 10000. | | `architectureFilter` | string[] | no | `[]` | Keep only tags that publish an image for at least one of these. Accepts a bare architecture (`arm64`, `amd64`, `386`, `ppc64le`, `s390x`, `riscv64`) or an arch/variant pair (`arm/v7`). Case-insensitive. Empty keeps every tag. | | `includeDigests` | boolean | no | `true` | Add a `digest` to each entry in `platforms`. The tag's own manifest `digest` is present either way. Off typically halves dataset size on multi-arch images. | **The filter runs before the cap, and that is the whole reason to think about ordering.** `architectureFilter: ["riscv64"]` with `maxTagsPerRepo: 20` gives you 20 riscv64-capable tags, not "whichever of the first 20 tags happened to be riscv64". The cost is paging: a narrow filter on a repository with thousands of tags walks a lot of pages, and the Actor stops at 100 pages (10000 tags) per repository and logs a warning when it gets there. Tags arrive newest-pushed first, so a small cap with no filter is the cheap way to see current releases. ## Output One record per tag saved. | Field | Type | Notes | |---|---|---| | `repository` | string | Normalised `namespace/repo`, lowercase. Group on this. | | `namespace` | string | `library` for official images. | | `name` | string | Repository name without the namespace. | | `tag` | string | Tag name, `1.30.4-trixie`. | | `image` | string | The pull reference you would type: `nginx:stable` for official images, `bitnami/postgresql:16` otherwise. | | `fullSize` | number \| null | Compressed size in bytes of the tag's primary image. See below. | | `digest` | string \| null | The tag's manifest or manifest-list digest. The immutable identity, and the thing to diff for rebuild detection. | | `architectures` | string[] | Runnable architectures, deduplicated. | | `osList` | string[] | Operating systems, usually `["linux"]`. | | `variants` | string[] | ARM variants present, `["v5", "v7", "v8"]`. Empty when no platform declares one. | | `platforms` | object[] | Per-platform detail. Keys: `os`, `architecture`, `variant`, `size`, `status`, `lastPushed`, plus `digest` when `includeDigests` is on. | | `mediaType` | string \| null | Manifest media type, for example an OCI image index. | | `contentType` | string \| null | `image` for ordinary images. | | `tagStatus` | string \| null | `active` or `inactive`. | | `lastUpdated` | string \| null | Tag metadata last changed, ISO 8601. | | `lastPusher` | string \| null | Docker Hub username that last pushed the tag. | | `tagLastPushed` | string \| null | When the tag's content was last pushed. The staleness signal. | | `tagLastPulled` | string \| null | Last pull recorded. Moves constantly on popular images. | | `url` | string | Deep link to the tag on Docker Hub. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, `platforms` trimmed to two entries: ```json { "repository": "library/nginx", "namespace": "library", "name": "nginx", "tag": "stable-trixie", "image": "nginx:stable-trixie", "fullSize": 63093193, "digest": "sha256:46ccc48fbb1f5a43167f2ee2c279c122b96eec5d976e7f4e1e0780f59a51b4d6", "architectures": ["amd64", "arm", "arm64", "386", "ppc64le", "riscv64", "s390x"], "osList": ["linux"], "variants": ["v5", "v7", "v8"], "platforms": [ { "os": "linux", "architecture": "amd64", "variant": null, "size": 63093193, "status": "active", "lastPushed": "2026-08-05T01:51:00.922797918Z", "digest": "sha256:9e4b33ead4c69e9845e53a1f175d0aa70a6cea62425b5d08957ba7eee5dc9b1d" }, { "os": "linux", "architecture": "arm64", "variant": "v8", "size": 61387564, "status": "active", "lastPushed": "2026-08-05T01:51:03.228737506Z", "digest": "sha256:9105903bbaf495b6994795c85e415a9cd021381ebcdf9eb2d2c7f2d47172a0cc" } ], "mediaType": "application/vnd.oci.image.index.v1+json", "contentType": "image", "tagStatus": "active", "lastUpdated": "2026-08-05T19:52:15.690518Z", "lastPusher": "doijanky", "tagLastPushed": "2026-08-05T19:52:15.690518Z", "tagLastPulled": "2026-08-06T11:22:04.35119218Z", "url": "https://hub.docker.com/_/nginx?tab=tags&name=stable-trixie", "scrapedAt": "2026-08-06T12:00:00.000Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** A run that returned fewer tags than you expected explains itself here. ```json { "repositoriesRequested": 2, "repositoriesFailed": 0, "failures": [], "tagsSaved": 150, "rateLimit": { "limit": 180, "remaining": 176, "resetsAt": "2026-08-06T12:10:52.000Z" }, "filters": { "repositories": ["library/nginx", "bitnami/postgresql"], "maxTagsPerRepo": 100, "architectureFilter": [], "includeDigests": true }, "finishedAt": "2026-08-06T12:00:03.881Z" } ``` `filters.repositories` is the normalised list, so it is the fastest way to confirm that `nginx` became `library/nginx` and that a pull reference lost its tag. `failures` carries one entry per repository with a `repository` and an `error`; a not-found message means a wrong namespace, a private message means credentials would be needed. `rateLimit` is the request budget observed on the last response, and it is `null` when nothing reported one. `repositoriesFailed` below `repositoriesRequested` with a low `tagsSaved` means the cap or the filter bit, not an error. ## Behaviour to plan around - **The architecture filter is evaluated per tag, then the cap applies.** A repository can be walked for 100 pages and still save far fewer tags than the cap, because most tags did not match. The log line reports listed, matched and saved separately. - **Attestation manifests are excluded from the platform summary.** SBOM and provenance manifests are listed alongside real images with `os` and `architecture` both `unknown`. They are not runnable platforms, so `architectures`, `osList`, `variants` and `platforms` cover real images only. A tag with nine underlying manifests can therefore report five architectures, and the counts will not match `docker manifest inspect` unless you ignore its `unknown/unknown` entries too. - **`fullSize` is compressed registry size, not on-disk size.** It measures the layer blobs as stored. An unpacked image on a running host is typically two to three times larger. Do not size a disk from this field. - **`digest` is the identity, `tag` is not.** A tag is a moving pointer. Diff `repository` plus `tag` on `digest` to catch an upstream rebuild that changed nothing visible. - **A missing or private repository never aborts the run.** It is recorded in `failures` and the run continues. The Actor only throws when every repository failed and nothing at all was saved. - **Partial results are kept.** If page 7 of a repository fails, the tags already saved stay in the dataset and the failure is still reported. - **The 100-page ceiling is silent in the data.** Past 10000 tags scanned for one repository the Actor stops and logs a warning, but the dataset just looks short. Narrow the repository list or accept the sample. - **Nulls are honest.** `fullSize`, `digest`, `lastPusher` and the timestamps are `null` when the registry does not report them for that tag, which happens on very old or unusual tags. - **Transient errors are retried** three times, with a longer backoff on throttling than on other errors. Not-found and unauthorised responses are final for that repository. ## Recipes **Base-image freshness audit.** The tags you actually depend on, newest first. ```json { "repositories": ["nginx", "library/python", "library/redis", "bitnami/postgresql"], "maxTagsPerRepo": 50, "includeDigests": false } ``` Filter locally on `tagLastPushed` older than your build date and raise a ticket per `image`. **Verify arm64 support before migrating.** Anything that survives the filter has a real arm64 image published, not just a promise in the docs. ```json { "repositories": ["grafana/grafana", "prom/prometheus", "library/mysql"], "architectureFilter": ["arm64"], "maxTagsPerRepo": 100 } ``` An empty result for a repository means no arm64 tag was found in the pages walked, so check `failures` first to rule out an error. **Rebuild detection on a schedule.** Snapshot the same tags daily and compare. ```json { "repositories": ["library/node", "library/golang"], "maxTagsPerRepo": 200, "includeDigests": true } ``` Key on `repository` plus `tag`; a changed `digest` with an unchanged `tag` means the upstream image was rebuilt under you.