Manage Your Homelab with Ansible

Use Ansible to configure a Raspberry Pi as code — one command, full setup.

Introduction

30 Second Summary

When you manage a server by hand, every change is a one-off command you might forget to repeat. If that server dies or you add a second one, you start from scratch.

In this project, you will use Ansible to manage your Raspberry Pi 5 as code. You'll write YAML files that describe what your Pi should look like, then run a single command to make it so.

What You'll Build

A complete Ansible project that configures your Raspberry Pi 5 as a learning platform server, with your entire infrastructure described in code.

By the end of this project, you'll have:

  • An Ansible inventory file that describes your Pi as a manageable node in your Infrastructure as Code setup.
  • A playbook that sets your Pi's hostname, installs packages, creates a dedicated admin user, hardens SSH, and installs Docker.
  • First-hand experience with idempotency, where re-running your playbook changes nothing if the Pi is already in the desired state.
  • Secret Mission: Deploy a local AI agent on your Pi using Ollama and chat with it via the API.

Are there any prerequisites?

You need a Mac with Homebrew installed and a Raspberry Pi 5 (8GB) running Raspberry Pi OS (64-bit) with SSH enabled. Your Pi must be connected to the same network as your Mac.

If you don't have an SSH key yet, you'll generate one in Step 1.

Install Ansible and Verify Connectivity

To manage your Raspberry Pi as code, you need Ansible installed on your Mac and a way for Ansible to know where your Pi lives on the network.

Ansible connects to remote machines over SSH. Since your Pi already has SSH enabled, you just need to tell Ansible the address and credentials to use.

In this step, get ready to:
  • Install Ansible on your Mac using Homebrew.
  • Create a project directory with an inventory file describing your Pi.
  • Confirm connectivity with an ad-hoc ping command.
Install Ansible via Homebrew

Ansible is the industry-standard tool for Infrastructure as Code configuration management. It runs on your Mac and reaches out to your Pi over SSH to enforce the state you define.

  • Press Cmd+Space to open Spotlight search.
  • Type Terminal and press Enter to open it.
  • Install Ansible by running this command:
brew install ansible

What is this installing?

Homebrew downloads and installs the full Ansible package, which includes everything you need to write and run playbooks against remote machines.

Ansible's official docs recommend Homebrew as the macOS installation method. No virtual environments or extra Python setup required.

  • Verify the installation succeeded by running:
ansible --version

✔️ I see a version number

You should see output starting with ansible [core 2.x.x] followed by configuration paths and Python details. Ansible is ready to go.

ⓧ Command not found

If your terminal says the command is not found, try these fixes:

  • Close and reopen Terminal, then run ansible --version again. Homebrew sometimes needs a fresh shell to update your PATH.
  • Run brew list ansible to check if the install actually succeeded. If Ansible is listed, the PATH is the issue.
  • If the install failed, run brew update first, then brew install ansible again.
Create your project directory and inventory file

Ansible needs a project folder to hold your configuration files. The most important file is the inventory, which tells Ansible which machines to manage and how to connect to them.

  • Create your project directory and move into it by running these commands:
mkdir ~/homelab-ansible
cd ~/homelab-ansible
  • Confirm you're in the right folder by running:
pwd

You should see /Users/your-username/homelab-ansible in the output.

Now you need your Pi's IP address. This is how Ansible knows where to connect on your network.

Find your Pi's IP address

If you don't know your Pi's IP address, SSH into it from another terminal window with the hostname you normally use, then run hostname -I. The first address printed is your Pi's local IP.

  • Create the inventory file by running:
touch inventory.ini
  • Open inventory.ini in your preferred text editor and add the following content:
[homelab]
rpi5 ansible_host=[[PI_IP="your Pi's IP address"]] ansible_user=pi ansible_ssh_private_key_file=~/.ssh/id_ed25519

What does this inventory file do?

The inventory file is Ansible's address book. Each line defines a host that Ansible can manage.

  • [homelab] is a group name. You can target all hosts in this group with a single command.
  • rpi5 is the alias you're giving your Pi. You'll use this name in playbooks later.
  • ansible_host is the IP address Ansible connects to.
  • ansible_user and ansible_ssh_private_key_file tell Ansible which SSH credentials to use.

Using an RSA key instead of ed25519?

If your SSH key is RSA, change the path to ~/.ssh/id_rsa. The rest of the line stays the same.

  • Save inventory.ini.
  • Confirm the file exists by running:
ls

You should see inventory.ini in the output.

Don't see inventory.ini?

  • Make sure you're in the ~/homelab-ansible directory. Run pwd to check.
  • Check that you saved the file after editing. Some editors require an explicit save before the file appears on disk.

Why isn't my inventory file showing up?

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

[homelab]
rpi5 ansible_host=your-pi-ip-here ansible_user=pi ansible_ssh_private_key_file=~/.ssh/id_ed25519
Verify connectivity with an ad-hoc ping

Before writing any playbooks, you should confirm that Ansible can actually reach your Pi. Ansible has an ad-hoc command mode that lets you run a single module against your inventory without creating a file.

  • Test the connection to your Pi by running this command:
ansible all -i inventory.ini -m ping

What does this command do?

  • all targets every host in the inventory.
  • -i inventory.ini tells Ansible which inventory file to use.
  • -m ping runs Ansible's built-in ping module. This is not a network ping. It SSHs into the host, checks that Python is available, and returns pong if everything works.

You should see green output that looks like this:

rpi5 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

The SUCCESS status and "ping": "pong" confirm that Ansible connected to your Pi over SSH, found Python, and got a response back.

Seeing a connection error?

  • Check that the IP address in inventory.ini matches your Pi's actual IP. Run hostname -I on your Pi to double check.
  • Make sure you can SSH into your Pi manually first. Run ssh pi@your-pi-ip from your Mac. If that doesn't work, Ansible won't work either.
  • Verify the SSH key path in your inventory matches an actual key on your Mac. Run ls ~/.ssh/id_ed25519 to confirm the file exists.

Help me debug my Ansible ping connection error.

Your Ansible setup is working. You have a live connection from your Mac to your Pi, all defined in code. Next up, you'll write your first playbook to actually configure the Pi.

Write Your First Playbook

In the last step, you confirmed that Ansible can reach your Pi over SSH. Now it's time to put that connection to work by telling Ansible what to do on the Pi.

Ansible uses playbooks to describe the desired state of a machine. A playbook is a YAML file that lists tasks. When you run it, Ansible connects to your Pi and makes whatever changes are needed to reach that state.

In this step, get ready to:
  • Create a configuration file and your first playbook.
  • Run the playbook to configure your Pi's hostname and install packages.
  • Re-run the playbook to experience idempotency firsthand.
Create the Ansible config file

Before writing a playbook, you can simplify your workflow by setting project-wide defaults in an ansible.cfg file. This saves you from typing flags like -i inventory.ini on every command.

  • Create a file called ansible.cfg in your homelab-ansible folder by running this command:
touch ansible.cfg
  • Open ansible.cfg in your text editor and paste this configuration:
[defaults]
inventory = inventory.ini
remote_user = pi
host_key_checking = False
retry_files_enabled = False

What does this config do?

  • inventory = inventory.ini tells Ansible where to find your hosts, so you don't need -i inventory.ini every time.
  • remote_user = pi sets the default SSH user for connections.
  • host_key_checking = False prevents SSH from blocking connections when your Pi's fingerprint isn't cached yet.
  • retry_files_enabled = False stops Ansible from creating .retry files that clutter your project folder.
  • Save ansible.cfg.
  • Confirm Ansible picks up your config by running:
ansible all -m ping

This time you didn't need the -i inventory.ini flag. You should still see a green SUCCESS response from your Pi.

Ping failed without -i flag?

Make sure ansible.cfg is in the same folder you're running commands from (~/homelab-ansible). Ansible looks for a config file in the current directory first.

Also check that the filename is exactly ansible.cfg with no extra extension like .txt.

Help me troubleshoot why Ansible isn't finding my ansible.cfg config file

✔️ Awesome, I've got everything!

Great. Double check you've saved ansible.cfg before moving on.

ⓧ I'd like to double check the full code

[defaults]
inventory = inventory.ini
remote_user = pi
host_key_checking = False
retry_files_enabled = False
Create the site.yml playbook

A playbook is where you declare the desired state of your Pi. You'll create one that sets the hostname and installs essential packages.

  • Create a file called site.yml in your homelab-ansible folder by running:
touch site.yml
  • Open site.yml in your text editor and paste this playbook:
---
- name: Configure Raspberry Pi as learning platform
  hosts: homelab
  become: true

  vars:
    hostname: rpi5-homelab
    admin_user: admin
    admin_shell: /bin/bash
    packages:
      - git
      - vim
      - curl
      - htop
      - tree
      - ca-certificates

  tasks:
    # Base configuration
    - name: Set hostname
      ansible.builtin.hostname:
        name: "{{ hostname }}"

    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install essential packages
      ansible.builtin.apt:
        name: "{{ packages }}"
        state: present

What does this playbook do?

  • hosts: homelab targets every host in the [homelab] group from your inventory file.
  • become: true tells Ansible to use sudo for tasks that need root permissions (like installing packages).
  • The vars section defines reusable values. The packages list is referenced by the install task below.
  • Each task uses a fully qualified module name like ansible.builtin.hostname. This makes it clear which Ansible module handles the work.
  • cache_valid_time: 3600 prevents Ansible from refreshing the package cache if it was updated less than an hour ago.
  • Save site.yml.
  • Confirm both files exist by running:
ls

You should see ansible.cfg, inventory.ini, and site.yml in the output.

Missing a file?

Make sure you're in the ~/homelab-ansible directory. Run pwd to check your current location. If you're elsewhere, run cd ~/homelab-ansible to get back.

Help me figure out why my Ansible project files are missing from the directory listing

✔️ Awesome, I've got everything!

Great. Double check you've saved site.yml before moving on.

ⓧ I'd like to double check the full code

---
- name: Configure Raspberry Pi as learning platform
  hosts: homelab
  become: true

  vars:
    hostname: rpi5-homelab
    admin_user: admin
    admin_shell: /bin/bash
    packages:
      - git
      - vim
      - curl
      - htop
      - tree
      - ca-certificates

  tasks:
    # Base configuration
    - name: Set hostname
      ansible.builtin.hostname:
        name: "{{ hostname }}"

    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install essential packages
      ansible.builtin.apt:
        name: "{{ packages }}"
        state: present
Run the playbook

Your playbook is ready. Time to let Ansible configure your Pi.

  • Run your playbook by executing this command:
ansible-playbook site.yml

Ansible connects to your Pi, runs each task in order, and reports the result. You should see output with changed next to each task name, meaning Ansible made a modification.

What should I see?

Each task prints its name and a status. On this first run, most tasks show changed: [rpi5] because the Pi didn't have the hostname set or the packages installed yet.

At the bottom, you'll see a PLAY RECAP with a summary. Look for the changed count. It should be greater than zero.

Seeing a permission denied error?

The pi user needs passwordless sudo access on your Raspberry Pi. On Raspberry Pi OS this is enabled by default. If you see a sudo password prompt error, SSH into your Pi manually and check that sudo whoami returns root without asking for a password.

Help me fix a permission denied error when running my Ansible playbook against my Raspberry Pi

Re-run the playbook to see idempotency

Idempotency means running the same command multiple times produces the same result. Ansible checks the current state before acting. If the Pi already matches the playbook, Ansible does nothing.

  • Run the exact same command again:
ansible-playbook site.yml

This time, every task should show ok instead of changed. The PLAY RECAP at the bottom should show changed=0.

Why does this matter?

In a real infrastructure team, playbooks run on schedules or as part of automated pipelines. Idempotency guarantees that re-running a playbook never breaks a server that's already configured correctly.

This is the core value of Infrastructure as Code. You describe what state you want, and the tool figures out how to get there without damaging what's already correct.

Still seeing changed tasks on the second run?

If the Update apt cache task shows changed, that can happen if more than an hour passed between runs (the cache_valid_time window expired). Run the playbook a third time immediately. All tasks should show ok.

Help me understand why my Ansible playbook still shows changed tasks on the second run

Your Pi now has a proper hostname and essential packages installed, all managed by code. Next up, you'll add user management and SSH hardening to lock down your server.

Add User Management and SSH Hardening

Your Pi has its hostname set and essential packages installed. The next priority is access control.

Right now, anyone with the pi user's password could log in over SSH. In this step, you'll create a dedicated admin user with key-based access and lock down SSH so passwords and root login are disabled entirely.

In this step, get ready to:
  • Create a dedicated admin user with sudo privileges.
  • Deploy your SSH public key to the new user.
  • Disable password authentication and root login over SSH.
Add user management tasks to your playbook

A best practice for server management is to avoid using the default pi user for everything. You'll create a dedicated admin user with sudo access and deploy your SSH key so you can log in without a password.

  • In site.yml, find the last task (Install essential packages). Add the following code below it:
    # User management
    - name: Create admin user
      ansible.builtin.user:
        name: "{{ admin_user }}"
        shell: "{{ admin_shell }}"
        groups: sudo
        append: true
        create_home: true

    - name: Deploy SSH key for admin user
      ansible.posix.authorized_key:
        user: "{{ admin_user }}"
        key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
        state: present

What does this code do?

  • The first task uses ansible.builtin.user to create a user called admin with a bash shell and membership in the sudo group. The append: true ensures it adds to existing groups rather than replacing them.
  • The second task uses ansible.posix.authorized_key to copy your local public key (~/.ssh/id_ed25519.pub) into the admin user's authorized_keys file on the Pi. This lets you SSH in as admin without typing a password.

What is ansible.posix.authorized_key?

This module is part of the ansible.posix collection, which is included automatically when you install the full Ansible package (as you did with Homebrew). It manages SSH authorized keys on remote hosts.

  • Save site.yml.
  • You won't see a change on the Pi yet. The next substep adds SSH hardening, then we'll run the full playbook.
Add SSH hardening tasks and a handler

Disabling password login and root access over SSH are standard hardening steps for any server. When Ansible modifies the SSH config, it needs to restart the SSH service for changes to take effect.

Ansible uses handlers for this. A handler is a task that only runs when notified by another task. This avoids unnecessary service restarts.

  • In site.yml, find the closing line of your vars section (the last item in the packages list, - ca-certificates). Add the following handler section between vars and tasks:
  handlers:
    - name: Restart ssh
      ansible.builtin.service:
        name: ssh
        state: restarted

What is a handler?

A handler is a task that waits until it is "notified" by another task. If a task changes something on the server and includes notify: Restart ssh, the handler runs at the end of the play. If nothing changed, the handler is skipped entirely.

This keeps your SSH service from restarting unnecessarily every time you run the playbook.

  • Now scroll to the bottom of your tasks section (after the Deploy SSH key for admin user task you just added). Add the following SSH hardening tasks:
    # SSH hardening
    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PasswordAuthentication'
        line: 'PasswordAuthentication no'
      notify: Restart ssh

    - name: Disable root login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin'
        line: 'PermitRootLogin no'
      notify: Restart ssh

What does this code do?

  • ansible.builtin.lineinfile finds a line in a file matching the regexp pattern and replaces it with the line value. The ^#? pattern matches both commented-out and active lines.
  • Setting PasswordAuthentication no means only SSH key holders can log in. No passwords accepted.
  • Setting PermitRootLogin no prevents anyone from logging in directly as root over SSH.
  • Both tasks include notify: Restart ssh to trigger the handler you added above. The SSH service restarts only if either line was actually changed.
  • Save site.yml.

Getting YAML indentation errors?

  • Make sure handlers: is indented at the same level as vars: and tasks: (two spaces from the left margin).
  • Each task line (- name:) must be indented with four spaces under its parent section.
  • YAML does not allow tabs. Use only spaces.

Help me fix a YAML indentation error in my Ansible playbook.

Run the playbook and verify access

Your playbook now has user management and SSH hardening tasks. Time to apply them to your Pi.

  • Run your updated playbook by executing this command:
ansible-playbook site.yml

What should I see?

You should see output showing each task with its status. The base configuration tasks (hostname, apt cache, packages) show ok because those were applied in the previous step. The new user management and SSH hardening tasks show changed because they are running for the first time.

At the bottom, you should see a PLAY RECAP line with changed greater than 0.

  • Verify you can SSH into the Pi as the new admin user by running:
ssh [[PI_IP="admin@your-Pi-IP-address"]] -i ~/.ssh/id_ed25519
  • You should land in a terminal prompt as admin without being asked for a password. This confirms your SSH key was deployed successfully.
  • Type exit to return to your Mac's terminal.

Permission denied when SSHing as admin?

  • Make sure you have a public key at ~/.ssh/id_ed25519.pub on your Mac. If you don't have one, generate it with ssh-keygen -t ed25519 and re-run the playbook.
  • Double-check that the ansible_ssh_private_key_file path in your inventory.ini matches the key you're specifying in the SSH command.
  • If you use an RSA key instead of ed25519, update the path in both inventory.ini and the lookup('file', ...) in site.yml to point to your actual key.

Help me troubleshoot why I can't SSH into my Pi as the admin user after running my Ansible playbook.

✔️ Awesome, I've got everything!

Great. Double check you've saved site.yml and confirmed SSH access as admin.

ⓧ I'd like to double check the full code

---
- name: Configure Raspberry Pi as learning platform
  hosts: homelab
  become: true

  vars:
    hostname: rpi5-homelab
    admin_user: admin
    admin_shell: /bin/bash
    packages:
      - git
      - vim
      - curl
      - htop
      - tree
      - ca-certificates

  handlers:
    - name: Restart ssh
      ansible.builtin.service:
        name: ssh
        state: restarted

  tasks:
    # Base configuration
    - name: Set hostname
      ansible.builtin.hostname:
        name: "{{ hostname }}"

    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install essential packages
      ansible.builtin.apt:
        name: "{{ packages }}"
        state: present

    # User management
    - name: Create admin user
      ansible.builtin.user:
        name: "{{ admin_user }}"
        shell: "{{ admin_shell }}"
        groups: sudo
        append: true
        create_home: true

    - name: Deploy SSH key for admin user
      ansible.posix.authorized_key:
        user: "{{ admin_user }}"
        key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
        state: present

    # SSH hardening
    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PasswordAuthentication'
        line: 'PasswordAuthentication no'
      notify: Restart ssh

    - name: Disable root login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin'
        line: 'PermitRootLogin no'
      notify: Restart ssh

Your Pi is locked down with a dedicated admin user and hardened SSH. Next up, you'll install Docker Engine on the Pi entirely through Ansible.

Install Docker via Ansible

Your Pi now has a dedicated admin user and a hardened SSH configuration. The next goal is to give your Pi the ability to run containers.

Docker is the industry-standard container runtime. Instead of SSH-ing into your Pi and running installation commands manually, you will declare the entire Docker setup in your playbook. Ansible handles the apt repository, GPG keys, and package installation for you.

In this step, get ready to:
  • Add Docker installation tasks to your Ansible playbook.
  • Configure your admin user for container access and enable the Docker service.
  • Run the playbook and verify Docker works on your Pi.
Add Docker variables, handler, and installation tasks

The official Docker documentation recommends installing Docker Engine on Raspberry Pi OS 64-bit using the Debian arm64 apt repository method. Your playbook will add Docker's GPG key, register its repository, and install the packages.

  • In site.yml, find the packages list in the vars section. Add the docker_packages list directly below it:
    docker_packages:
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - docker-buildx-plugin
      - docker-compose-plugin
  • In the handlers section, add this handler after the Restart ssh handler:
    - name: Restart docker
      ansible.builtin.service:
        name: docker
        state: restarted
  • At the end of the tasks section, below the SSH hardening tasks (the Disable root login task), add these Docker installation tasks:
    # Docker installation
    - name: Install Docker prerequisites
      ansible.builtin.apt:
        name:
          - ca-certificates
          - curl
        state: present

    - name: Create keyrings directory
      ansible.builtin.file:
        path: /etc/apt/keyrings
        state: directory
        mode: '0755'

    - name: Add Docker GPG key
      ansible.builtin.get_url:
        url: https://download.docker.com/linux/debian/gpg
        dest: /etc/apt/keyrings/docker.asc
        mode: '0644'

    - name: Add Docker apt repository
      ansible.builtin.apt_repository:
        repo: "deb [arch=arm64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable"
        state: present
        filename: docker

    - name: Install Docker packages
      ansible.builtin.apt:
        name: "{{ docker_packages }}"
        state: present
        update_cache: true

What do these tasks do?

The docker_packages variable keeps the package list in one place. The Restart docker handler lets future tasks trigger a Docker service restart when configuration changes.

The five installation tasks follow the official Docker docs for Debian arm64. They install prerequisites, download Docker's GPG signing key, register the apt repository, and install the engine packages. The ansible_distribution_release fact resolves to bookworm on Raspberry Pi OS, so Ansible builds the correct repository URL automatically.

  • Save site.yml. No visible changes on your Pi yet. You will add two more tasks next, then run the full playbook.
Add user and service management tasks

Docker is usable without sudo only if your user belongs to the docker group. You also need to ensure the Docker service starts on boot.

  • Still in site.yml, add these tasks directly below the Install Docker packages task:
    - name: Add admin user to docker group
      ansible.builtin.user:
        name: "{{ admin_user }}"
        groups: docker
        append: true

    - name: Ensure Docker service is started and enabled
      ansible.builtin.service:
        name: docker
        state: started
        enabled: true

What do these tasks do?

The first task adds the admin user to the docker group with append: true so existing group memberships (like sudo) are preserved.

The second task ensures the Docker daemon is running now (state: started) and will start automatically on every boot (enabled: true).

  • Save site.yml. Your playbook is now complete.

Seeing YAML syntax errors?

YAML is indentation-sensitive. Every task list item must start with exactly 4 spaces followed by a hyphen. Check that your new tasks line up with the existing tasks above them.

help me fix a YAML indentation error in my Ansible playbook

✔️ Awesome, I've got everything!

Great. Double check you have saved site.yml before running the playbook.

ⓧ I'd like to double check the full code

---
- name: Configure Raspberry Pi as learning platform
  hosts: homelab
  become: true

  vars:
    hostname: rpi5-homelab
    admin_user: admin
    admin_shell: /bin/bash
    packages:
      - git
      - vim
      - curl
      - htop
      - tree
      - ca-certificates
    docker_packages:
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - docker-buildx-plugin
      - docker-compose-plugin

  handlers:
    - name: Restart ssh
      ansible.builtin.service:
        name: ssh
        state: restarted

    - name: Restart docker
      ansible.builtin.service:
        name: docker
        state: restarted

  tasks:
    # Base configuration
    - name: Set hostname
      ansible.builtin.hostname:
        name: "{{ hostname }}"

    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install essential packages
      ansible.builtin.apt:
        name: "{{ packages }}"
        state: present

    # User management
    - name: Create admin user
      ansible.builtin.user:
        name: "{{ admin_user }}"
        shell: "{{ admin_shell }}"
        groups: sudo
        append: true
        create_home: true

    - name: Deploy SSH key for admin user
      ansible.posix.authorized_key:
        user: "{{ admin_user }}"
        key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
        state: present

    # SSH hardening
    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PasswordAuthentication'
        line: 'PasswordAuthentication no'
      notify: Restart ssh

    - name: Disable root login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin'
        line: 'PermitRootLogin no'
      notify: Restart ssh

    # Docker installation
    - name: Install Docker prerequisites
      ansible.builtin.apt:
        name:
          - ca-certificates
          - curl
        state: present

    - name: Create keyrings directory
      ansible.builtin.file:
        path: /etc/apt/keyrings
        state: directory
        mode: '0755'

    - name: Add Docker GPG key
      ansible.builtin.get_url:
        url: https://download.docker.com/linux/debian/gpg
        dest: /etc/apt/keyrings/docker.asc
        mode: '0644'

    - name: Add Docker apt repository
      ansible.builtin.apt_repository:
        repo: "deb [arch=arm64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable"
        state: present
        filename: docker

    - name: Install Docker packages
      ansible.builtin.apt:
        name: "{{ docker_packages }}"
        state: present
        update_cache: true

    - name: Add admin user to docker group
      ansible.builtin.user:
        name: "{{ admin_user }}"
        groups: docker
        append: true

    - name: Ensure Docker service is started and enabled
      ansible.builtin.service:
        name: docker
        state: started
        enabled: true
Run the playbook and verify Docker
  • Run the playbook to apply all your Docker changes:
ansible-playbook site.yml

The playbook will take a minute or two as it downloads and installs Docker Engine. Watch for changed status on the new Docker tasks. Earlier tasks (hostname, packages, SSH) should show ok since they are already applied. That is idempotency at work again.

Seeing failures on the Docker tasks?

If the GPG key or repository tasks fail, make sure your Pi has an active internet connection. These tasks download files from Docker's servers.

If you see a Permission denied error, confirm that become: true is set at the play level in your playbook. Docker installation requires root privileges.

help me debug a failed Docker installation task in my Ansible playbook

Once the playbook completes successfully, verify Docker is working by running a container on the Pi.

  • SSH into your Pi as the admin user by running:
ssh admin@[[PI_IP="your Pi's IP address"]]
  • Run the hello-world container to confirm Docker is working:
docker run hello-world

You should see output starting with Hello from Docker! followed by a message confirming your installation is working correctly.

Why no sudo?

Your playbook added the admin user to the docker group. Because you opened a fresh SSH session after the playbook ran, the new group membership is already active. No sudo required.

  • Type exit to disconnect from the Pi and return to your Mac's terminal.

Permission denied when running docker?

If you get a permission error, the group membership has not taken effect for your session. Disconnect from the Pi and SSH back in as admin again. A fresh session picks up the new group.

help me fix a Docker permission denied error on my Raspberry Pi

Your Pi is now a fully configured learning platform with Docker Engine installed and running. Everything from the hostname to SSH hardening to container support is defined in one YAML file. If you ever need to rebuild, you run one command and the machine configures itself.

Secret mission

Deploy a Local AI Agent

Your Pi is configured, hardened, and running Docker. But what if it could think? In this secret mission, you'll deploy a local AI agent on your Pi using Ollama and chat with it through the API. All managed through Ansible, of course.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your Mac and your Raspberry Pi hardware. There are no cloud services or ongoing subscription costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • ~/homelab-ansible project directory on your Mac.
  • Ansible installed via Homebrew on your Mac.
  • Ollama Docker container and TinyLlama model running on your Pi.
  • Docker Engine installed on your Pi.
  • admin user created on your Pi.
  • Configuration changes on your Pi (hostname, packages, SSH hardening).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with Ansible and your Pi.

  • The Ollama container is set to --restart unless-stopped, so it resumes automatically when your Pi reboots. It uses minimal resources when idle.
  • Your Ansible files in ~/homelab-ansible are ready for future modifications.
  • Docker and all installed packages remain available on your Pi.

Pause - I'll come back to this later

Stop running processes to free up memory on your Pi, but keep everything installed so you can resume later.

  • SSH into your Pi and stop the Ollama container by running:
  • docker stop ollama.
  • To resume later, SSH into your Pi and run docker start ollama.
  • Your Ansible project files on your Mac remain untouched.

Delete - I don't want to use this again

Remove all project resources from your Mac and undo the changes on your Pi.

On your Mac, remove the project directory by running:

rm -rf ~/homelab-ansible

Uninstall Ansible from your Mac by running:

brew uninstall ansible

On your Pi, SSH in and remove the Ollama container and model data:

ssh pi@your-pi-ip-here
sudo docker rm -f ollama
sudo rm -rf /opt/ollama

Remove Docker Engine from your Pi:

sudo apt purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo rm -rf /var/lib/docker /var/lib/containerd
sudo rm /etc/apt/keyrings/docker.asc
sudo rm /etc/apt/sources.list.d/docker.list

Remove the admin user created by your playbook:

sudo userdel -r admin

Revert SSH hardening (re-enable password authentication):

sudo sed -i 's/^PasswordAuthentication no/#PasswordAuthentication yes/' /etc/ssh/sshd_config
sudo sed -i 's/^PermitRootLogin no/#PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sudo systemctl restart ssh

To fully reset your Pi to factory state, re-flash the SD card using the Raspberry Pi Imager.

Nice Work!

Nice Work!

Nice work! You just built a complete Infrastructure as Code project that transforms a fresh Raspberry Pi into a fully configured learning platform server with a single command.

Here's what you accomplished:

  • Installed Ansible on your Mac and configured it to manage your Raspberry Pi over SSH, using an inventory file that describes your infrastructure as code.
  • Created a playbook that configures hostname, installs packages, creates users, hardens SSH, and installs Docker on your Pi.
  • Experienced idempotency firsthand by re-running your playbook and seeing Ansible change nothing when the Pi is already in the desired state.
  • Secret Mission: Deployed a local AI agent (Ollama + TinyLlama) on your Pi entirely through Ansible, and chatted with it via the API.

Your Raspberry Pi is now a reproducible, code-defined server. If you ever wipe it, add a second Pi, or want to show an employer how you manage infrastructure, you run one command and the machine configures itself. That is Infrastructure as Code.

Ready to quiz yourself?