Philipp Lehmann @PhilippTheServer

Docker Registry Credentials for Two Local Users on One Host

Docker reads whoever invoked the CLI's config, so logging in as root alone leaves a deploy user unauthenticated.

The problem

An Ansible role provisions a host, logs into a private registry as part of that provisioning, and later a deploy pipeline running as an unprivileged deploy user tries to pull an image and gets:

Error response from daemon: pull access denied for registry.example.com/app,
repository does not exist or may require 'docker login'

The login happened. It is right there in the play recap, changed: true, no errors. The confusion is that “logged in” was never a host-wide fact — it was true for exactly one user, and it was probably root, because that is who Ansible tasks run as by default once become: true is set.

# This authenticates root. Nobody else.
- name: Log in to the registry
  community.docker.docker_login:
    registry_url: registry.example.com
    username: "{{ registry_username }}"
    password: "{{ registry_password }}"
  become: true

The Docker CLI does not consult a single system-wide credential store. It reads $HOME/.docker/config.json of the user invoking docker, full stop. become: true without a specific become_user runs as root, which writes /root/.docker/config.json. A deploy user with membership in the docker group can run docker commands just fine — group membership is what lets it talk to the daemon socket at all — but it has its own, entirely separate $HOME/.docker/config.json, which the login task never touched.

The failure mode is quiet because both the login task and the pull command, on their own, behave exactly as documented. The mismatch only exists in the gap between them: two different users, two different home directories, one shared assumption that “logged in” means something host-wide.

Working through it

Where the CLI actually looks

docker login and every module that wraps it write to ~/.docker/config.json for the user context they ran in. docker pull, in turn, reads that same file for the user running the pull. There is no daemon-side credential store for this — authentication is a client-side concern, mediated entirely through that JSON file and, optionally, a credential helper it references.

This means “did the login succeed” and “will this user’s pull succeed” are two different questions whenever more than one user account runs docker commands on a host. It comes up constantly with CI runners and deploy tooling, where root provisions the host but an unprivileged service account performs the actual deployment for defence-in-depth reasons.

Two honest ways to fix it

The direct fix is to run the login as every user that needs it, rather than once as root:

- name: Log in to the registry as root
  community.docker.docker_login:
    registry_url: registry.example.com
    username: "{{ registry_username }}"
    password: "{{ registry_password }}"
  become: true
  become_user: root

- name: Log in to the registry as the deploy user
  community.docker.docker_login:
    registry_url: registry.example.com
    username: "{{ registry_username }}"
    password: "{{ registry_password }}"
  become: true
  become_user: deploy

This is explicit and easy to audit, at the cost of the credential landing in two files that now both need the same lifecycle — rotate the password and both need updating, or you are back to one working user and one silently stale one.

The other approach is to write the config file directly with config_path, and control its permissions yourself, which lets you loop over every user that needs it from one credential source instead of one task per user:

- name: Log in as every local user that needs the registry
  community.docker.docker_login:
    registry_url: registry.example.com
    username: "{{ registry_username }}"
    password: "{{ registry_password }}"
    config_path: "/home/{{ item }}/.docker/config.json"
  loop: "{{ registry_consumer_users }}"
  become: true

- name: Fix ownership of the config file the module just wrote
  ansible.builtin.file:
    path: "/home/{{ item }}/.docker/config.json"
    owner: "{{ item }}"
    group: "{{ item }}"
    mode: "0600"
  loop: "{{ registry_consumer_users }}"
  become: true

The ownership fix matters because the module writes the file as whichever user the task ran as — root, here, since become_user was not overridden — regardless of whose home directory the path points into. Skip that step and the target user cannot even read their own credential file.

Neither option survives a plaintext password in the repository

registry_password has to come from Ansible Vault or an external secrets store, not a group_vars file in cleartext — this is true regardless of which user ends up holding the credential. That is a separate problem from the one this article is about, but it is the one that turns a config mistake into an incident, so it is worth stating plainly rather than leaving as an implied assumption in the examples below.

The solution

A complete, reproducible test: a local authenticated registry running in a container, and an Ansible playbook that provisions credentials for two users, verified by having both actually pull.

# Set up a throwaway authenticated registry to test against.
mkdir -p auth
docker run --rm --entrypoint htpasswd httpd:2.4 -Bbn testuser testpass123 > auth/htpasswd

docker run -d --name test-registry -p 5000:5000 \
  -v "$(pwd)/auth:/auth" \
  -e REGISTRY_AUTH=htpasswd \
  -e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  registry:2.8.3

docker pull traefik/whoami:v1.10.3
docker tag traefik/whoami:v1.10.3 localhost:5000/whoami:v1.10.3
docker login localhost:5000 -u testuser -p testpass123
docker push localhost:5000/whoami:v1.10.3
docker logout localhost:5000
# requirements.yml
collections:
  - name: community.docker
    version: "3.13.2"
# site.yml
- name: Provision registry credentials for every consuming user
  hosts: docker_hosts
  vars:
    registry_url: "localhost:5000"
    registry_username: "testuser"
    registry_password: "testpass123"   # In Vault in anything real.
    registry_consumer_users:
      - root
      - deploy
  tasks:
    - name: Ensure the deploy user exists
      ansible.builtin.user:
        name: deploy
        groups: docker
        append: true
        shell: /bin/bash
      become: true

    - name: Ensure each user's .docker directory exists
      ansible.builtin.file:
        path: "{{ '/root/.docker' if item == 'root' else '/home/' + item + '/.docker' }}"
        state: directory
        owner: "{{ item }}"
        group: "{{ item }}"
        mode: "0700"
      loop: "{{ registry_consumer_users }}"
      become: true

    - name: Log in as every consuming user
      community.docker.docker_login:
        registry_url: "{{ registry_url }}"
        username: "{{ registry_username }}"
        password: "{{ registry_password }}"
        config_path: "{{ '/root/.docker/config.json' if item == 'root' else '/home/' + item + '/.docker/config.json' }}"
      loop: "{{ registry_consumer_users }}"
      become: true
      register: login_result

    - name: Fix ownership of the written config files
      ansible.builtin.file:
        path: "{{ '/root/.docker/config.json' if item == 'root' else '/home/' + item + '/.docker/config.json' }}"
        owner: "{{ item }}"
        group: "{{ item }}"
        mode: "0600"
      loop: "{{ registry_consumer_users }}"
      become: true

Verifying it

ansible-galaxy collection install -r requirements.yml
ansible-playbook -i inventory site.yml

# On the host, as each user in turn:
sudo docker pull localhost:5000/whoami:v1.10.3
sudo -u deploy docker pull localhost:5000/whoami:v1.10.3

Correct output is an identical, successful pull for both invocations. Before the fix, sudo docker pull (root) succeeds and sudo -u deploy docker pull fails with pull access denied, which is the exact split this article is about, reproduced on a laptop with no private infrastructure involved.

Conclusion

Docker authentication is per-user client state, not a host-wide fact. Anything that says “the registry login task ran” has to also say “as which user,” because that is the only scope the credential actually has.

become: true without an explicit become_user defaults to root, and root’s config is usually not the config that matters. The user actually running docker pull in production — a deploy account, a CI runner’s service account — is very often not root, and provisioning has to target that account specifically.

A credential written for two users is two copies to keep in sync, not one. Rotation, expiry and revocation all need to happen against every file you wrote, which is a good argument for a credential helper backed by a real secrets manager once there are more than one or two consuming accounts on a host.