Ansible git clone - clone public and private repositories with the git module (key_file, accept_hostkey, version, depth, force, update), tested on ansible-core 2.21

TL;DR

  • Public repository: ansible.builtin.git with repo, dest and version. The first run reports changed=true and the commit it checked out; the second run reports changed=false because the module compares the commit before and after.
  • Private repository over SSH: add key_file pointing at a deploy key on the target and accept_hostkey: true. Leave out accept_hostkey and the task fails with Host key verification failed; use the wrong key and it fails with Permission denied (publickey,password).
  • version takes a branch, a tag or a commit. Switching the same task from v1.0 to release-1 moved the checkout and reported the new commit.
  • depth: 1 is worth it: 3,336 ms and 1.4 MB in .git against 4,362 ms and 1.7 MB for the full history of the same small repository, about 24% faster.
  • Local edits on the server stop the module (Local modifications exist in the destination ... (force=no)); force: true resets them, update: false leaves an existing checkout alone for ever.
  • The shell module is not a substitute: git clone in shell works once and fails every run after that because the directory exists.
  • Every output below is from ansible-core 2.21.5 with git 2.55 on Ubuntu 24.04, cloning from GitHub and from a private bare repository over SSH on 2026-10-16.
  • Not your exact case? The closest post on this site is git@github.com.

What the Ansible git module needs for a public and a private repository, and what the second run does

When I wrote the first version of this post in 2023 I listed three ways to clone: the git module, the shell module and the command module. Only one of them is right, and the original post did not show why. For this rewrite I ran every variant against a real sshd and a real GitHub repository and kept the output, including the failures that people actually search for.

Table of Content

  1. The options that matter
  2. Clone a public repository
  3. Private repository over SSH - the two errors first
  4. Private repository over SSH - the working task
  5. Putting the deploy key on the server and on GitHub
  6. Second runs - update, force and local modifications
  7. Why not the shell or command module
  8. git is not installed on the target
  9. What broke when I ran this
  10. Conclusion

1. The options that matter

OptionWhat it doesDefaultWhen I set it
repoHTTPS or SSH URLrequiredalways
destdirectory on the targetrequiredalways
versionbranch, tag or commit to check outHEAD of the default branchalways; pinning a tag makes deploys repeatable
depthshallow clone with this many commitsfull history1 for deployments
key_fileprivate key on the target used for SSHnoneprivate repositories
accept_hostkeyadd the server's host key automaticallyfalseprivate repositories, or the task fails
updatefetch and move to version on later runstruefalse when the server owns the checkout
forcediscard local changes before updatingfalsetrue for deploy directories nobody should edit
executablepath to git on the targetgit on PATHcustom installs

The full list, including single_branch, recursive for submodules and bare, is in the ansible.builtin.git module documentation.


2. Clone a public repository

 1- hosts: web
 2  tasks:
 3    - name: Clone a public repository
 4      ansible.builtin.git:
 5        repo: https://github.com/rahulwagh/vpc-subnet-planner.git
 6        dest: /opt/vpc-subnet-planner
 7        version: main
 8        depth: 1
 9      register: out
10    - debug:
11        msg: "changed={{ out.changed }} before={{ out.before }} after={{ out.after }}"

The first run clones, the second run finds nothing to do:

 1TASK [Clone a public repository] ***********************************************
 2changed: [localhost]
 3
 4TASK [debug] *******************************************************************
 5ok: [localhost] => {
 6    "msg": "changed=True before= after=816d52eba0696a1e22c2ed6be0fdaa6028b69c06"
 7}
 8
 9--- second run: nothing changes
10TASK [Clone a public repository] ***********************************************
11ok: [localhost]
12
13TASK [debug] *******************************************************************
14ok: [localhost] => {
15    "msg": "changed=False before=816d52eba0696a1e22c2ed6be0fdaa6028b69c06 after=816d52eba0696a1e22c2ed6be0fdaa6028b69c06"
16}

before and after are the commit SHAs, which makes the module's own idempotence visible: when they match, nothing changed. The depth: 1 clone really is shallow:

1$ cd /opt/vpc-subnet-planner && git log --oneline -1 && git rev-parse --is-shallow-repository
2816d52e 1.2.0: guided step-by-step flow with a live blueprint, official cloud logos, expert view
3true

And it is faster. On the same small repository, timed with the whole playbook run:

ClonePlaybook timeSize of .git
depth: 13,336 ms1.4 MB
full history4,362 ms1.7 MB

That is about 24% less time for a repository with a short history; on a repository with years of commits the difference is far larger.


3. Private repository over SSH - the two errors first

For a private repository the target server needs a key that GitHub (or your Git server) accepts, and it needs to trust the server's host key. Here is what happens when you forget each one. First, no accept_hostkey and a fresh server that has never connected to the Git host:

1TASK [Clone private repo (no accept_hostkey, no key_file)] *********************
2[ERROR]: Task failed: Module failed: Host key verification failed.
3fatal: Could not read from remote repository.
4
5Please make sure you have the correct access rights
6and the repository exists.
7
8fatal: [localhost]: FAILED! => {"changed": false, "cmd": "/usr/bin/git ls-remote 'ssh:********@localhost/tmp/private.git' -h refs/heads/HEAD", "msg": "Host key verification failed.

Second, accept_hostkey: true but a key the server does not accept:

1TASK [Clone private repo (accept_hostkey but still no key)] ********************
2[ERROR]: Task failed: Module failed: Warning:********@localhost: Permission denied (publickey,password).
3fatal: Could not read from remote repository.

Both messages come from git, not from Ansible, which is why they look the same as the errors you get at a terminal. The cmd field shows the module starts with git ls-remote; if that command fails from a shell on the target with the same key, the playbook will fail the same way. My post on permission denied (publickey) walks through that debugging on the command line.


4. Private repository over SSH - the working task

 1- hosts: web
 2  tasks:
 3    - name: Clone private repo at tag v1.0
 4      ansible.builtin.git:
 5        repo: ssh://git@localhost/tmp/private.git     # git@github.com:org/private.git in real life
 6        dest: /opt/private
 7        version: v1.0
 8        key_file: /home/deploy/.ssh/deploy_key
 9        accept_hostkey: true
10      register: out
11    - debug:
12        msg: "changed={{ out.changed }} after={{ out.after }}"
1TASK [Clone private repo at tag v1.0] ******************************************
2changed: [localhost]
3ok: [localhost] => {
4    "msg": "changed=True after=016808e8088674411f4c3a29bbd8405828006cc9"
5}
6
7$ cat /opt/private/README.md; git -C /opt/private describe --tags
8# private app
9v1.0

Change version to a branch and run again, and the module moves the checkout:

1TASK [Clone private repo at tag v1.0] ******************************************
2changed: [localhost]
3ok: [localhost] => {
4    "msg": "changed=True after=c3a5d13a44d552f87ba9118bb4e5285dfb38f289"
5}
6
7$ cat /opt/private/README.md
8# private app
9v2

Two notes on accept_hostkey. It adds the host key the first time and never asks again, which is what you want on a fleet of servers. It also means a man-in-the-middle on the very first connection would be trusted; if that matters to you, distribute the Git host's public key with ansible.builtin.known_hosts first and leave accept_hostkey off. GitHub publishes its host key fingerprints for exactly that purpose.


5. Putting the deploy key on the server and on GitHub

The key_file is read on the target, not on the machine running Ansible, so the key has to be there first. My usual pattern is a dedicated deploy key per application, generated on the control node and copied once:

1- name: Install the deploy key
2  ansible.builtin.copy:
3    src: files/deploy_key
4    dest: /home/deploy/.ssh/deploy_key
5    owner: deploy
6    group: deploy
7    mode: "0600"

Then the public half goes to GitHub as a read-only deploy key on the repository, which is what the screenshots from the original post show:

Generate the key pair with ssh-keygen

GitHub - SSH and GPG keys settings

Save the public key on GitHub

A repository deploy key is better than a personal key here: it only reaches one repository, it can be read-only, and revoking it does not touch your own access. GitHub's managing deploy keys page compares the options. If you prefer HTTPS with a token, put the token in Ansible Vault and build the URL as https://{{ token }}@github.com/org/private.git, but remember the token then shows up in cmd in the task output unless you set no_log: true.


6. Second runs - update, force and local modifications

What the module does on a server that already has the checkout depends on two flags. With update: false, it never touches an existing checkout, even when the remote has moved:

1TASK [Only clone if missing] ***************************************************
2ok: [localhost]
3ok: [localhost] => { "msg": "changed=False" }

With the default update: true, the module fetches and moves to version. If somebody edited files on the server, it refuses:

1TASK [Reset to the remote, discarding local changes] ***************************
2[ERROR]: Task failed: Module failed: Local modifications exist in the destination: /opt/private (force=no).
3fatal: [localhost]: FAILED! => {"before": "c3a5d13a44d552f87ba9118bb4e5285dfb38f289", "changed": false, "msg": "Local modifications exist in the destination: /opt/private (force=no)."}

Add force: true and the same task resets the working tree and succeeds:

1TASK [Reset to the remote, discarding local changes] ***************************
2changed: [localhost]
3ok: [localhost] => { "msg": "changed=True" }
4
5$ tail -1 /opt/private/README.md
6v2

I use force: true for directories that only Ansible should write, because a failed deploy at 2 am over a forgotten hot-fix is worse than losing the hot-fix. For directories where people legitimately edit (a config repo on a jump host), update: false is the safer default, and a separate task with force: true handles the deliberate reset.


7. Why not the shell or command module

The original post listed shell: git clone ... as an alternative. Here is the problem with it:

1$ ansible-playbook -i inventory.ini shell.yml      # first run
2TASK [git clone with the shell module] *****************************************
3changed: [localhost]
4
5$ ansible-playbook -i inventory.ini shell.yml      # second run
6TASK [git clone with the shell module] *****************************************
7[ERROR]: Task failed: Module failed: The command exited with a non-zero return code.
8fatal: [localhost]: FAILED! => {"changed": true, "cmd": "git clone https://github.com/rahulwagh/vpc-subnet-planner.git /opt/shellclone", ...

git clone into an existing directory fails, so the playbook is broken from its second run onward. The usual patch is creates:

1    - name: git clone with the shell module
2      ansible.builtin.shell: git clone https://github.com/rahulwagh/vpc-subnet-planner.git /opt/shellclone
3      args:
4        creates: /opt/shellclone/.git
1TASK [git clone with the shell module] *****************************************
2ok: [localhost]

That makes it idempotent, but it also means the checkout is never updated again, and you have none of the version, force or before/after handling. The shell module documentation itself says to prefer a dedicated module when one exists. The command module has the same limitations and additionally cannot use pipes or redirects. If you still need a shell step after the clone, for example npm ci, run it as a separate task and use when: out.changed so it only runs after a real update.


8. git is not installed on the target

The module runs git on the target, so git has to exist there. Pointing executable at a missing path shows the error you get on a bare image:

1TASK [Clone with git missing from PATH] ****************************************
2[ERROR]: Task failed: Module failed: Error executing command: [Errno 2] No such file or directory: b'/usr/bin/git-does-not-exist'
3fatal: [localhost]: FAILED! => {"changed": false, "cmd": "/usr/bin/git-does-not-exist --version", "msg": "Error executing command.", "rc": 2, ...

Note the --version in cmd: the first thing the module does is ask git for its version. Install it first with a package task (ansible.builtin.apt: name=git or dnf), the same way I do in my Ansible installation post.


9. What broke when I ran this

All of this ran as one script on a GitHub Actions runner on 2026-10-16, with sshd on the runner standing in for the Git server. Three things are worth passing on.

1. The host-key error happened on the first SSH clone, not the second. I had cleaned ~/.ssh/known_hosts to simulate a fresh server, and the very first task failed with Host key verification failed even though the key was correct. On a real fleet this is the failure you see on new servers only, which makes it look random. accept_hostkey: true (or a known_hosts task) is the fix, not re-running the playbook.

2. Ansible masks the URL in the output. Every cmd shows ssh:********@localhost/.... That is Ansible hiding what it thinks might be a credential in the URL. Useful when a token is embedded, confusing when you are trying to read which user the clone used. Check the task's repo in your playbook rather than the log.

3. The timing difference was smaller than I expected. On a 1.7 MB repository, depth: 1 saved about one second out of four. The repository was small and the runner is close to GitHub; the saving grows with history size and with distance. I keep depth: 1 for deployments anyway, because it also keeps the server from holding years of source it does not need.


10. Conclusion

To clone a git repository with Ansible, use the git module and nothing else. To summarise what I ran:

  1. Public repository: repo, dest, version, and depth: 1 for a faster, smaller checkout.
  2. Private repository: a deploy key on the target in key_file, plus accept_hostkey: true or a known_hosts task, otherwise Host key verification failed.
  3. Pin version to a tag for repeatable deploys; the module reports before and after commits so you can see what moved.
  4. Decide what second runs should do: force: true for deploy directories, update: false for directories people edit.
  5. The shell module needs creates to survive a second run and still cannot update, so keep it for the steps after the clone.

If the clone is the first step of a bigger playbook, Ansible handlers are the clean way to restart a service only when the checkout actually changed, and limiting a run to some hosts is how I test a new version on one server first.

Did this save you a debugging session? Stay in the loop -