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.
-
Faster Builds
-
BuildKit: Cache Mounts and Why Rebuilds Are Slow
-
Multi-Stage Builds Without Shipping the Compiler
-
-
-
Images That Don't Leak
-
Non-Root Users and Dropping Capabilities
-
Pin Digests, Base Image Updates and CVEs
-
-
-
Running Under Load
-
Resource Limits, Restart Policies and Healthchecks
-
Logging Drivers and What Happens When Disk Fills
-
-
-
Beyond One Compose File
-
Compose Profiles, Watch and Override Files
-
Private Registries and Auth Without Sticking Tokens in Files
-
-
-
Production Concerns
-
Read-Only Root Filesystems and tmpfs
-
Rolling a Service Forward on One Host
-
When Docker Desktop Is the Wrong Tool
-
-
- Final Check: Docker Advanced
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
Comments (0)
Sign in to commentNo comments yet.