The Ansible package in Debian 13’s archive runs on ansible-core 2.19, which upstream stops maintaining in November 2026. Install it with pipx instead and you get ansible-core 2.21 with a newer set of bundled collections, in 31 seconds and without touching the system Python. That gap is the real decision here, so we measured both paths on one box.
You’ll install Ansible on Debian 13 with pipx or apt, then set up SSH, an inventory, ad-hoc commands, a first playbook and collections. Our lab was a single Debian 13.7 VM acting as control node and managing itself over SSH plus localhost, not a multi-host fleet. Every step was run there in October 2026 with Ansible 14.5.0 (ansible-core 2.21.5) from PyPI and Ansible 12.0.0 (ansible-core 2.19.11) from apt.
Choose how to install Ansible on Debian 13
You have three realistic options, and they differ in which ansible-core you end up with far more than in how hard they are to run. We installed each one on the same Debian 13 VM, one at a time, and measured the root filesystem with df before and after each install with the apt and uv caches cleared, so the three disk figures are directly comparable:
| Method | ansible-core | Collections | Disk used (df delta) | First install |
|---|---|---|---|---|
apt install ansible | 2.19.11 | 91 bundled | 509 MB | 72 s (26.8 MB download) |
pipx install --include-deps ansible | 2.21.5 | 92 bundled | 486 MB | 31 s |
uv tool install ansible-core | 2.21.5 | ansible.builtin only | 31 MB | 3.3 s |
The table hides the number that matters most: support. The ansible-core support matrix lists 2.19 as end of life in November 2026, with its security-fix window already closed on 18 May 2026, and 2.21 as supported until November 2027, and the Ansible 12 community package that Debian ships is already marked unmaintained. Debian may still backport security fixes into its own package, but new modules, collection updates and bug fixes stop at 2.19. So we’d install with pipx on any control node you plan to keep, and only use the apt package when your organisation forbids anything outside the Debian archive.
The uv row is there for a reason too. uv is not in the Debian 13 archive, so we installed it with Astral’s script from astral.sh. ansible-core on its own then takes 31 MB and installs in about three seconds, which suits CI runners and containers where you pull in only the collections a project lists. On a workstation or a long-lived control node, the full ansible package saves you from chasing missing modules, and that is the path the rest of this guide follows.
Prerequisites
The control node is the only machine that needs Ansible installed. Managed hosts need SSH and Python 3.9 or newer, because ansible-core 2.21 supports target Python 3.9 to 3.14. Current Debian, Ubuntu and RHEL-family releases ship that, but a RHEL 8 clone (Python 3.6) or Ubuntu 20.04 (3.8) needs a newer Python installed first, and a minimal Debian image may need sudo apt install python3. Have these ready before you start:
- A Debian 13 server or VM for the control node, with a sudo user
- One or more managed hosts reachable over SSH (TCP 22)
- About 500 MB of free disk for the pipx install
- The same login name on the control node and the managed hosts, or a note of the remote user
Ansible itself is light on hardware because it does its work over SSH and keeps nothing running between jobs. Our VM had 2 vCPUs and 2 GB of RAM, which is a floor for following along rather than a sizing guide. A control node that runs playbooks against hundreds of hosts at once needs more CPU and memory as you raise forks, since every fork is a separate Python process. If you are still learning the tool rather than running a fleet, a structured book helps more than extra RAM, and our roundup of Ansible books lists the ones that match current ansible-core.
Install Ansible with pipx
Debian 13 marks its system Python as externally managed (PEP 668), so the old pip install ansible instruction fails outright. On a minimal install you may instead get pip: command not found, since Debian does not ship pip by default; either way, go straight to pipx below. We installed python3-pip and tried it first so you can recognise the message:
pip install ansible
pip refuses before downloading anything, and the message already names both ways out. A hand-made virtual environment works, but you then have to activate it in every shell and in every cron job, which is the part people forget. The output is long, so here are its first and last lines:
error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.
...
note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
Don’t reach for --break-system-packages. It mixes PyPI packages into the directory apt owns, and the next Debian point release can overwrite them or break apt’s own Python tools. pipx gives Ansible a private virtual environment and puts its commands on your PATH. That is the method the official installation guide recommends for systems like this, so install pipx from the Debian archive:
sudo apt update
sudo apt install -y pipx
Now install the full Ansible package. The --include-deps flag matters, because the ansible package only ships the ansible-community command itself and every ansible-* binary actually comes from its ansible-core dependency. Without the flag pipx would hide them:
pipx install --include-deps ansible
On our VM the install took 31 seconds. pipx lists every command it linked into ~/.local/bin, and the list is the quickest proof that --include-deps did its job, because ansible-playbook and ansible-vault only appear with it:
installed package ansible 14.5.0, installed using Python 3.13.5
These apps are now globally available
- ansible
- ansible-community
- ansible-config
- ansible-console
- ansible-doc
- ansible-galaxy
- ansible-inventory
- ansible-playbook
- ansible-pull
- ansible-test
- ansible-vault
- cffi-gen-src
pipx also warned that /home/josphat/.local/bin is not on your PATH. That is normal on a fresh Debian account, because ~/.local/bin is not on the default PATH until it exists. Let pipx add it to your shell profile, then reload the profile so the current session sees it:
pipx ensurepath
source ~/.bashrc
Confirm which core you are running and where it lives, since the path tells you which install answered:
ansible --version
The executable and module paths should both point into the pipx virtual environment. If they show /usr/bin/ansible instead, an apt copy is still installed and wins on the PATH, so remove it before going further:
ansible [core 2.21.5]
config file = None
configured module search path = ['/home/josphat/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /home/josphat/.local/share/pipx/venvs/ansible/lib/python3.13/site-packages/ansible
ansible collection location = /home/josphat/.ansible/collections:/usr/share/ansible/collections
executable location = /home/josphat/.local/bin/ansible
python version = 3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0] (/home/josphat/.local/share/pipx/venvs/ansible/bin/python)
jinja version = 3.1.6
pyyaml version = 6.0.3 (with libyaml v0.2.5)
config file = None is expected at this point. Ansible reads ansible.cfg from the current directory unless the ANSIBLE_CONFIG variable points somewhere else, and we create one per project in a moment. If you only want ansible-core, swap the package name for ansible-core and drop --include-deps; you then install collections yourself as shown further down.
Install Ansible from the Debian repository instead
The apt route makes sense when policy says every package comes from the Debian archive, or when unattended-upgrades must cover Ansible too. Skip this section if you used pipx, because having both installed only makes it harder to tell which ansible you are calling.
sudo apt update
sudo apt install -y ansible
apt downloaded 26.8 MB and the root filesystem grew by 509 MB once the package cache was cleared, and most of that is the 436 MB ansible_collections tree rather than ansible-core. Those modules are the reason the apt route is still usable, because the core is older but most of what you call lives in collections. The version check shows the older core.
ansible --version
Note that the package no longer creates /etc/ansible/, which many older guides still tell you to edit. We checked: the directory does not exist after install, and config file reads None just like the pipx copy:
ansible [core 2.19.11]
config file = None
configured module search path = ['/home/josphat/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /usr/lib/python3/dist-packages/ansible
ansible collection location = /home/josphat/.ansible/collections:/usr/share/ansible/collections
executable location = /usr/bin/ansible
python version = 3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0] (/usr/bin/python3)
jinja version = 3.1.6
pyyaml version = 6.0.2 (with libyaml v0.2.5)
Everything else in this guide works on either install, with one caveat we hit later: ansible-core 2.21 warns about facts used as top-level variables, while 2.19 stays quiet. Writing templates the 2.21 way, as shown in the playbook section, works on both.
Set up SSH access to the managed hosts
Ansible logs in to every managed host over SSH, and it never answers a password or host-key prompt for you. Both have to be sorted out first, which is why most first runs fail here rather than in a playbook. Set the managed host’s address and your remote login once, because the next few commands reuse them:
export HOST_IP="10.0.1.50"
export REMOTE_USER="${USER}"
Replace 10.0.1.50 with your managed host’s IP and set REMOTE_USER to its login if that differs from your local one. These exports only last for the current shell, so run them again if you reconnect. Then create a key pair for the control node. We used an empty passphrase for the lab; on a shared control node, set a passphrase and load the key into ssh-agent instead:
ssh-keygen -t ed25519 -C ansible-control
Copy the public key to each managed host. ssh-copy-id asks for the remote password once and then appends the key to that user’s authorized_keys, so the password is never needed by Ansible afterwards. Repeat it per host if you have several.
ssh-copy-id "${REMOTE_USER}@${HOST_IP}"
Our managed host was the control node itself and the cloud image had password logins disabled, so we appended ~/.ssh/id_ed25519.pub to ~/.ssh/authorized_keys directly. The result is the same. Before you did either, though, Ansible fails in two different ways, and both are worth recognising. With the key in place but the host never seen before, the first ping stops on the host key:
ansible01 | UNREACHABLE! => {
"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh: Host key verification failed.",
"unreachable": true
}
Ansible keeps host_key_checking on by default, so an unknown key is a hard failure rather than a prompt. Record the host key once, ideally after checking its fingerprint against the server console:
ssh-keyscan -H "${HOST_IP}" >> ~/.ssh/known_hosts
If the key was not copied yet, the next attempt fails with Permission denied (publickey) instead. Our lab printed this before we added the key:
ansible01 | UNREACHABLE! => {
"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh: [email protected]: Permission denied (publickey).",
"unreachable": true
}
So the three SSH failures you are likely to meet each map to a different fix:
Host key verification failed: runssh-keyscanfor that hostPermission denied (publickey): copy the key withssh-copy-idConnection timed out: open TCP 22 or fixansible_host
A plain ssh "${REMOTE_USER}@${HOST_IP}" hostname that returns the hostname without a prompt means Ansible will connect too. Fix SSH until that works, because no Ansible setting will paper over it.
Create the inventory and ansible.cfg
The inventory tells Ansible which hosts exist and how to reach them, and a project-level ansible.cfg saves you from passing -i inventory.ini on every command. Keep both in one directory per project so each project carries its own settings in Git. The commands use vim, which our cloud image already had; on a minimal install run sudo apt install -y vim or swap in nano:
mkdir -p ~/ansible-lab
cd ~/ansible-lab
vim inventory.ini
This is the inventory we ran, with our lab IP replaced by the example address. The web group holds the managed host, and localhost with ansible_connection=local lets Ansible manage the control node without SSH:
[web]
ansible01 ansible_host=10.0.1.50
[control]
localhost ansible_connection=local
[all:vars]
ansible_user=josphat
Change ansible01, the IP and ansible_user to match your host. Edit these project files as your normal user, not with sudo, because Ansible runs as you and root-owned files in a project directory end up needing sudo for every later edit or commit. Next, the project config:
vim ~/ansible-lab/ansible.cfg
Two settings are enough for now. inventory points at the file above, and interpreter_python = auto_silent keeps Python discovery but drops the warning it prints on every new host. We left host_key_checking at its default of on, because turning it off (as many older guides do) means Ansible will happily talk to whatever answers on that IP, including a rebuilt or hijacked host. Add the two lines:
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
Check that Ansible parses the inventory the way you meant it. This reads the files only, so it works before SSH does:
ansible-inventory --graph
Each group should list its hosts. A host that shows up under ungrouped usually means a typo in a section header, and a missing host means the line was swallowed by the wrong section, so fix the file before running anything against it.
@all:
|--@ungrouped:
|--@web:
| |--ansible01
|--@control:
| |--localhost
Inventories grow quickly once you add groups, group variables and dynamic sources. The inventory management guide covers YAML inventories, group_vars and patterns when the INI file stops being enough.
Run Ansible ad-hoc commands
Ad-hoc commands run one module against a host pattern without a playbook, which makes them the quickest way to prove the setup works. Start with ping, which logs in, runs Python on the target and returns pong. It sends no ICMP at all:
ansible all -m ping
Both hosts answered in our lab, one over SSH and one locally. The discovered_interpreter_python line shows Ansible found Debian’s Python 3.13 on its own, so no interpreter setting is needed for Debian targets.
localhost | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.13"
},
"changed": false,
"ping": "pong"
}
ansible01 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.13"
},
"changed": false,
"ping": "pong"
}
Facts are the next thing worth seeing, because playbooks branch on them all the time. The setup module with a filter returns only the distribution facts:
ansible web -m ansible.builtin.setup -a "filter=ansible_distribution*"
The host reports itself as Debian 13.7, codename trixie. Note that the major version comes back as a string, so a when: condition should compare ansible_facts['distribution_major_version'] == '13' with quotes:
ansible01 | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Debian",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "13",
"ansible_distribution_minor_version": "7",
"ansible_distribution_release": "trixie",
"ansible_distribution_version": "13.7",
"discovered_interpreter_python": "/usr/bin/python3.13"
},
"changed": false
}
Installing a package needs root on the managed host, and Ansible only escalates when you ask it to. We left --become off on purpose to see the failure:
ansible web -m ansible.builtin.apt -a "name=htop state=present"
apt cannot take its lock as a normal user, and the error says so. Ansible passes the module’s stderr through untouched, so the message is the same one you would get typing apt-get install without sudo:
ansible01 | FAILED! => {
...
"stderr_lines": [
"E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)",
"E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?"
],
Add --become and the module runs through sudo. Run the same command twice and you see idempotency in action: the first run reports CHANGED, the second SUCCESS with "changed": false, because the package is already in the state you asked for. If your sudo user needs a password, add --ask-become-pass as well:
ansible web -m ansible.builtin.apt -a "name=tree state=present update_cache=true" --become
Ad-hoc commands are fine for checks and one-off fixes, but anything you would run twice belongs in a playbook, because a playbook is reviewed and kept in Git. The ad-hoc commands reference has the patterns for services, files, users and parallelism once these basics work.
Write and run your first playbook
A playbook records the desired state in YAML, so the same run gives the same result on the tenth host as on the first. This one installs Nginx, writes a site config and an index page from templates, keeps the service running, and reloads Nginx only when the config actually changes. Create the playbook file.
vim ~/ansible-lab/web.yml
Paste the playbook exactly as we ran it. Every module uses its fully qualified name (ansible.builtin.apt rather than apt), which avoids surprises when a collection ships a module with the same short name:
---
- name: Install and configure Nginx
hosts: web
become: true
vars:
http_port: 8080
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy the site config
ansible.builtin.template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/default
mode: "0644"
notify: Reload Nginx
- name: Deploy the index page
ansible.builtin.template:
src: templates/index.html.j2
dest: /var/www/html/index.html
mode: "0644"
- name: Keep Nginx running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
The two templates live in a templates/ directory next to the playbook. Create it and the site config first:
mkdir -p ~/ansible-lab/templates
vim ~/ansible-lab/templates/site.conf.j2
The {{ http_port }} variable comes from the play’s vars, which is what lets you change the port later without editing the template:
server {
listen {{ http_port }} default_server;
root /var/www/html;
index index.html;
}
Now open the index page template. It only exists to prove facts reach the managed host, so keep it to one line:
vim ~/ansible-lab/templates/index.html.j2
It reads facts through ansible_facts['...']. Our first version used the old top-level ansible_distribution variable, and ansible-core 2.21 printed [DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to `True` is deprecated ... This feature will be removed from ansible-core version 2.24 on every run. This form is quiet on 2.21 and works the same on Debian’s 2.19:
<h1>{{ inventory_hostname }} runs {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}</h1>
Check the syntax before touching the host. --syntax-check only parses the YAML and the play structure, so it catches indentation mistakes in a second.
cd ~/ansible-lab
ansible-playbook web.yml --syntax-check
ansible-playbook web.yml
The play uses become: true, and our lab user had passwordless sudo. If your sudo asks for a password, add -K (--ask-become-pass) to every ansible-playbook run, so Ansible can answer the sudo prompt on the managed host.
On the first real run, three tasks changed and the handler fired once at the end, after the site config was written, which is why the recap counts four changes:
PLAY [Install and configure Nginx] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [ansible01]
TASK [Install Nginx] ***********************************************************
changed: [ansible01]
TASK [Deploy the site config] **************************************************
changed: [ansible01]
TASK [Deploy the index page] ***************************************************
changed: [ansible01]
TASK [Keep Nginx running and enabled] ******************************************
ok: [ansible01]
RUNNING HANDLER [Reload Nginx] *************************************************
changed: [ansible01]
PLAY RECAP *********************************************************************
ansible01 : ok=6 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Run it again and the recap reads changed=0, with no handler, because nothing differs from the desired state. That second run is the real test of a playbook: any task that reports changed every time (a bare shell task, usually) is lying to you and will trigger handlers for nothing. The page is now served on port 8080:
curl -s "http://${HOST_IP}:8080/"
The template filled in the hostname and the facts gathered from the managed host, so the page differs per host without a separate file for each.
<h1>ansible01 runs Debian 13.7</h1>
The capture below repeats that check in a terminal on the lab VM after the pipx install, with both hosts answering and the page Ansible deployed:

To see the handler earn its keep, change the port from the command line and ask for a diff. Extra variables passed with -e override the play’s vars:
ansible-playbook web.yml -e http_port=8081 --diff
Only the site config changed, the diff shows the one line, and Nginx reloaded because of it. The index page task stayed ok, so it did not trigger anything. Running the playbook once more without -e put the port back on 8080 in our lab, with the mirror-image diff and another reload:
TASK [Deploy the site config] **************************************************
--- before: /etc/nginx/sites-available/default
+++ after: /home/josphat/.ansible/tmp/ansible-local-19462i2zp5bed/tmpfxcsjykm/site.conf.j2
@@ -1,5 +1,5 @@
server {
- listen 8080 default_server;
+ listen 8081 default_server;
root /var/www/html;
index index.html;
}
changed: [ansible01]
TASK [Deploy the index page] ***************************************************
ok: [ansible01]
TASK [Keep Nginx running and enabled] ******************************************
ok: [ansible01]
RUNNING HANDLER [Reload Nginx] *************************************************
changed: [ansible01]
Handlers, variables and templates each get a full chapter in the series, starting with the playbook tutorial and the handlers chapter.
Check mode fails with “Could not find the requested service nginx”
We ran ansible-playbook web.yml --check --diff before the first real run, and it stopped at the service task. The earlier tasks had reported changed and printed their diffs, so the failure looks like a broken playbook at first glance:
TASK [Keep Nginx running and enabled] ******************************************
[ERROR]: Task failed: Module failed: Could not find the requested service nginx: host
Origin: /home/josphat/ansible-lab/web.yml:29:7
Check mode only predicts changes, so the apt task reported Nginx as installed without installing it, and the service module then looked for a unit that does not exist yet. The playbook is fine. Either run check mode only against hosts that already have the package, or mark tasks that depend on an earlier install with ignore_errors: "{{ ansible_check_mode }}" so a dry run on a fresh host completes.
Add collections to a project
Modules outside ansible.builtin live in collections. The full package you installed with pipx already carries 92 of them, including community.general 13.5.0, ansible.posix 2.2.2 and community.docker 5.4.0, while the Debian package carries 91 at older versions (community.general 11.2.1). That is plenty for learning, but a project should still declare what it needs, so a colleague on ansible-core alone, or a CI runner, gets the same modules. Create a requirements file in the project.
vim ~/ansible-lab/requirements.yml
Pin a minimum version for anything you depend on, so an older bundled copy cannot satisfy it silently. Without the pin, a teammate on Debian’s apt package would pass this check with community.general 11.2.1 and hit missing module options later:
---
collections:
- name: community.general
version: ">=13.0.0"
- name: ansible.posix
Install from the file. Against the full package nothing needs downloading, which makes this a cheap check that the bundle already covers the project.
ansible-galaxy collection install -r requirements.yml
Ansible confirms the bundled copies already satisfy the file. On an ansible-core-only install, the same command downloads three collections into ~/.ansible/collections, because community.general 13 pulls in community.library_inventory_filtering_v1 as a dependency. We tested that on a uv-installed ansible-core: the three came to 33 MB, so a CI runner built that way stays around 64 MB instead of half a gigabyte of modules nobody calls. The full package printed this:
Starting galaxy collection install process
Nothing to do. All requested collections are already installed. If you want to reinstall them, consider using `--force`.
When you need to know which copy of a collection Ansible will load, ansible-galaxy collection list community.general prints each location with its version, and the first path in the collection search order wins. The collections tutorial covers private Galaxy servers and offline installs.
Secrets are the other thing a real project needs before it goes into Git. Database passwords and API tokens belong in files encrypted with ansible-vault, which ships with both installs. Our Ansible Vault guide was tested on this same Debian 13 VM and covers vault IDs, password scripts and the ways a vaulted value can still leak into output.
Upgrade or remove Ansible
The pipx install upgrades in one command, and --include-injected also upgrades any extra Python packages you added to its environment with pipx inject:
pipx upgrade --include-injected ansible
When we ran it, 14.5.0 was the newest release on PyPI, so pipx had nothing to do. A real upgrade prints the old and new versions on the last line instead.
upgrading shared libraries...
upgrading ansible...
ansible is already at latest version 14.5.0 (location:
/home/josphat/.local/share/pipx/venvs/ansible)
Read the porting guide before you upgrade a control node that runs production playbooks, because every ansible-core X.Y.0 release is a major release and can change behaviour. The 2.21 fact-variable deprecation above is a good example: harmless today, an error once 2.24 lands. Removing Ansible depends on how you installed it:
- pipx:
pipx uninstall ansibledeletes the whole virtual environment - apt:
sudo apt purge ansible ansible-core, thensudo apt autoremove - uv:
uv tool uninstall ansible-core
Your project directories, inventories and ~/.ansible/collections stay untouched either way, so you can switch install methods without losing work. If you also run Ansible from Rocky Linux or Ubuntu machines, the Rocky Linux and Ubuntu install guide covers the same pipx path on those systems.