Package #
In the Linux ecosystem, software installation and maintenance (package management) is one of the main pillars of system administration. Various Linux distributions have different built-in package managers, like APT on the Debian/Ubuntu family and YUM/DNF on the Red Hat family (RHEL/CentOS/Fedora). Managing these installations manually in a multi-server environment creates version inconsistency gaps, insecure repositories, and tracking difficulties. Ansible simplifies this process by providing native modules for each package manager plus a generic abstraction module. By deeply understanding how these modules work, you can design consistent, high-performance, idempotent software deployment processes across your entire infrastructure.
Package Management Philosophy: Generic Abstraction vs Native Modules #
When designing Ansible playbooks that run across various environments, you’re often faced with a choice: should you use the generic module that automatically detects the operating system, or the native module specific to a certain distribution?
Ansible provides the ansible.builtin.package module as a generic abstraction layer. This module detects the package manager running on the managed node using system facts (ansible_facts.pkg_mgr) and maps the command to the appropriate native module, like apt, dnf, or yum.
flowchart TD
A["Start Package Task"] --> B{"Module Type?"}
B -- "Generic: package" --> C["Ansible Detects ansible_facts.pkg_mgr"]
C --> D{"Package Manager Detected?"}
D -- "apt" --> E["Execute the apt Module (Debian/Ubuntu)"]
D -- "dnf" --> F["Execute the dnf Module (RHEL 8+)"]
D -- "yum" --> G["Execute the yum Module (CentOS 7)"]
B -- "Native: apt/dnf/yum" --> H["Call the Native Module Directly"]
H --> I["Use Specific Features (e.g. purge, autoremove, dnf modules)"]
E --> J["Done: Package Installed"]
F --> J
G --> JUsing the generic package module is very helpful if you only need to make sure basic utilities like curl, git, or tmux are installed on all servers regardless of their operating system. However, the generic module has a major limitation: it only supports basic options common to all package managers (like name and state).
If you need advanced features — like cleaning old configuration files on Debian (purge on the apt module), or managing application modules on RHEL (module_hotfixes or modular streams on the dnf module) — you must use the native module. Additionally, package names often differ between distributions. For example, the Apache web server is named apache2 on Ubuntu/Debian, but httpd on RHEL/CentOS. Therefore, the best approach for multi-OS infrastructure is separating package name lists per operating system into distinct variables and calling the native module using when conditions.
Here’s a comparison of the main features between the native apt and dnf modules:
| Feature | apt Module (Debian/Ubuntu) | dnf Module (RHEL/CentOS/Fedora) |
|---|---|---|
| Configuration Cleanup | Supported via the purge: true option | Not directly supported |
| Dependency Cleanup | Supported via the autoremove: true option | Supported via the autoremove: true option |
| Package Cache Update | Supported via the update_cache: true option | Supported via the update_cache: true option |
| Modular Package Streams | Not supported (uses PPA repositories) | Natively supported via the @module syntax |
| Group Installation | Uses special task names | Natively supported via @Group Name |
Managing Packages on Debian and Ubuntu with the apt Module #
The ansible.builtin.apt module is used specifically for managing packages on Debian and Ubuntu-based systems. The installation process using this module requires attention to execution efficiency and repository cache management.
One of the most used parameters is update_cache. This parameter is equivalent to running the apt-get update command in the terminal. If you set update_cache: true on every package installation task, your playbook execution time becomes very slow because Ansible has to contact the repository servers repeatedly. To optimize it, you must use the cache_valid_time parameter. This parameter tells Ansible to only update the cache if the last update has passed a certain time limit (in seconds).
Let’s study an efficient and idempotent package installation example:
# ANTI-PATTERN: Using a loop to install many packages, very slow because it triggers apt repeatedly
- name: Install utility packages (loop)
apt:
name: "{{ item }}"
state: present
loop:
- curl
- git
- tmux
# CORRECT: Giving a package list directly to the name parameter, executed in one go
- name: Install utility packages efficiently
apt:
name:
- curl
- git
- tmux
state: present
update_cache: true
cache_valid_time: 3600 # Only run 'apt update' if the cache is older than 1 hour
By giving a list to the name parameter, Ansible abstracts that instruction into a single apt command call in the background (for example apt-get install curl git tmux). This is much faster than calling the apt command three times in a loop.
If you need to remove a package thoroughly, including its related global configuration files, you must set the state: absent parameter together with purge: true and autoremove: true:
- name: Remove Apache2 cleanly from the system
apt:
name: apache2
state: absent
purge: true # Removes global configuration files in /etc/apache2/
autoremove: true # Removes no-longer-used dependency packages
To guarantee application reliability on production servers, you’re also advised to specify the exact version of the package to install. This prevents your application from breaking due to incompatible package version updates:
- name: Install a specific Nginx version
apt:
name: nginx=1.24.0-1~jammy
state: present
Managing Packages on RHEL, CentOS, and Fedora with yum and dnf #
On Red Hat-based systems (RHEL, CentOS, Rocky Linux, AlmaLinux, and Fedora), package management uses the ansible.builtin.yum module (for old versions like CentOS 7) or the ansible.builtin.dnf module (for modern RHEL 8 and above).
Modern RHEL systems use the Application Streams concept (modular packages) that lets you choose a specific software version stream to install from the same repository. The dnf module in Ansible fully supports this feature natively.
Here’s an example of installing modular packages and managing package groups on CentOS/RHEL:
# Installing Node.js from a specific version stream using dnf
- name: Install Node.js version 18 from the modular stream
dnf:
name: "@nodejs:18/common"
state: present
# Installing the Development Tools package group
- name: Install the Development Tools group
dnf:
name: "@Development Tools"
state: present
# Removing unwanted packages
- name: Remove podman if installed
dnf:
name: podman
state: absent
autoremove: true
The @ syntax is used by DNF to identify that the name you entered is an application module or package group, not a single regular package. This gives you high flexibility to deploy technology stacks that require specific version runtimes consistently.
Repository and Security Key Management #
Before you can install packages not available in the OS’s default repositories (like Docker, PostgreSQL, or Node.js), you must add the repository key (GPG Key) and the repository address itself to the managed node system.
Ansible provides declarative modules for this purpose. You must not write repositories manually into /etc/apt/sources.list using text modules because that’s not idempotent and is prone to entry duplication.
Here’s how to deploy third-party repositories on Debian/Ubuntu safely:
- name: Set up the Docker repository on Ubuntu
block:
# Downloading and registering the repository GPG key
- name: Add the official Docker GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
# Adding the repository to the sources.list.d list
- name: Add the Docker CE repository
apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
filename: docker-ce # Stored in /etc/apt/sources.list.d/docker-ce.list
update_cache: true # Run apt update after the repository is added
# Installing Docker after the repository is ready
- name: Install Docker Engine
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
state: present
Meanwhile, on RHEL-based systems, custom repository management is done using the ansible.builtin.yum_repository module. This module automatically creates .repo configuration files in the /etc/yum.repos.d/ directory:
- name: Add a custom PostgreSQL repository on RHEL
yum_repository:
name: pgdg15
description: PostgreSQL 15 RPM-based repository
baseurl: https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/
gpgcheck: true
gpgkey: https://download.postgresql.org/pub/repos/yum/RPM-GPG-KEY-PGDG-15
state: present
By managing repositories as separate resources using these dedicated modules, Ansible can verify GPG key integrity and repository addresses on every run, and update them if URLs change without breaking other repository configurations.
In-Depth State Analysis: present vs latest vs absent #
Choosing the value for the state parameter is a very important design decision in configuration management. Ansible supports several statuses for package management, with the following behaviors:
present(orinstalled): This status ensures the package is installed on the server. If the package doesn’t exist, Ansible installs it. If the package already exists (regardless of version), Ansible takes no action. This is the safest status for production operations because it maintains your application version stability.latest: This status ensures the package is installed with the newest version available in the repository. If the package is installed but a newer version exists in the repository, Ansible performs an upgrade. Usingstate: latestin production environments is an anti-pattern because uncontrolled automatic updates can suddenly break your application compatibility without testing.absent(orremoved): This status ensures the package is removed from the system. If the package doesn’t exist, Ansible takes no action (idempotent).
Here’s an execution scenario comparison for each status:
| Initial Server Condition | Target State: present | Target State: latest | Target State: absent |
|---|---|---|---|
| Package missing | Installs the package (status: changed) | Installs the latest version (status: changed) | Takes no action (status: ok) |
| Package exists (old version) | Takes no action (status: ok) | Upgrades the package (status: changed) | Removes the package (status: changed) |
| Package exists (latest version) | Takes no action (status: ok) | Takes no action (status: ok) | Removes the package (status: changed) |
Let’s look at an implementation example showing the danger of using latest and how to handle it:
# ANTI-PATTERN: Using latest for package installation on production servers
- name: Install the latest PostgreSQL version
apt:
name: postgresql
state: latest # CAN BREAK DATA IF THE MAJOR VERSION SUDDENLY JUMPS!
# CORRECT: Using present with a specific version to lock our system stability
- name: Ensure PostgreSQL 15 is stably installed
apt:
name: postgresql-15
state: present
Modular Package Management: Python pip, Node npm, and OS Packages #
Besides operating system packages, as a developer you often have to manage packages specific to certain programming languages, like Python packages using pip or Node.js modules using npm.
One common mistake to avoid is installing programming language packages globally to the operating system with root privileges (for example sudo pip install package). This practice can break the OS’s internal python dependencies and cause Linux built-in system tools to malfunction.
The solution is isolating application dependencies into a virtual environment for Python, or a local directory for Node.js. Ansible provides dedicated modules like ansible.builtin.pip to handle this in an isolated way.
- name: Manage Python application dependencies in a virtualenv
block:
# Installing the system packages needed to build python packages
- name: Install system requirements
apt:
name:
- python3-pip
- python3-venv
- build-essential
- libpq-dev
state: present
# Creating a virtualenv and installing python libraries inside it safely
- name: Install Python libraries into the application virtualenv
pip:
name:
- flask
- psycopg2-binary
- gunicorn
virtualenv: /opt/myapp/venv
virtualenv_python: python3
state: present
# Or installing in bulk using a requirements.txt file
- name: Install dependencies from requirements.txt
pip:
requirements: /opt/myapp/requirements.txt
virtualenv: /opt/myapp/venv
state: present
By isolating external libraries into /opt/myapp/venv, your operating system files stay clean and you’re free to manage Python module versions without fear of breaking other Linux system components.
Case Study: Node.js & Docker Multi-Distribution Deployment #
To summarize your entire understanding of package management, let’s create a playbook that configures repositories and installs the Node.js runtime plus Docker CE on a heterogeneous server cluster (a mix of Debian/Ubuntu and RedHat/CentOS).
This playbook uses system fact detection (ansible_os_family) to determine the appropriate installation logic and repositories:
# playbooks/deploy-package-infra.yml
---
- name: Deploy Multi-Distribution Repositories and Packages
hosts: all
become: true
vars:
nodejs_version: "18"
common_utilities:
- curl
- git
- vim
- htop
tasks:
# 1. Basic utility installation using the generic module (safe for multi-OS)
- name: Install basic utilities on all hosts
package:
name: "{{ item }}"
state: present
loop: "{{ common_utilities }}"
# 2. Special block for the Debian/Ubuntu family
- name: Configure Repositories and Packages for Debian/Ubuntu
block:
- name: Add the Docker repository GPG Key (Debian/Ubuntu)
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add the Docker CE repository (Debian/Ubuntu)
apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
filename: docker-ce
update_cache: true
- name: Install Docker and Node.js on Debian/Ubuntu
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- "nodejs"
state: present
when: ansible_os_family == "Debian"
# 3. Special block for the RedHat/CentOS family
- name: Configure Repositories and Packages for RedHat/CentOS
block:
- name: Add the Docker CE repository (RedHat/CentOS)
yum_repository:
name: docker-ce-stable
description: Docker CE Stable - $basearch
baseurl: https://download.docker.com/linux/centos/7/$basearch/stable
gpgcheck: true
gpgkey: https://download.docker.com/linux/centos/gpg
state: present
- name: Enable the Node.js modular stream (RedHat/CentOS)
dnf:
name: "@nodejs:{{ nodejs_version }}/common"
state: present
- name: Install Docker on RedHat/CentOS
dnf:
name:
- docker-ce
- docker-ce-cli
- containerd.io
state: present
when: ansible_os_family == "RedHat"
The playbook above shows how to write a single very robust automation flow. Ansible evaluates each task block dynamically. If run on an Ubuntu server, only the Debian block executes, and if run on CentOS, only the RedHat block executes, while the basic utility installation runs smoothly on both operating systems.
Summary #
- Generic vs Native Modules — Use the
packagemodule for simple cross-OS utilities, and use native modules (apt,dnf,yum) for Linux distribution-specific features.- APT Cache Optimization — Always combine
update_cache: truewithcache_valid_timeon theaptmodule to avoid repeated, slow cache update executions.- Avoid the Latest State — Use
state: presentcombined with a specific version number on production servers to prevent damage from untested package version changes.- Efficient Package Installation — Send a package list directly to the
nameparameter instead of using aloopto reduce execution overhead.- Modular Stream Management — Leverage the
dnfmodule’s ability to enable Application Streams (@module:version) on modern RHEL systems.- Declarative Repositories — Manage external repository additions using
apt_repositoryoryum_repositoryto avoid breaking sources.list file configurations.- Programming Language Library Isolation — Use the
pipmodule combined withvirtualenvto isolate application python dependencies from operating system libraries.- Perfect Cleanup — Use the combination of
state: absentwith thepurge: trueparameter on Debian/Ubuntu systems to remove leftover global configuration files.