Working offline, navigating corporate proxies that intercept TLS, or verifying whether a mirror configuration resolves correctly requires a container registry that you control. Docker provides an official container image for this, registry:2, which spins up a working registry with a single command: docker run -d -p 5000:5000 registry:2. However, running the container naively stores everything inside its writable layer, wiping your cached images the moment the container is removed. On top of that, standard docker tag behaviour makes it easy to accidentally overwrite images when pushing multiple versions.

To solve these issues, I wrapped registry:2 in a small shell script, local-docker-registry. Here is how to handle persistence, avoid image clobbering, query the underlying HTTP API, and catch configuration drift.

Persistence and Container Lifecycle

Running the official registry image locally is straightforward:

docker run -d -p 5000:5000 --name registry registry:2

The Docker daemon treats loopback addresses (localhost and 127.0.0.1) as insecure by default, allowing pushes and pulls without TLS or daemon configuration changes. However, container storage is ephemeral; removing the container with docker rm deletes all pushed image layers and manifests.

To persist images across container removals and reboots, bind-mount host storage to /var/lib/registry (the internal directory where registry:2 stores all layer blobs and manifests):

docker run -d \
  -p 5000:5000 \
  --restart=always \
  --name registry \
  -v "$HOME/.local-docker-registry/data:/var/lib/registry" \
  registry:2

With storage bound to the host, the container can be recreated or upgraded at any time without data loss.

Tag Retention and Image Clobbering

When tagging an image to point to a local registry, omitting the tag defaults to latest:

docker tag ubuntu:24.04 localhost:5000/ubuntu

The source tag (24.04) is discarded, and the target image becomes localhost:5000/ubuntu:latest. Tagging multiple versions of the same repository without explicitly re-specifying the tag overwrites the previous version:

docker tag python:3.12-slim localhost:5000/python   # stored as :latest
docker tag python:3.11-slim localhost:5000/python   # overwrites :latest

The earlier image layer becomes untagged and inaccessible via the registry tag index.

To prevent this, local-registry add parses the image reference and retains the source tag automatically. When adding an image pinned by an immutable digest (such as ubuntu@sha256:754cc6...) rather than a tag, Docker cannot push the digest directly as a destination tag name. The script extracts the first twelve characters of the SHA-256 hash to generate a deterministic tag:

parse_ref() {
  local ref="$1" last name_part digest_part digest_hex
  last="${ref##*/}"
  if [[ "$last" == *@* ]]; then
    name_part="${last%%@*}"
    if [[ "$name_part" == *:* ]]; then
      PARSED_REPO="${name_part%%:*}"
      PARSED_TAG="${name_part##*:}"
    else
      digest_part="${last#*@}"   # e.g. sha256:abcd1234...
      digest_hex="${digest_part#*:}"
      PARSED_REPO="$name_part"
      PARSED_TAG="${digest_hex:0:12}"
    fi
  elif [[ "$last" == *:* ]]; then
    ...

This ensures pulling, tagging, and pushing can be performed in one operation while preserving identifiers:

local-registry add ubuntu:24.04
local-registry add ubuntu:24.04 as my-ubuntu          # localhost:5000/my-ubuntu:24.04
local-registry add ubuntu:24.04 as my-ubuntu:custom   # localhost:5000/my-ubuntu:custom

Interacting with the Registry HTTP API

The Docker registry implements the OCI Distribution Specification over HTTP, which defines the standard /v2/ REST API that container engines use to fetch manifests and layer blobs. You can inspect the contents of a local registry using standard tools like curl.

To list all repositories in the registry:

curl -s http://localhost:5000/v2/_catalog
{"repositories":["alpine","nginx","python","redis","ubuntu"]}

To list all tags for a specific repository:

curl -s http://localhost:5000/v2/python/tags/list
{"name":"python","tags":["3.11-slim","3.12-slim"]}

local-registry list queries these endpoints and formats the output into a table:

REPOSITORY                     TAGS
alpine                         3.20
python                         3.12-slim, 3.11-slim

To keep the wrapper portable across minimal environments without requiring jq, catalog responses are parsed directly with sed:

# Extracts the quoted string elements of a single JSON array field, e.g.
# '{"repositories":["a","b"]}' -> "a\nb". Good enough for the registry API's
# always-flat repositories/tags arrays; avoids a hard dependency on jq.
json_array_field() {
  local json="$1" field="$2"
  echo "$json" | sed -n "s/.*\"${field}\":\[\(.*\)\].*/\1/p" | tr -d '"' | tr ',' '\n' | sed '/^$/d'
}

Because the registry’s catalog and tag list endpoints return single-level string arrays, basic stream editing extracts the values reliably without external dependencies.

Detecting Configuration Drift

When wrapping Docker commands, passing new environment variables to docker start does not update an existing container:

REGISTRY_PORT=6000 local-registry start

Port bindings, volume mounts, and network settings are fixed at container creation. If a container named registry already exists on port 5000, subsequent start commands reuse the original port mapping.

To prevent silent misconfigurations, the script inspects the running container and compares the actual port with the requested value:

existing_host_port() {
  docker inspect --format '{{ (index (index .NetworkSettings.Ports "5000/tcp") 0).HostPort }}' \
    "$CONTAINER_NAME" 2>/dev/null
}

If the active configuration differs from the requested environment variables, the script warns the user and provides the local-registry recreate command to rebuild the container with the updated settings while leaving host data intact.

Bash Scripting Considerations

Two specific Bash behaviours require explicit handling in the wrapper.

set -e in Conditionals

In Bash, errexit (set -e) is disabled within conditional statements, including commands placed on the left side of && or inside if tests. When the batch import command (local-registry load, which mirrors an image list from a file) invokes cmd_add within a conditional check:

cmd_add "$src" as "$dest_name" && ok=1 || ok=0

set -e remains inactive inside cmd_add. If a command fails during the function execution (such as a network failure during docker pull), execution does not abort automatically. Each critical step requires explicit status checking:

docker pull "$src" || { echo "error: failed to pull $src" >&2; return 1; }

Batch Error Handling

When mirroring a list of images with local-registry load, individual pull failures should not abort the entire batch. The script logs individual errors, continues processing subsequent images, and reports a final status summary:

Loaded 18 image(s), 1 failed.

The script exits with a non-zero status code after the batch completes so automated CI pipelines detect partial failures.

Scope and Limitations

The script is scoped specifically for local development and offline mirror testing:

  • No TLS or Authentication: Intended solely for loopback interfaces (localhost), which Docker permits without certificates. Network-accessible registries require TLS and credential management.
  • Push-Populated Registry: The default setup acts as an explicit storage target rather than a transparent pull-through cache (proxy.remoteurl), which automatically fetches and caches missing upstream images from Docker Hub on demand.
  • Storage Reclaiming: Deleting an image tag via the HTTP API only removes the manifest reference; underlying layer blobs remain on disk until running registry garbage-collect. The script provides purge to remove the container and host data directory entirely, requiring interactive confirmation or the --yes flag in scripts:
if [ ! -t 0 ]; then
  echo "error: refusing to purge without confirmation in a non-interactive shell; re-run with --yes" >&2
  exit 1
fi
  • Port 5000 on macOS: macOS AirPlay Receiver listens on port 5000 by default. Set REGISTRY_PORT to an alternate port (such as 5001) or disable AirPlay Receiver in system settings.

Summary

Running registry:2 locally provides a reliable, isolated target for testing container builds, offline workflows, and mirror resolution. Using persistent host volume mounts protects image layers across reboots, while automated tag handling prevents unintended tag overwrites.

The complete script is available at github.com/krisfoster/local-docker-registry.