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
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Docker Compose5.4.0
- Architectureamd64
- TimeAbout 13 min
- Reviewed21 August 2026
Tested on the versions above. The registry here runs locally with htpasswd auth, so every request and refusal is real. Credentials shown are throwaway.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- Pushing and pulling images - guide 20 in this path.
- Host port 5001 free.
-
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 enabledExpected resultA running registry backed by a bcrypt password file.
Success conditionThe container is up.
-Bbnmeans bcrypt, batch mode, and print to stdout rather than writing a file itself. -
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: 200Expected 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.
-
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 credentialsExpected resultA refusal naming both a missing repository and missing authorization.
Success conditionYou can read past the first half of that message.
no basic auth credentialsis the operative part - you are not logged in. -
Log in without putting the password in your shell history
--password-stdinis the point of this step. Passing-pon 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 result
Login 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.
-
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:cgpassExpected 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.
-
Log out, and check it is gone
docker logoutremoves 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.
-
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 nodesExpected resultA login, push and logout using only environment-supplied secrets.
Success conditionNo credential is written into a command line. For a credential helper, set
credsStorein config.json so the secret goes to the OS keychain instead of a file. -
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 volumeExpected resultThe container and tag removed.
Success condition
docker ps -ais clear. Checkdocker volume lsfor the leftover anonymous volume, as in the publishing guide.
Troubleshooting
no basic auth credentials
Why: Not logged in to that registry, or logged in to a different hostname than the image name uses.
Fix:The login host must match the registry prefix in the image name exactly, including the port.
bash cat ~/.docker/config.json# the key here must match the prefix of the image you are pushinghttp: server gave HTTP response to HTTPS client
Why: The registry is plain HTTP and Docker requires TLS for anything that is not localhost.
Fix:Give the registry a certificate, or add it to insecure-registries in daemon.json - understanding that this sends credentials unencrypted.
bash # insecure-registries transmits your credentials in the cleardocker info --format "{{.RegistryConfig.IndexConfigs}}"Login works locally but fails in CI
Why: The secret is unset or empty in the CI environment, so an empty string is piped in.
Fix:Fail the job early if the variable is empty rather than letting login produce a confusing error.
bash test -n "$CI_REGISTRY_TOKEN" || { echo 'token not set'; exit 1; }Swarm services cannot pull from the private registry
Why: The manager authenticated, but the worker nodes have no credentials of their own.
Fix:Deploy with
--with-registry-authso the manager forwards the credential to the nodes that need it.bash docker service create --with-registry-auth --name app registry.example.com/team/app:1.0