OS IaC: Pyinfra

A Python-based IaC

Posted by Isaac on Thursday, November 5, 2026

Recently I gave an Infrastructure as Code (IaC) talk on Open-Source tooling and focused on Ansible and OpenTofu. One of the participants asked me about Pyinfra which I had never heard of before.

Pyinfra is fully MIT licensed and seems rather full featured.

Today we will get it setup locally, create some basic “deploys” (their word for playbooks), then look at remote deploys before showing how we can create scheduled YAML based pipelines to launch and manage infrastructure with Pyinfra.

But first, let’s get it installed locally…

Getting Started

As the name suggests, it is Python based so the first thing we need to do is install with uv

(base) builder@LuiGi:~/Workspaces/ansible-playbooks$ source .venv/bin/activate
(.venv) (base) builder@LuiGi:~/Workspaces/ansible-playbooks$ sudo snap install astral-uv --classic
astral-uv 0.12.21 from Alex Lowe (lengau✪) installed
$ uv tool install pyinfra
Resolved 26 packages in 1.52s
Prepared 24 packages in 1.83s                                                                     Installed 26 packages in 22ms                                                                      + annotated-types==0.8.0                                                                          + bcrypt==5.0.0                                                                                   + cffi==2.1.1                                                                                     + click==8.5.0                                                                                    + cryptography==50.0.2                                                                            + distro==1.9.0                                                                                   + gevent==26.9.0                                                                                  + greenlet==3.5.6                                                                                 + invoke==3.0.3                                                                                   + jinja2==3.1.6                                                                                   + markupsafe==3.0.4                                                                               + packaging==26.3                                                                                 + paramiko==4.0.0                                                                                 + pycparser==3.12                                                                                 + pydantic==2.14.2                                                                                + pydantic-core==2.50.2                                                                           + pyinfra==3.10.0                                                                                 + pynacl==1.6.2                                                                                   + python-dateutil==2.9.0.post0
 + six==1.17.0
 + typeguard==4.6.0
 + types-paramiko==4.0.0.20260518
 + typing-extensions==4.16.0
 + typing-inspection==0.4.4
 + zope-event==6.2
 + zope-interface==8.7
Installed 1 executable: pyinfra

Let’s do a simple test for one box in our network (192.168.1.99) with SSH and a docker host. As an example package, we’ll install iputils-ping.

The inventory.py looks like this:

$ cat ./inventory.py
# Define a group as a list of hosts
my_hosts = [
    (
        "ubuntu2204",
        {
            "ssh_port": 22,
            "ssh_hostname": "192.168.1.99",
            "ssh_user": "builder",
            "ssh_password": "SSH-PASSWORD-HERE",
            "_sudo": True,
        },
    ),
    "@docker/ubuntu:22.04",
]

If you already have passwordless ssh (e.g. the user in the authorized hosts) then you can omit “ssh_password” in the above.

And the deploy.py is:

$ cat ./deploy.py
from pyinfra.operations import apt, server

apt.packages(
    name="Ensure the iptuils ping package is installed",
    packages=["iputils-ping"],
    update=True,
)

Now we can run it

$ pyinfra inventory.py deploy.py
    Attempting to work in a virtualenv.
    If you encounter problems, please install pyinfra inside the virtualenv.


--> Loading config...
--> Loading inventory...
--> Connecting to hosts...
    [ubuntu2204]           Connected
    [@docker/ubuntu:22.04] Connected

--> Preparing operation files...
    Loading: deploy.py
    [@docker/ubuntu:22.04] Ready: deploy.py
    [ubuntu2204]           Ready: deploy.py

--> Detected changes:
    Operation                                      Change                                 Conditional Change
    Ensure the iptuils ping package is installed   2 (@docker/ubuntu:22.04, ubuntu2204)   -

    Detected changes may not include every change pyinfra will execute.
    Hidden side effects of operations may alter behaviour of future operations,
    this will be shown in the results. The remote state will always be updated
    to reflect the state defined by the input operations.

    Detected changes displayed above, skip this step with -y

--> Beginning operation run...
--> Starting operation: Ensure the iptuils ping package is installed
    [@docker/ubuntu:22.04] Success
    [ubuntu2204]           Get:1 https://download.docker.com/linux/ubuntu focal InRelease [57.7 kB]
    [ubuntu2204]           Hit:2 https://packages.microsoft.com/repos/azure-cli focal InRelease
    [ubuntu2204]           Ign:3 https://apt.datadoghq.com stable InRelease
    [ubuntu2204]           Hit:4 https://apt.datadoghq.com stable Release
    [ubuntu2204]           Hit:6 http://security.ubuntu.com/ubuntu jammy-security InRelease
    [ubuntu2204]           Hit:7 https://packages.cloud.google.com/apt gcsfuse-jammy InRelease
    [ubuntu2204]           Ign:8 https://apt.kubernetes.io kubernetes-xenial InRelease
    [ubuntu2204]           Err:9 https://apt.kubernetes.io kubernetes-xenial Release
    [ubuntu2204]             404  Not Found [IP: 34.107.204.206 443]
    [ubuntu2204]           Hit:10 http://us.archive.ubuntu.com/ubuntu jammy InRelease
    [ubuntu2204]           Hit:11 http://us.archive.ubuntu.com/ubuntu jammy-updates InRelease
    [ubuntu2204]           Hit:12 http://us.archive.ubuntu.com/ubuntu jammy-backports InRelease
    [ubuntu2204]           Reading package lists...
    [ubuntu2204]           W: https://packages.cloud.google.com/apt/dists/gcsfuse-jammy/InRelease: Key is stored in legacy trusted.gpg keyring (/etc/apt/trusted.gpg), see the DEPRECATION section in apt-key(8) for details.
    [ubuntu2204]           E: The repository 'https://apt.kubernetes.io kubernetes-xenial Release' no longer has a Release file.
    [ubuntu2204]           Error: executed 0 commands

    One of more hosts failed, continue?

--> Results:
    Operation                                      Hosts   Success   Error   No Change
    Ensure the iptuils ping package is installed   2       1         1       -
    Grand total                                    2       1         1       -

--> Disconnecting from hosts...
    [@docker/ubuntu:22.04] docker build complete, image ID: 1013178a232a

One of the issues is the old deprecated Apt packages (the old xenial kubernetes one).

Let’s remove that in the deploy playbook

$ cat ./deploy.py
from pyinfra.operations import apt, files, server

files.file(
    name="Remove deprecated kubernetes.io apt list",
    path="/etc/apt/sources.list.d/kubernetes.list",
    present=False,
)

apt.repo(
    name="Ensure deprecated kubernetes-xenial repo is removed",
    src="deb https://apt.kubernetes.io/ kubernetes-xenial main",
    present=False,
)

apt.packages(
    name="Ensure the iptuils ping package is installed",
    packages=["iputils-ping"],
    update=True,
)

And try again

$ pyinfra inventory.py deploy.py
    Attempting to work in a virtualenv.
    If you encounter problems, please install pyinfra inside the virtualenv.


--> Loading config...
--> Loading inventory...
--> Connecting to hosts...
    [ubuntu2204]           Connected
    [@docker/ubuntu:22.04] Connected

--> Preparing operation files...
    Loading: deploy.py
    [@docker/ubuntu:22.04] Ready: deploy.py
    [ubuntu2204]           Ready: deploy.py

--> Detected changes:
    Operation                                             Change                                 Conditional Change
    Remove deprecated kubernetes.io apt list              1 (ubuntu2204)                         -
    Ensure deprecated kubernetes-xenial repo is removed   -                                      -
    Ensure the iptuils ping package is installed          2 (@docker/ubuntu:22.04, ubuntu2204)   -

    Detected changes may not include every change pyinfra will execute.
    Hidden side effects of operations may alter behaviour of future operations,
    this will be shown in the results. The remote state will always be updated
    to reflect the state defined by the input operations.

    Detected changes displayed above, skip this step with -y

--> Beginning operation run...
--> Starting operation: Remove deprecated kubernetes.io apt list
    [@docker/ubuntu:22.04] No changes
    [ubuntu2204]           Success

--> Starting operation: Ensure deprecated kubernetes-xenial repo is removed
    [ubuntu2204]           No changes
    [@docker/ubuntu:22.04] No changes

--> Starting operation: Ensure the iptuils ping package is installed
    [@docker/ubuntu:22.04] Success
    [ubuntu2204]           Success

--> Results:
    Operation                                             Hosts   Success   Error   No Change
    Remove deprecated kubernetes.io apt list              2       1         -       1
    Ensure deprecated kubernetes-xenial repo is removed   2       -         -       2
    Ensure the iptuils ping package is installed          2       2         -       -
    Grand total                                           6       3         -       3

--> Disconnecting from hosts...
    [@docker/ubuntu:22.04] docker build complete, image ID: b62f67fb49c6

I did a quick test on the target host to ensure it installed

$ ssh builder@192.168.1.99
builder@192.168.1.99's password:
Welcome to Ubuntu 22.04.2 LTS (GNU/Linux 6.8.0-138-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

Expanded Security Maintenance for Applications is not enabled.

177 updates can be applied immediately.
4 of these updates are standard security updates.
To see these additional updates run: apt list --upgradable

27 additional security updates can be applied with ESM Apps.
Learn more about enabling ESM Apps service at https://ubuntu.com/esm

New release '24.04.5 LTS' available.
Run 'do-release-upgrade' to upgrade to it.


1 updates could not be installed automatically. For more details,
see /var/log/unattended-upgrades/unattended-upgrades.log
*** System restart required ***
Last login: Sat Oct 31 20:15:28 2026 from 192.168.1.108
builder@builder-T100:~$ ping harbor.freshbrewed.science
PING harbor.freshbrewed.science (76.156.69.232) 56(84) bytes of data.
64 bytes from c-76-156-69-232.hsd1.mn.comcast.net (76.156.69.232): icmp_seq=1 ttl=64 time=0.623 ms
64 bytes from c-76-156-69-232.hsd1.mn.comcast.net (76.156.69.232): icmp_seq=2 ttl=64 time=0.695 ms

I want to create a new type of playbook that can hop into a host, clone my aiskills repo and ensure it’s updated

$ cat deployaiskills.py
from pyinfra import host
from pyinfra.facts.server import Home
from pyinfra.operations import apt, files, git

apt.packages(
    name="Ensure git and ca-certificates are installed",
    packages=["git", "ca-certificates"],
    update=True,
)

home = host.get_fact(Home, _sudo=False)
workspaces_dir = f"{home}/Workspaces"
repo_dir = f"{workspaces_dir}/aiskills"

files.directory(
    name="Ensure ~/Workspaces directory exists",
    path=workspaces_dir,
    present=True,
    _sudo=False,
)

git.repo(
    name="Clone or update aiskills repo in ~/Workspaces/aiskills",
    src="https://github.com/idjohnson/aiskills.git",
    dest=repo_dir,
    branch="main",
    pull=True,
    _sudo=False,
)

/img/2026-11-pyinfra-01.png

I then checked that host (which i know did not have the AISkills repo) and saw it listed

builder@builder-T100:~$ cd Workspaces/
builder@builder-T100:~/Workspaces$ cd aiskills/
builder@builder-T100:~/Workspaces/aiskills$ ls
INSTALL.md  install.sh  LICENSE  README.md  skills

We can convert an Ansible playbook pretty easily

The BTop ansible playbook

$ cat btop.yaml
---
- name: Install BTop
  hosts: all

  tasks:
  - name: Update apt
    ansible.builtin.shell: |
      apt-get update --allow-releaseinfo-change
    become: true
    args:
      chdir: /tmp

  - name: Install Dependencies
    ansible.builtin.shell: |
      apt-get install -y coreutils sed git build-essential gcc-10 g++-10
    become: true
    args:
      chdir: /tmp

  - name: Download btop
    ansible.builtin.shell: |
      # Clean if needed
      rm -rf /tmp/btop || true
      rmdir /tmp/btop || true

      # Clone
      git clone --recursive https://github.com/aristocratos/btop.git /tmp/btop
    become: true
    args:
      chdir: /tmp

  - name: Make
    ansible.builtin.shell: |
      make QUIET=true CXX=g++-10
    become: true
    args:
      chdir: /tmp/btop

  - name: Make
    ansible.builtin.shell: |
      umask 0002
      make QUIET=true CXX=g++-10
    become: true
    args:
      chdir: /tmp/btop

  - name: Make Install
    ansible.builtin.shell: |
      umask 0002
      make install CXX=g++-10
    become: true
    args:
      chdir: /tmp/btop

  - name: Test
    ansible.builtin.shell: |
      btop --version
    become: true
    args:
      chdir: /tmp/btop

Converted to pyinfra

$ cat btop.py
from pyinfra.operations import apt, files, git, server

# Update apt (--allow-releaseinfo-change)
server.shell(
    name="Update apt",
    commands=["apt-get update --allow-releaseinfo-change"],
    _sudo=True,
    _chdir="/tmp",
)

# Install dependencies using pyinfra's native apt.packages operation
apt.packages(
    name="Install Dependencies",
    packages=[
        "coreutils",
        "sed",
        "git",
        "build-essential",
        "gcc-10",
        "g++-10",
    ],
    _sudo=True,
)

# Clean existing /tmp/btop directory if present
files.directory(
    name="Clean /tmp/btop if needed",
    path="/tmp/btop",
    present=False,
    _sudo=True,
)

# Clone btop repository with submodules
git.repo(
    name="Download btop",
    src="https://github.com/aristocratos/btop.git",
    dest="/tmp/btop",
    update_submodules=True,
    recursive_submodules=True,
    _sudo=True,
)

# Build and install btop
server.shell(
    name="Make and Install btop",
    commands=[
        "umask 0002 && make QUIET=true CXX=g++-10",
        "umask 0002 && make install CXX=g++-10",
    ],
    _sudo=True,
    _chdir="/tmp/btop",
)

# Verify installation
server.shell(
    name="Test btop",
    commands=["btop --version"],
    _sudo=True,
    _chdir="/tmp/btop",
)

However it had issues

(.venv) (base) builder@LuiGi:~/Workspaces/ansible-playbooks$ sudo -v
[sudo: authenticate] Password:
(.venv) (base) builder@LuiGi:~/Workspaces/ansible-playbooks$ pyinfra --same-sudo-password inventory_self.py btop.py
    Attempting to work in a virtualenv.
    If you encounter problems, please install pyinfra inside the virtualenv.


--> Loading config...
sudo password:
--> Loading inventory...
--> Connecting to hosts...
    [@local] Connected

--> Preparing operation files...
    Loading: btop.py
    [@local] Ready: btop.py

--> Detected changes:
    Operation                   Change       Conditional Change
    Update apt                  1 (@local)   -
    Install Dependencies        1 (@local)   -
    Clean /tmp/btop if needed   -            -
    Download btop               1 (@local)   -
    Make and Install btop       1 (@local)   -
    Test btop                   1 (@local)   -

    Detected changes may not include every change pyinfra will execute.
    Hidden side effects of operations may alter behaviour of future operations,
    this will be shown in the results. The remote state will always be updated
    to reflect the state defined by the input operations.

    Detected changes displayed above, skip this step with -y

--> Beginning operation run...
--> Starting operation: Update apt
    [@local] Success

--> Starting operation: Install Dependencies
    [@local] Reading package lists...
    [@local] Building dependency tree...
    [@local] Reading state information...
    [@local] Package g++-10 is not available, but is referred to by another package.
    [@local] This may mean that the package is missing, has been obsoleted, or
    [@local] is only available from another source
    [@local]
    [@local] E: Unable to locate package gcc-10
    [@local] E: Package 'g++-10' has no installation candidate
    [@local] Error: executed 0 commands

--> Disconnecting from hosts...
--> pyinfra error: No hosts remaining!

In Ubuntu 26, which is on this laptop, g++/gcc"-10" should just be g++/gcc

$ cat ./btop.py
from pyinfra.operations import apt, files, git, server

# Update apt (--allow-releaseinfo-change)
server.shell(
    name="Update apt",
    commands=["apt-get update --allow-releaseinfo-change"],
    _sudo=True,
    _chdir="/tmp",
)

# Install dependencies using pyinfra's native apt.packages operation
apt.packages(
    name="Install Dependencies",
    packages=[
        "coreutils",
        "sed",
        "git",
        "build-essential",
        "gcc",
        "g++",
    ],
    _sudo=True,
)

# Clean existing /tmp/btop directory if present
files.directory(
    name="Clean /tmp/btop if needed",
    path="/tmp/btop",
    present=False,
    _sudo=True,
)

# Clone btop repository with submodules
git.repo(
    name="Download btop",
    src="https://github.com/aristocratos/btop.git",
    dest="/tmp/btop",
    update_submodules=True,
    recursive_submodules=True,
    _sudo=True,
)

# Build and install btop
server.shell(
    name="Make and Install btop",
    commands=[
        "umask 0002 && make QUIET=true CXX=g++",
        "umask 0002 && make install CXX=g++",
    ],
    _sudo=True,
    _chdir="/tmp/btop",
)

# Verify installation
server.shell(
    name="Test btop",
    commands=["btop --version"],
    _sudo=True,
    _chdir="/tmp/btop",
)

Here we can see it invoked


(.venv) (base) builder@LuiGi:~/Workspaces/ansible-playbooks$ pyinfra --same-sudo-password inventory_self.py btop.py
    Attempting to work in a virtualenv.
    If you encounter problems, please install pyinfra inside the virtualenv.


--> Loading config...
sudo password:
--> Loading inventory...
--> Connecting to hosts...
    [@local] Connected

--> Preparing operation files...
    Loading: btop.py
    [@local] Ready: btop.py

--> Detected changes:
    Operation                   Change       Conditional Change
    Update apt                  1 (@local)   -
    Install Dependencies        -            -
    Clean /tmp/btop if needed   1 (@local)   -
    Download btop               1 (@local)   -
    Make and Install btop       1 (@local)   -
    Test btop                   1 (@local)   -

    Detected changes may not include every change pyinfra will execute.
    Hidden side effects of operations may alter behaviour of future operations,
    this will be shown in the results. The remote state will always be updated
    to reflect the state defined by the input operations.

    Detected changes displayed above, skip this step with -y

--> Beginning operation run...
--> Starting operation: Update apt
    [@local] Success

--> Starting operation: Install Dependencies
    [@local] No changes

--> Starting operation: Clean /tmp/btop if needed
    [@local] Success

--> Starting operation: Download btop
    [@local] Success

--> Starting operation: Make and Install btop
    [@local] Success

--> Starting operation: Test btop
    [@local] Success

--> Results:
    Operation                   Hosts   Success   Error   No Change
    Update apt                  1       1         -       -
    Install Dependencies        1       -         -       1
    Clean /tmp/btop if needed   1       1         -       -
    Download btop               1       1         -       -
    Make and Install btop       1       1         -       -
    Test btop                   1       1         -       -
    Grand total                 6       5         -       1

--> Disconnecting from hosts...

Here we can see it in action

Pipelines

I’ve shown examples of OpenTofu in Azure DevOps Pipelines and Forgejo.

Let’s look at making an pyinfra deploy for btop

I basically have a deploy.py, an example inventory and a bash script for dynamically making the inventory (will make sense in a moment).

(.venv) (base) builder@LuiGi:~/Workspaces/infraPipeline$ tree .
.
├── create_inventory.sh
├── deploy.py
└── inventory.py.example

1 directory, 3 files

The bash script:

$ cat create_inventory.sh
#!/usr/bin/env bash
set -euo pipefail

HOSTS=""
SSH_USER=""
SSH_PASS=""
SSH_PORT=22
OUTPUT_FILE="inventory.py"

usage() {
    cat <<EOF
Usage: $(basename "$0") --hosts <host1,host2,...> --sshuser <user> --sshpass <password> [--port <port>] [--output <file>]

Options:
  --hosts     Comma-separated list of host IPs or hostnames (e.g. 192.168.1.99,192.168.1.100)
  --sshuser   SSH username for the hosts
  --sshpass   SSH password for the hosts
  --port      SSH port (default: 22)
  --output    Output file path (default: inventory.py)
  -h, --help  Show this help message
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --hosts)
            HOSTS="${2:-}"
            shift 2
            ;;
        --hosts=*)
            HOSTS="${1#*=}"
            shift
            ;;
        --sshuser)
            SSH_USER="${2:-}"
            shift 2
            ;;
        --sshuser=*)
            SSH_USER="${1#*=}"
            shift
            ;;
        --sshpass)
            SSH_PASS="${2:-}"
            shift 2
            ;;
        --sshpass=*)
            SSH_PASS="${1#*=}"
            shift
            ;;
        --port)
            SSH_PORT="${2:-}"
            shift 2
            ;;
        --port=*)
            SSH_PORT="${1#*=}"
            shift
            ;;
        --output)
            OUTPUT_FILE="${2:-}"
            shift 2
            ;;
        --output=*)
            OUTPUT_FILE="${1#*=}"
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            echo "Error: Unknown parameter '$1'" >&2
            usage >&2
            exit 1
            ;;
    esac
done

if [[ -z "$HOSTS" || -z "$SSH_USER" || -z "$SSH_PASS" ]]; then
    echo "Error: --hosts, --sshuser, and --sshpass are required." >&2
    usage >&2
    exit 1
fi

IFS=',' read -r -a HOST_ARRAY <<< "$HOSTS"

escape_py_string() {
    local str="$1"
    str="${str//\\/\\\\}"
    str="${str//\"/\\\"}"
    printf '%s' "$str"
}

ESCAPED_USER="$(escape_py_string "$SSH_USER")"
ESCAPED_PASS="$(escape_py_string "$SSH_PASS")"

{
    echo "my_hosts = ["
    for host in "${HOST_ARRAY[@]}"; do
        # Trim whitespace
        host="${host#"${host%%[![:space:]]*}"}"
        host="${host%"${host##*[![:space:]]}"}"
        [[ -z "$host" ]] && continue

        # Strip dots/non-alphanumeric characters to form the host identifier (e.g. linuxhost192168199)
        host_id="linuxhost${host//[^a-zA-Z0-9]/}"
        escaped_host="$(escape_py_string "$host")"

        cat <<EOF
    (
        "${host_id}",
        {
            "ssh_port": ${SSH_PORT},
            "ssh_hostname": "${escaped_host}",
            "ssh_user": "${ESCAPED_USER}",
            "ssh_password": "${ESCAPED_PASS}",
            "ssh_known_hosts_file": "/dev/null",
            "ssh_strict_host_key_checking": "no",
            "_sudo": True,
        },
    ),
EOF
    done
    echo "]"
} > "$OUTPUT_FILE"

echo "Generated ${OUTPUT_FILE} with ${#HOST_ARRAY[@]} host(s)."

and the btop deploy file

$ cat deploy.py
from pyinfra.operations import apt, files, git, server

# Update apt (--allow-releaseinfo-change)
server.shell(
    name="Update apt",
    commands=["apt-get update --allow-releaseinfo-change"],
    _sudo=True,
    _chdir="/tmp",
)

# Install dependencies using pyinfra's native apt.packages operation
apt.packages(
    name="Install Dependencies",
    packages=[
        "coreutils",
        "sed",
        "git",
        "build-essential",
        "gcc",
        "g++",
    ],
    _sudo=True,
)

# Clean existing /tmp/btop directory if present
files.directory(
    name="Clean /tmp/btop if needed",
    path="/tmp/btop",
    present=False,
    _sudo=True,
)

# Clone btop repository with submodules
git.repo(
    name="Download btop",
    src="https://github.com/aristocratos/btop.git",
    dest="/tmp/btop",
    update_submodules=True,
    recursive_submodules=True,
    _sudo=True,
)

# Build and install btop
server.shell(
    name="Make and Install btop",
    commands=[
        "umask 0002 && make QUIET=true CXX=g++",
        "umask 0002 && make install CXX=g++",
    ],
    _sudo=True,
    _chdir="/tmp/btop",
)

# Verify installation
server.shell(
    name="Test btop",
    commands=["btop --version"],
    _sudo=True,
    _chdir="/tmp/btop",
)

I then have the Gitea CICD file:

(.venv) (base) builder@LuiGi:~/Workspaces/infraPipeline$ cat .gitea/workflows/cicd.yaml
name: CICD
run-name: ${{ gitea.actor }} CICD - ${{ gitea.ref_name }}
on:
  schedule:
    - cron: "0 5 * * *"
  push:
    branches:
      - "main"

jobs:
  CICD:
    runs-on: ubuntu-latest
    container: node:22
    steps:
      - uses: actions/checkout@v3 # Checks out your repository
      - name: Install UV
        shell: bash
        run: |
          whoami
          which hugo || true
          apt update
          cat /etc/os-release
          apt install -y ca-certificates curl gnupg unzip sudo groff tree jq
          apt install -y python3 python3-pip python3-venv
          python3 -m venv .venv
          source .venv/bin/activate
          pip install uv
          echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"

      # In staging, we want to see the 'future' posts
      - name: Test uv exists
        shell: bash
        run: |
          source .venv/bin/activate
          uv --version
          which uv
          ls -ltra

      - name: Install and configure
        shell: bash
        run: |
          which uv
          uv tool install pyinfra
          ls -ltra
          ./create_inventory.sh --hosts 192.168.1.99 --sshuser '${{ secrets.SSHUSER }}' \
                   --sshpass '${{ secrets.SSHPASS }}' --output ./our_inventory.py

      - name: Invokaton
        shell: bash
        run: |
          set -x
          pyinfra -y our_inventory.py deploy.py

Now I probably could optimize that by using a python base container instead of a generic node:22… I just know the node 22 works pretty well in my local docker host. I add python3 virtual env, then install uv needed to install pyinfra.

Once we have pyinfra installed, you see the script used to take some Gitea secrets (username and password) and add that to an inventory.py script we can then use to deploy btop.

It runs okay

/img/2026-11-pyinfra-03.png

The error is really about the older g++ on that docker host having issues with the makefile

/img/2026-11-pyinfra-04.png

I’ll use “-10” to make it work on older Ubuntu hosts

(.venv) (base) builder@LuiGi:~/Workspaces/infraPipeline$ git diff deploy.py
diff --git a/deploy.py b/deploy.py
index 5ab9f55..2a36c8e 100644
--- a/deploy.py
+++ b/deploy.py
@@ -16,8 +16,8 @@ apt.packages(
         "sed",
         "git",
         "build-essential",
-        "gcc",
-        "g++",
+        "gcc-10",
+        "g++-10",
     ],
     _sudo=True,
 )
@@ -44,8 +44,8 @@ git.repo(
 server.shell(
     name="Make and Install btop",
     commands=[
-        "umask 0002 && make QUIET=true CXX=g++",
-        "umask 0002 && make install CXX=g++",
+        "umask 0002 && make QUIET=true CXX=g++-10",
+        "umask 0002 && make install CXX=g++-10",
     ],
     _sudo=True,
     _chdir="/tmp/btop",
(.venv) (base) builder@LuiGi:~/Workspaces/infraPipeline$ git add deploy.py
(.venv) (base) builder@LuiGi:~/Workspaces/infraPipeline$ git commit -m "make deploy work with older Ubuntu hosts"
[main 0b9aec3] make deploy work with older Ubuntu hosts
 1 file changed, 4 insertions(+), 4 deletions(-)
(.venv) (base) builder@LuiGi:~/Workspaces/infraPipeline$ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 16 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 345 bytes | 345.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
To https://gitea2.freshbrewed.science/builder/infraPipeline.git
   a40d146..0b9aec3  main -> main

I had one last update for a new base gcc (and different host):

from pyinfra.operations import apt, files, git, server

# Update apt (--allow-releaseinfo-change)
server.shell(
    name="Update apt",
    commands=["apt-get update --allow-releaseinfo-change"],
    _sudo=True,
    _chdir="/tmp",
)

# Install software-properties-common for PPA management
apt.packages(
    name="Install PPA Prerequisites",
    packages=[
        "software-properties-common",
    ],
    _sudo=True,
)

# Add Ubuntu Toolchain PPA for GCC 14 (required for C++23 in btop v1.4.6+ on Ubuntu 22.04)
apt.ppa(
    name="Add Ubuntu Toolchain PPA",
    src="ppa:ubuntu-toolchain-r/test",
    _sudo=True,
)

# Install dependencies using pyinfra's native apt.packages operation
apt.packages(
    name="Install Dependencies",
    packages=[
        "coreutils",
        "sed",
        "git",
        "build-essential",
        "lowdown",
        "gcc-14",
        "g++-14",
    ],
    update=True,
    _sudo=True,
)

# Clean existing /tmp/btop directory if present
files.directory(
    name="Clean /tmp/btop if needed",
    path="/tmp/btop",
    present=False,
    _sudo=True,
)

# Clone btop repository with submodules
git.repo(
    name="Download btop",
    src="https://github.com/aristocratos/btop.git",
    dest="/tmp/btop",
    update_submodules=True,
    recursive_submodules=True,
    _sudo=True,
)

# Build and install btop
server.shell(
    name="Make and Install btop",
    commands=[
        "umask 0002 && make QUIET=true CC=gcc-14 CXX=g++-14",
        "umask 0002 && make install CC=gcc-14 CXX=g++-14",
    ],
    _sudo=True,
    _chdir="/tmp/btop",
)

# Verify installation
server.shell(
    name="Test btop",
    commands=["btop --version"],
    _sudo=True,
    _chdir="/tmp/btop",
)

Which worked

/img/2026-11-pyinfra-05.png

Summary

Today we looked at pyinfra and compared to Ansible with AWX. We did some new deploys and converted some playbooks to deploy files.

There were some more advanced things we could do like setting system limits

security_limit(
    name="Set nofile limit for all users",
    domain='*',
    limit_type='soft',
    item='nofile',
    value=1024,
)

or enabling a service

server.service(
    name="Enable open-vm-tools service",
    service="open-vm-tools",
    enabled=True,
)

Lastly, I wanted to show a pipeline option for which we used Gitea to invoke a btop pipeline on a list of Linux hosts remotely.

I would argue this is a good equivalent to Ansible playbooks. It is very simple and doesn’t require a Kubernetes AWX host to invoke.

As it is python based, there is a lot more things we could do.