CertGrid CertGrid
Hands-on Lab·Docker

Docker Private Registry Authentication

Run a registry that demands credentials, watch an unauthenticated push get refused, log in properly - then look at where Docker actually put your password. It is not encrypted.

Dockerfiles and Builds Guide 21 of 46 Intermediate

Tested on the versions above. The registry here runs locally with htpasswd auth, so every request and refusal is real. Credentials shown are throwaway.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. Stand up a registry that requires credentials

    The registry image supports htpasswd authentication with three environment variables. Generating the password file needs htpasswd, which the httpd image already contains - so no host packages are installed for this.

    bash
    mkdir -p auth && docker run --rm --entrypoint htpasswd httpd:2 -Bbn cguser cgpass > auth/htpasswddocker run -d --name cg-authreg -p 5001:5000 -v "$PWD/auth:/auth" -e REGISTRY_AUTH=htpasswd -e REGISTRY_AUTH_HTPASSWD_REALM=Registry -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd registry:3# registry running with authentication enabled

    Expected resultA running registry backed by a bcrypt password file.

    Success conditionThe container is up. -Bbn means bcrypt, batch mode, and print to stdout rather than writing a file itself.

  2. Confirm it actually refuses anonymous access

    Never assume auth is on because you configured it. A 401 for an anonymous request and a 200 with credentials is the proof.

    bash
    curl -s -o /dev/null -w "anonymous: %{http_code}\n" http://localhost:5001/v2/_cataloganonymous: 401curl -s -o /dev/null -w "with credentials: %{http_code}\n" -u cguser:cgpass http://localhost:5001/v2/_catalogwith credentials: 200

    Expected result401 without credentials, 200 with them.

    Success conditionAuthentication is genuinely enforced. A 200 for the anonymous request would mean the registry started without the auth configuration.

  3. What an unauthenticated push looks like

    Worth seeing once so you recognise it. The message names authorization, but it can be misread as the repository not existing - which sends people looking for the wrong problem.

    bash
    docker tag alpine:3.22 localhost:5001/cg-private:1docker push localhost:5001/cg-private:1push access denied, repository does not exist or may require authorization: authorization failed: no basic auth credentials

    Expected resultA refusal naming both a missing repository and missing authorization.

    Success conditionYou can read past the first half of that message. no basic auth credentials is the operative part - you are not logged in.

  4. Log in without putting the password in your shell history

    --password-stdin is the point of this step. Passing -p on the command line writes your password into shell history and into the process list where any other user on the box can read it. Piping it in avoids both.

    bash
    # never: docker login -u user -p secret  <- lands in history and in ps outputecho "$REGISTRY_PASSWORD" | docker login localhost:5001 -u cguser --password-stdinLogin Succeeded WARNING! Your credentials are stored unencrypted in '/home/sysadmin/.docker/config.json'.Configure a credential helper to remove this warning. Seehttps://docs.docker.com/go/credential-store/

    Expected resultLogin Succeeded, with a warning about how the credentials are stored.

    Success conditionLogin worked and the push now succeeds. Read that warning rather than dismissing it - the next step shows exactly what it means.

  5. Where your password actually went

    The warning is not boilerplate. Docker wrote the credentials to a plain file, base64-encoded. Base64 is an encoding, not encryption - anyone who can read the file can read the password. On a shared host or a CI runner that persists between jobs, this matters.

    bash
    cat ~/.docker/config.json{	"auths": {		"localhost:5001": {			"auth": "Y2d1c2VyOmNncGFzcw=="		}	}}echo "Y2d1c2VyOmNncGFzcw==" | base64 -dcguser:cgpass

    Expected resultThe username and password recovered from the config file in one command.

    Success conditionYou have proved it is not encrypted. This is why credential helpers exist, and why CI should use short-lived tokens rather than a stored password.

  6. Log out, and check it is gone

    docker logout removes the entry. Verify rather than assume - on a shared or long-lived machine a forgotten login is a credential left lying around.

    bash
    docker logout localhost:5001Removing login credentials for localhost:5001cat ~/.docker/config.json{
    	"auths": {}
    }

    Expected resultAn empty auths object.

    Success conditionThe credential is gone. Make this part of any CI job that logs in - the runner may be reused.

  7. Doing it properly in CI

    Three rules. Use a short-lived token rather than a long-lived password. Feed it through stdin from the CI secret store so it never reaches history or the process list. Log out at the end of the job. Most registries issue scoped, expiring tokens for exactly this.

    bash
    echo "$CI_REGISTRY_TOKEN" | docker login registry.example.com -u "$CI_REGISTRY_USER" --password-stdindocker push registry.example.com/team/app:$CI_COMMIT_SHAdocker logout registry.example.com# in swarm, add --with-registry-auth so the manager forwards credentials to the nodes

    Expected resultA login, push and logout using only environment-supplied secrets.

    Success conditionNo credential is written into a command line. For a credential helper, set credsStore in config.json so the secret goes to the OS keychain instead of a file.

  8. Clean up

    Remove the registry and the throwaway image. The registry keeps its data in an anonymous volume, so remove that too or it lingers unnamed.

    bash
    docker rm -f cg-authregdocker rmi localhost:5001/cg-private:1# then check docker volume ls for the registry's anonymous volume

    Expected resultThe container and tag removed.

    Success conditiondocker ps -a is clear. Check docker volume ls for the leftover anonymous volume, as in the publishing guide.

Troubleshooting

Official sources