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.gitwithrepo,destandversion. The first run reportschanged=trueand the commit it checked out; the second run reportschanged=falsebecause the module compares the commit before and after. - Private repository over SSH: add
key_filepointing at a deploy key on the target andaccept_hostkey: true. Leave outaccept_hostkeyand the task fails withHost key verification failed; use the wrong key and it fails withPermission denied (publickey,password). versiontakes a branch, a tag or a commit. Switching the same task fromv1.0torelease-1moved the checkout and reported the new commit.depth: 1is worth it: 3,336 ms and 1.4 MB in.gitagainst 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: trueresets them,update: falseleaves an existing checkout alone for ever. - The shell module is not a substitute:
git cloneinshellworks 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.
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
- The options that matter
- Clone a public repository
- Private repository over SSH - the two errors first
- Private repository over SSH - the working task
- Putting the deploy key on the server and on GitHub
- Second runs - update, force and local modifications
- Why not the shell or command module
- git is not installed on the target
- What broke when I ran this
- Conclusion
1. The options that matter
| Option | What it does | Default | When I set it |
|---|---|---|---|
repo | HTTPS or SSH URL | required | always |
dest | directory on the target | required | always |
version | branch, tag or commit to check out | HEAD of the default branch | always; pinning a tag makes deploys repeatable |
depth | shallow clone with this many commits | full history | 1 for deployments |
key_file | private key on the target used for SSH | none | private repositories |
accept_hostkey | add the server's host key automatically | false | private repositories, or the task fails |
update | fetch and move to version on later runs | true | false when the server owns the checkout |
force | discard local changes before updating | false | true for deploy directories nobody should edit |
executable | path to git on the target | git on PATH | custom 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:
| Clone | Playbook time | Size of .git |
|---|---|---|
depth: 1 | 3,336 ms | 1.4 MB |
| full history | 4,362 ms | 1.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:



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:
- Public repository:
repo,dest,version, anddepth: 1for a faster, smaller checkout. - Private repository: a deploy key on the target in
key_file, plusaccept_hostkey: trueor aknown_hoststask, otherwiseHost key verification failed. - Pin
versionto a tag for repeatable deploys; the module reportsbeforeandaftercommits so you can see what moved. - Decide what second runs should do:
force: truefor deploy directories,update: falsefor directories people edit. - The shell module needs
createsto 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 -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.