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 --> J

Using 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:

Featureapt Module (Debian/Ubuntu)dnf Module (RHEL/CentOS/Fedora)
Configuration CleanupSupported via the purge: true optionNot directly supported
Dependency CleanupSupported via the autoremove: true optionSupported via the autoremove: true option
Package Cache UpdateSupported via the update_cache: true optionSupported via the update_cache: true option
Modular Package StreamsNot supported (uses PPA repositories)Natively supported via the @module syntax
Group InstallationUses special task namesNatively 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 (or installed): 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. Using state: latest in production environments is an anti-pattern because uncontrolled automatic updates can suddenly break your application compatibility without testing.
  • absent (or removed): 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 ConditionTarget State: presentTarget State: latestTarget State: absent
Package missingInstalls 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 package module for simple cross-OS utilities, and use native modules (apt, dnf, yum) for Linux distribution-specific features.
  • APT Cache Optimization — Always combine update_cache: true with cache_valid_time on the apt module to avoid repeated, slow cache update executions.
  • Avoid the Latest State — Use state: present combined 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 name parameter instead of using a loop to reduce execution overhead.
  • Modular Stream Management — Leverage the dnf module’s ability to enable Application Streams (@module:version) on modern RHEL systems.
  • Declarative Repositories — Manage external repository additions using apt_repository or yum_repository to avoid breaking sources.list file configurations.
  • Programming Language Library Isolation — Use the pip module combined with virtualenv to isolate application python dependencies from operating system libraries.
  • Perfect Cleanup — Use the combination of state: absent with the purge: true parameter on Debian/Ubuntu systems to remove leftover global configuration files.

← Previous: User & Permission Next: Service →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact