Skip to Content
Docker Advanced: Builds and Containers That Hold Up in Production
Advanced
0 likes
Courses
Docker Advanced: Builds and Containers That Hold Up in Production

Private Registries and Auth Without Sticking Tokens in Files

Run a registry with a password, log in from Windows and from a Linux command line, and see where the credentials end up. After this, you can log in from a laptop or CI without leaving tokens in files.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If your machines already log in with --password-stdin, store credentials in a credential helper, and your CI uses short-lived or scoped tokens, skip to "In CI" and compare.

A registry password in a file is a password you will eventually leak. It ends up in Git, in a backup, in a screenshot, in a support ticket. Docker makes this easy to get wrong, because docker login quietly writes credentials to a config file unless something better is set up. This lesson runs a private registry with a username and password on your own machine, logs in from Windows and from a Linux command line, and shows exactly where the credentials end up.

Run on Docker Desktop 4.85.0 with Engine 29.6.2, using the open-source registry:2 image, httpd:2.4-alpine only to create the password file, and docker:29-cli for the Linux part. The username and password below are made up for this demo. Never use them anywhere real.

A registry that asks for a password

The registry reads users from an htpasswd file with bcrypt hashes. The htpasswd tool comes with the Apache image, so we borrow it instead of installing anything:

New-Item -ItemType Directory -Force auth-demo | Out-Null
Set-Location auth-demo
New-Item -ItemType Directory -Force auth | Out-Null
docker run --rm --entrypoint htpasswd httpd:2.4-alpine -Bbn ci-bot demo-only-password | Set-Content -Encoding ascii auth\htpasswd
docker run -d --name auth-registry -p 127.0.0.1:5055:5000 `
  -v "${PWD}\auth:/auth:ro" `
  -e REGISTRY_AUTH=htpasswd -e REGISTRY_AUTH_HTPASSWD_REALM=demo `
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd registry:2 | Out-Null
Start-Sleep -Seconds 2
docker tag alpine:3.24 127.0.0.1:5055/demo-team/base:3.24
docker push 127.0.0.1:5055/demo-team/base:3.24
no basic auth credentials

Without logging in, the push is refused. This registry listens on 127.0.0.1, which Docker allows over plain HTTP. A registry on any other address needs HTTPS, and you should not turn that check off to make an error go away.

A separate Docker config for this demo

Docker keeps its settings and logins in a folder called .docker in your user profile. I don't want this demo to touch the real one, so I point DOCKER_CONFIG at a new, empty folder for this terminal only. My demo folder was C:\demo\auth-demo, which is why that path shows up in the output below. You can do the same to test login behaviour safely.

$env:DOCKER_CONFIG = "$PWD\docker-config"
New-Item -ItemType Directory -Force $env:DOCKER_CONFIG | Out-Null
Set-Content -Encoding ascii "$env:DOCKER_CONFIG\config.json" "{}"

What Docker Desktop does by default

Log in the right way first: the secret goes through standard input, never as an argument. Passing it with -p puts it in your shell history, and on Linux other users can see it in the process list.

$env:REGISTRY_TOKEN = "demo-only-password"
$env:REGISTRY_TOKEN | docker login 127.0.0.1:5055 -u ci-bot --password-stdin
Get-Content "$env:DOCKER_CONFIG\config.json"
docker push -q 127.0.0.1:5055/demo-team/base:3.24
Login Succeeded
{
	"auths": {
		"127.0.0.1:5055": {}
	},
	"credsStore": "wincred"
}
127.0.0.1:5055/demo-team/base:3.24

 Info -> Not all multiplatform-content is present and only the available single-platform image was pushed
         sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 -> sha256:d56c381f961d307a21b3ca004cf1e3910f106644aefb1f43e654c8a56c4fd395

The login worked and the push went through. Look at config.json: the registry has an empty entry, and Docker added "credsStore": "wincred" by itself. When the config names no credential helper, the Docker CLI on Windows picks the Windows one if it is installed, and Docker Desktop installs it. The password went to Windows Credential Manager, not into the file. In real use, the value of REGISTRY_TOKEN would come from a password manager or your CI's secret store, not from a line in a script like in this demo.

docker logout 127.0.0.1:5055
Removing login credentials for 127.0.0.1:5055

Where it goes wrong: a machine without a helper

Linux servers and CI runners usually have no credential helper installed. There, docker login writes the credential into config.json. To show that on this Windows machine, I ran the Linux Docker command line in a container (docker:29-cli) and let it talk to the same engine through the Docker socket. The script it runs:

echo "demo-only-password" | docker login 127.0.0.1:5055 -u ci-bot --password-stdin
cat /root/.docker/config.json
echo
echo "decoded auth value:"
sed -n 's/.*"auth": "\([^"]*\)".*/\1/p' /root/.docker/config.json | base64 -d
echo
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "${PWD}:/demo:ro" docker:29-cli sh /demo/login-linux.sh

WARNING! Your credentials are stored unencrypted in '/root/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/

Login Succeeded
{
	"auths": {
		"127.0.0.1:5055": {
			"auth": "Y2ktYm90OmRlbW8tb25seS1wYXNzd29yZA=="
		}
	}
}
decoded auth value:
ci-bot:demo-only-password

Docker warns that the credentials are stored unencrypted, and it is right. The auth value is Base64, not encryption, and one command turns it back into the user name and password. Anyone who copies that file can push to your registry.

⚠ Warning: Mounting /var/run/docker.sock into a container gives that container full control of your Docker engine. I did it here only to get a Linux docker command quickly, with Docker's own image. Don't do it with images you don't trust, and never in production for convenience.

The fix on Linux and CI

The same two habits work everywhere: --password-stdin for the login, and a credential helper so nothing lands in the file. On Linux desktops that is docker-credential-secretservice, on servers often docker-credential-pass. I did not set either up for this lesson. On Windows, you can name the helper yourself so nobody depends on the automatic choice:

Set-Content -Encoding ascii "$env:DOCKER_CONFIG\config.json" '{ "credsStore": "desktop" }'
$env:REGISTRY_TOKEN | docker login 127.0.0.1:5055 -u ci-bot --password-stdin
Get-Content "$env:DOCKER_CONFIG\config.json"
Login Succeeded
{
	"auths": {
		"127.0.0.1:5055": {}
	},
	"credsStore": "desktop"
}

desktop is Docker Desktop's own helper, and on Windows it also keeps the secret in Windows Credential Manager. Log out when you are done. That removes the stored credential as well:

docker logout 127.0.0.1:5055
Get-Content "$env:DOCKER_CONFIG\config.json"
Remove-Item Env:DOCKER_CONFIG, Env:REGISTRY_TOKEN
Removing login credentials for 127.0.0.1:5055
{
	"auths": {},
	"credsStore": "desktop"
}

⚠ Warning: Don't commit config.json, and don't bake it into an image with COPY. If a build needs registry access, use a build secret (--secret), which the Intermediate course covers, so the credential never lands in a layer.

In CI

A CI job is a fresh machine every time, so it logs in at the start of every run. The rules are the same: secret from the CI's secret store, passed through standard input, and the narrowest token you can get. On Docker Hub that is a personal or organisation access token with read-only or read-write scope, never the account password.

This is what it looks like in GitHub Actions with Docker's own login action. I did not run this workflow. It is an example, and the registry address and secret names are placeholders:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ vars.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_TOKEN }}
      - run: docker build -t registry.example.com/team/app:${{ github.sha }} .
      - run: docker push registry.example.com/team/app:${{ github.sha }}

The same pattern without the action is one line: echo "$REGISTRY_TOKEN" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin. On hosted runners the machine is thrown away after the job. On a self-hosted runner it is not, so add docker logout at the end, or every later job on that runner is logged in as you.

💡 Tip: Give CI its own account or token, separate from any person. When someone leaves the team, you revoke their token, and the builds keep working.

Common mistakes

  • Turning on "insecure registries" to avoid setting up HTTPS on a registry outside 127.0.0.1. Then the password travels in clear text.
  • One token for everything. A read-only token is enough for servers that only pull.
  • Logging in with your personal account on a shared server. Everyone with access to that server can now push as you.

If someone copied your user profile's .docker folder today, which registries could they push to?

About

Run a registry with a password, log in from Windows and from a Linux command line, and see where the credentials end up. After this, you can log in from a laptop or CI without leaving tokens in files.

Duration
10 min
Last update
08/01/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.