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.ymlansible.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=truerequirements.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.ymlInventory: hosts.yml#
---
all:
vars:
ansible_python_interpreter: /usr/bin/python3
children:
podmen:
hosts:
pi-hole-primary:
ansible_host: pi-hole-primary.fritz.boxGroup Vars: podmen.yml#
---
podman_user_name: podman
podman_user_group: podmanGroup 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_passCreating 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:
- podmanRunning the Playbook#
ansible-playbook play.ymlThe playbook:
- Sets up swap (optional, but recommended on 512 MB RAM)
- Protects critical services (SSH, journald, dbus) from CPU starvation
- Configures kernel parameters for the cgroup v2 memory controller
- Installs Podman and sets up a dedicated
podmanuser - Enables systemd delegation for rootless containers
- Reboots the Raspberry Pi (if kernel parameters changed)
What the head1328.podman Role Does#
The role automates these steps:
| Step | Description |
|---|---|
| Install Podman | apt install podman |
| Create user | System user podman with group podman |
| Access rights | Podman binary executable only for the podman group |
| subuid/subgid | Configure user namespaces for rootless containers |
| Lingering | loginctl enable-linger for user services without login |
| Delegation | systemd drop-in for cgroup controllers: cpu cpuset memory io pids |
| Socket | Enable 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 pidsThis 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: v2Controllers available for the user?#
cat /sys/fs/cgroup/user.slice/user-$(id -u podman).slice/cgroup.controllers
# Must contain: cpuset cpu io memory pidsDelegation active?#
systemctl show user@$(id -u podman) -p Delegate
# Expected: Delegate=yesTesting 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'| Option | Description |
|---|---|
--cpus=0.25 | At most 25% of one CPU core |
--pids-limit=50 | At most 50 processes/threads |
--memory=100m | At most 100 MB RAM |
--memory-swap=100m | No additional swap |
--security-opt no-new-privileges | Prevents privilege escalation |
nice -n 10 | Lower 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
podmanuser 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#
- Raspberry Pi - hardware and foundation
- Raspberry Pi OS - Debian-based operating system
- Podman - daemonless container engine
- Ansible - automation and configuration management
Own Roles and Playbooks#
Found this helpful?
Consider supporting via:
