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.
- Realm Mismatch: The Registry environment variable
REGISTRY_AUTH_HTPASSWD_REALM: Registry Realmexplicitly 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 calculatedAuthorizationheader digest will not match, resulting in a401 Unauthorized. - Proxy Stripping Headers: The
docker-registry-ui(joxit version) acts as a reverse proxy. By default, many UI proxies do not pass theAuthorizationheader 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. - Container Networking vs. Hostname: The registry is configured to accept requests only from
http://registry:5000. When accessing viahttp://localhost:5000, theOriginheader 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
Authorizationheader. 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:5000but the registry expectshttp://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
htpasswdvalidates 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:
- Aligning the Realm (or removing it to let the registry handle the default).
- Ensuring the UI passes the Authorization header to the registry (via
REGISTRY_PROXY_AUTHORIZATION). - Correcting the URL proxy pass to use the internal Docker network name
registryrather thanlocalhost.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`).