Skip to main content

Podman on Raspberry Pi with Ansible

Table of Contents
Raspberry Pi Homelab - This article is part of a series.
Part 1: This Article

The Raspberry Pi Zero 2 W, with its 512 MB of RAM and quad-core CPU, is a frugal yet capable candidate for small container workloads in the homelab. This article shows how you set up rootless Podman with working CPU and memory limits fully automated via Ansible.

We use the Ansible role head1328.podman , which sets up rootless Podman for a dedicated user (e.g. podman). The option podman_restrict_user=true ensures that only this user is allowed to run Podman. Debian 13 (Trixie) ships with Podman 5, which provides native Quadlet support for systemd integration. We’ll cover that in the next article of the series.

Requirements
#

The Raspberry Pi Zero 2 W already runs Raspberry Pi OS (Debian 13 / Trixie), installed via Raspberry Pi Imager. During imaging, an SSH key was deployed, the hostname was set to pi-hole-primary, and a user ansible with sudo rights was created. The Pi is connected to the LAN via USB Ethernet adapter and reachable as pi-hole-primary.fritz.box. There are plenty of tutorials covering Raspberry Pi setup; we’ll skip that part here.

You’ll need Ansible installed locally, or alternatively the Ansible Dev Container ghcr.io/ansible/community-ansible-dev-tools:latest.

This tutorial assumes macOS as the workstation and a FritzBox as the router. Other operating systems or routers should work largely the same.

Setting up the Ansible Project
#

The complete project is also available here:

This tutorial is based on Version 1.0.0 .

Project Structure
#

ansible/
├── ansible.cfg
├── requirements.yml
├── play.yml
└── inventories/
    └── homelab/
        ├── hosts.yml
        └── group_vars/
            ├── all.yml
            └── podmen.yml

ansible.cfg
#

[defaults]
inventory = ./inventories/homelab/hosts.yml
ask_vault_pass = true
remote_user = ansible
private_key_file = ~/.ssh/id_ed25519

# allow becoming non privileged user
allow_world_readable_tmpfiles=true

requirements.yml
#

---
collections:
  - name: devsec.hardening
    version: ">=10.4.0,<10.5"
    type: galaxy
  - name: containers.podman
    version: ">=1.18.0,<1.19"
    type: galaxy

roles:
  - name: head1328.podman
    version: "v0.1.0"
  - name: escalate.swap
    version: "v2.1.0"

Install dependencies (roles and collections):

ansible-galaxy install -r requirements.yml

Inventory: hosts.yml
#

---
all:
  vars:
    ansible_python_interpreter: /usr/bin/python3
  children:
    podmen:
      hosts:
        pi-hole-primary:
          ansible_host: pi-hole-primary.fritz.box

Group Vars: podmen.yml
#

---
podman_user_name: podman
podman_user_group: podman

Group Vars: all.yml
#

---
ansible_become_pass: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  ...

Encrypt the sudo password:

# interactive mode (recommended)
ansible-vault encrypt_string

# alternative
ansible-vault encrypt_string 'your-sudo-password' --name ansible_become_pass

Creating the Playbook
#

play.yml
#

---
- name: Podman Setup
  hosts: podmen
  handlers:
    - name: Reload systemd
      ansible.builtin.systemd:
        daemon_reload: true
      become: true

    - name: Reboot Raspberry Pi
      become: true
      ansible.builtin.reboot:
        msg: "Reboot required"
        connect_timeout: 5
        reboot_timeout: 600
        pre_reboot_delay: 2
        post_reboot_delay: 10
        test_command: whoami

  pre_tasks:
    - name: Setup swap (optional for 512 MB RAM)
      become: true
      tags:
        - swap
      block:
        - name: Include escalate.swap role
          ansible.builtin.include_role:
            name: escalate.swap
          vars:
            swap_enabled: true
            swap_config:
              CONF_SWAPSIZE: 256

    - name: Protect critical system services from CPU starvation
      become: true
      block:
        - name: Ensure systemd drop-in directories for critical services
          ansible.builtin.file:
            path: "/etc/systemd/system/{{ item }}.service.d"
            state: directory
            owner: root
            group: root
            mode: '0755'
          loop:
            - ssh
            - systemd-journald
            - dbus

        - name: Set high CPUWeight for critical services
          ansible.builtin.copy:
            dest: "/etc/systemd/system/{{ item }}.service.d/cpu-weight.conf"
            owner: root
            group: root
            mode: '0644'
            content: |
              [Service]
              CPUWeight=1000
          loop:
            - ssh
            - systemd-journald
            - dbus
          notify: Reload systemd

    - name: Ensure kernel cmdline has required cgroup flags
      become: true
      tags:
        - podman
      block:
        - name: Read kernel cmdline
          ansible.builtin.slurp:
            path: /boot/firmware/cmdline.txt
          register: cmdline_raw

        - name: Build normalized kernel cmdline
          ansible.builtin.set_fact:
            cmdline_new: >-
              {{
                (cmdline_raw.content | b64decode | trim)
                | regex_replace('\s*cgroup_enable=memory\s*', ' ')
                | regex_replace('\s*cgroup_memory=1\s*', ' ')
                | regex_replace('\s*swapaccount=1\s*', ' ')
                | regex_replace('\s+', ' ')
                | trim
              }}
              cgroup_enable=memory cgroup_memory=1 swapaccount=1

        - name: Write kernel cmdline (exactly one line)
          ansible.builtin.copy:
            dest: /boot/firmware/cmdline.txt
            content: "{{ cmdline_new }}"
            owner: root
            group: root
            mode: '0644'
          notify: Reboot Raspberry Pi

  roles:
    - role: head1328.podman
      become: true
      tags:
        - podman

Running the Playbook
#

ansible-playbook play.yml

The playbook:

  1. Sets up swap (optional, but recommended on 512 MB RAM)
  2. Protects critical services (SSH, journald, dbus) from CPU starvation
  3. Configures kernel parameters for the cgroup v2 memory controller
  4. Installs Podman and sets up a dedicated podman user
  5. Enables systemd delegation for rootless containers
  6. Reboots the Raspberry Pi (if kernel parameters changed)

What the head1328.podman Role Does
#

The role automates these steps:

StepDescription
Install Podmanapt install podman
Create userSystem user podman with group podman
Access rightsPodman binary executable only for the podman group
subuid/subgidConfigure user namespaces for rootless containers
Lingeringloginctl enable-linger for user services without login
Delegationsystemd drop-in for cgroup controllers: cpu cpuset memory io pids
SocketEnable and start the Podman user socket

The delegate.conf
#

The role creates /etc/systemd/system/user@.service.d/delegate.conf:

[Service]
Delegate=yes
Controllers=cpu cpuset memory io pids

This configuration is essential for resource limits to work in rootless containers.

The setup is now complete and Podman is running. Next, we verify the installation.

Verifying the Installation
#

After the playbook run, connect to the Pi via SSH and check:

cgroup v2 active?
#

stat -fc %T /sys/fs/cgroup
# Expected: cgroup2fs

sudo -u podman bash -c 'cd "$HOME" && podman info --format "{{ .Host.CgroupsVersion }}"'
# Expected: v2

Controllers available for the user?
#

cat /sys/fs/cgroup/user.slice/user-$(id -u podman).slice/cgroup.controllers
# Must contain: cpuset cpu io memory pids

Delegation active?
#

systemctl show user@$(id -u podman) -p Delegate
# Expected: Delegate=yes

Testing CPU Limits
#

You can verify CPU limits using stress-ng:

sudo -u podman bash -c 'cd "$HOME" && podman run --rm \
  --name=stressng \
  --cpus=0.5 \
  alpine \
  sh -c "apk add --no-cache stress-ng && stress-ng --cpu 1 --timeout 30"'

Watch from a second terminal:

sudo -u podman bash -c 'cd "$HOME" && podman stats'

Expected: max ~50% of one CPU core.

Verification at the cgroup level (CPU)
#

CG=$(sudo -u podman bash -c 'cd "$HOME" && podman inspect -f "{{ .State.CgroupPath }}" stressng')
cat "/sys/fs/cgroup${CG}/cpu.max"
# Example: 50000 100000 (50ms of 100ms = 50%)

Testing Memory Limits
#

Under cgroups v2, memory.max is not a hard kill threshold. The kernel prefers reclaim and throttling. OOM kill is the last escalation, when nothing else works. That makes it hard to provoke an OOM kill for demo purposes.

Instead, you can verify the memory limit through the cgroup files and podman stats:

# Terminal 1: start container with memory limit
sudo -u podman bash -c 'cd "$HOME" && podman run --rm \
  --name=stressng \
  --memory=64m \
  --memory-swap=64m \
  alpine \
  sh -c "apk add --no-cache stress-ng && stress-ng --vm 1 --vm-bytes 200M --timeout 30"'
# Terminal 2: watch memory usage
sudo -u podman bash -c 'cd "$HOME" && podman stats stressng'

Expected: MEM USAGE stays capped at ~64 MB even though stress-ng requests 200 MB.

Verification at the cgroup level (Memory)
#

CG=$(sudo -u podman bash -c 'cd "$HOME" && podman inspect -f "{{ .State.CgroupPath }}" stressng')
cat "/sys/fs/cgroup${CG}/memory.max"
# Expected: 67108864 (64 MB in bytes)

cat "/sys/fs/cgroup${CG}/memory.current"
# Shows current usage (close to the limit)

Best Practices for Small Systems
#

Recommended Container Limits#

sudo -u podman bash -c 'cd "$HOME" && podman run \
  --cpus=0.25 \
  --pids-limit=50 \
  --memory=100m \
  --memory-swap=100m \
  --security-opt no-new-privileges \
  alpine \
  nice -n 10 your-command'
OptionDescription
--cpus=0.25At most 25% of one CPU core
--pids-limit=50At most 50 processes/threads
--memory=100mAt most 100 MB RAM
--memory-swap=100mNo additional swap
--security-opt no-new-privilegesPrevents privilege escalation
nice -n 10Lower process priority

Rules of Thumb
#

  • CPUWeight protects services
  • nice protects the system
  • pids-limit protects the scheduler
  • no-new-privileges prevents accidental privilege escalation

Conclusion
#

With the head1328.podman Ansible role and the additional pre-tasks, the Raspberry Pi Zero 2 W is ready for rootless containers with working resource limits:

  • cgroup v2 with all controllers enabled
  • Kernel parameters set for memory accounting
  • systemd delegation configured for user containers
  • Critical system services protected from CPU starvation
  • Dedicated podman user with proper permissions

A container that doesn’t terminate despite a memory limit isn’t a bug; Linux is managing memory correctly and swapping out as needed.

In the next article, we’ll install Pi-hole as a container on the prepared Raspberry Pi.

Acknowledgments
#

Own Roles and Playbooks
#


Found this helpful?
Consider supporting via:

Raspberry Pi Homelab - This article is part of a series.
Part 1: This Article