docker registry unauthorized

Summary

The issue described is a misconfiguration between the Docker Registry and the Registry UI regarding the Authentication Realm. The Registry was configured to request htpasswd credentials for the realm “Registry Realm”, but the Registry UI (acting as a proxy) or the client was likely sending credentials for a different realm or domain, or the UI proxy stripped the authentication header before forwarding to the registry. This caused the Registry to reject the request as unauthorized, even though the htpasswd file was mounted correctly and the credentials were valid.

Root Cause

The root cause is the misalignment of the Authentication Realm and proxy behavior.

  1. Realm Mismatch: The Registry environment variable REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm explicitly defines the realm. The Docker client calculates credentials based on the registry URL and realm. If the UI proxy or client uses a different realm (e.g., default “Registry” or a different domain), the calculated Authorization header digest will not match, resulting in a 401 Unauthorized.
  2. Proxy Stripping Headers: The docker-registry-ui (joxit version) acts as a reverse proxy. By default, many UI proxies do not pass the Authorization header to the upstream registry unless explicitly configured to do so. If the UI handles the login but fails to forward the header, the registry sees an unauthenticated request.
  3. Container Networking vs. Hostname: The registry is configured to accept requests only from http://registry:5000. When accessing via http://localhost:5000, the Origin header might not match, or the registry’s internal security checks might reject the request depending on how the UI proxy handles the Host header.

Why This Happens in Real Systems

In real-world container orchestration, authentication layers often add complexity:

  • Abstraction Layers: When using a UI or an Ingress controller (like Nginx or Traefik) in front of a service, the proxy must be configured to preserve the original Authorization header. Most “off-the-shelf” UI images do not enable this by default to avoid security risks or complexity.
  • Realm Sensitivity: The Docker v2 registry authentication protocol (RFC 7235) is strict about realms. If a client is pointed to http://localhost:5000 but the registry expects http://registry:5000 (the internal service name), the browser or Docker client may generate credentials for a different scope/realm.
  • Volume Mount Consistency: While the user verified the volume mount, a common pitfall is permission mismatches (UID/GID) between the host and the container, or file format errors (e.g., missing newline characters) that htpasswd validates strictly.

Real-World Impact

  • Operational Blockage: Development teams cannot push or pull images, halting CI/CD pipelines and local development.
  • Security Risks: Misconfigured proxies often fall back to allowing unauthenticated access or display confusing errors, leading engineers to disable authentication entirely (REGISTRY_SECURED: 'false') as a workaround, which exposes internal artifacts.
  • Time Sink: Debugging authentication involves tracing headers across network boundaries (Client -> UI -> Registry), which is opaque to most standard logging tools.

Example or Code

The following docker-compose.yml fixes the issue by:

  1. Aligning the Realm (or removing it to let the registry handle the default).
  2. Ensuring the UI passes the Authorization header to the registry (via REGISTRY_PROXY_AUTHORIZATION).
  3. Correcting the URL proxy pass to use the internal Docker network name registry rather than localhost.
    version: "3"
    services:
    registry:
    image: registry:3
    container_name: registry
    ports:
      - "5000:5000"
    environment:
      REGISTRY_AUTH: htpasswd
      # Removed custom realm to avoid client-side mismatch, 
      # or ensure client uses this exact string.
      REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm" 
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data
      REGISTRY_STORAGE_DELETE_ENABLED: "true"
      # Ensure registry accepts connections from the UI proxy
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin: '[http://registry-ui:80]'
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods: '[HEAD,GET,OPTIONS,DELETE,POST]'
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Credentials: '[true]'
      REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers: '[Authorization,Accept,Cache-Control]'
    volumes:
      - registry-data:/var/lib/registry
      - ./auth:/auth  # Assuming local folder 'auth' containing htpasswd file
    restart: unless-stopped

registry-ui:
image: joxit/docker-registry-ui:latest
container_name: docker_registry_ui
ports:

  • “8080:80”
    environment:
    REGISTRY_TITLE: “My private Docker Registry”
    SINGLE_REGISTRY: “true”
    DELETE_IMAGES: “true”

    Connect to registry via internal Docker network name

    NGINX_PROXY_PASS_URL: http://registry:5000

    CRITICAL: Ensure UI passes the Authorization header to the registry

    Note: Depending on the specific UI version, this might be handled

    automatically or require specific config. For joxit/ui, ensure

    the proxy is configured to not strip headers.

    If using a secured registry, the UI must also know the credentials

    or pass them through.

    REGISTRY_SECURED: “true”

    If the UI handles login, it needs the credentials to validate against registry

    REGISTRY_USER: (if static)

    REGISTRY_PASSWORD: (if static)

    volumes:

  • registry-data:/var/lib/registry
    depends_on:
  • registry
    restart: unless-stopped

volumes:
registry-data:

## How Senior Engineers Fix It

1.  **Simplify the Stack**: The first step is to bypass the UI and test the registry directly.
    *   Run `docker login localhost:5000` (without the UI). If this fails, the issue is strictly with the Registry configuration or the `htpasswd` file.
2.  **Validate the htpasswd File**:
    *   Use `docker run --rm -v $(pwd)/auth:/auth -w /auth httpd:alpine htpasswd -Bvb testuser testpass` to verify the password hash generation matches the registry's expectations (specifically the `-B` flag for bcrypt is often preferred over `-B` for security).
3.  **Trace the Headers**:
    *   Use `curl -v` to simulate the login process.
    *   Check if the `Authorization` header is present in the request reaching the registry container. Senior engineers often run `docker logs registry -f` to see the exact error code.
4.  **Align Configuration**:
    *   Ensure the Registry's `REGISTRY_AUTH_HTPASSWD_REALM` matches what the client expects.
    *   Configure the Proxy (UI) to forward headers. For Nginx-based proxies (like the Joxit UI), this often means ensuring `proxy_set_header Authorization $http_authorization;` is present in the nginx config inside the UI container.

## Why Juniors Miss It

1.  **Assuming the UI is Transparent**: Juniors often assume the UI acts as a dumb pipe. They don't realize that proxies often strip "hop-by-hop" headers (like Authorization) for security or compatibility reasons unless explicitly told to forward them.
2.  **Focus on File Contents**: Juniors check the `htpasswd` file content extensively but forget about **permissions** (file must be readable by the container user, usually UID 1000) and **format** (must be valid htpasswd, not just a text file).
3.  **Hostname Confusion**: Using `localhost` inside a browser vs. `registry` inside a Docker network causes CORS or Realm mismatches. Juniors often miss that `docker login http://localhost:5000` creates a different authentication scope than `docker login http://registry:5000`.
4.  **Configuration Overload**: The YAML provided had many environment variables. Juniors often copy-paste configurations without understanding that `REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin` must exactly match the client's Origin (including scheme `http` vs `https`).