{"id":158,"date":"2026-06-15T12:00:00","date_gmt":"2026-06-15T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=158"},"modified":"2026-10-30T07:06:49","modified_gmt":"2026-10-30T07:06:49","slug":"part-03-ansible-roles-deep-dive","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=158","title":{"rendered":"Part 03 &#8211; Ansible Roles Deep Dive"},"content":{"rendered":"\n<h3 class=\"wp-block-heading\">Overview<\/h3>\n\n\n<p class=\"wp-block-paragraph\">In Part 2 I explained the overall Ansible project structure, how files are organized, what the inventory contains, and how playbooks call roles. In this article I go one level deeper and walk through the actual YAML inside three core roles:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>common: OS hardening applied to both nodes<\/li>\n\n\n<li>networking: UFW firewall rules<\/li>\n\n\n<li>k3s_server: K3s control plane installation<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph\">Each role is explained task by task. By the end you&#8217;ll understand not just what the YAML does but why each decision was made.<\/p>\n\n\n<h3 class=\"wp-block-heading\">Role 1: common<\/h3>\n\n\n<p class=\"wp-block-paragraph\">The common role is the foundation. It runs on both jarvis and thor and handles everything that needs to be in place before any Kubernetes component is installed.<\/p>\n\n<h3 class=\"wp-block-heading\">Full tasks\/main.yml<\/h3>\n\n\n<pre class=\"wp-block-code\"><code>---\n- name: Set hostname\n  hostname:\n    name: \"{{ node_name }}\" <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">hostname is a built-in Ansible module. It uses hostnamectl under the hood on Ubuntu 24.04. {{ node_name }} resolves to jarvis or thor depending on which host is being configured, set in the inventory file.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Update \/etc\/hosts with cluster nodes\n  blockinfile:\n    path: \/etc\/hosts\n    marker: \"# {mark} HOMELAB CLUSTER NODES\"\n    block: |\n      10.0.0.148  jarvis\n      10.0.0.149  thor <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">blockinfile adds a block of text between two marker comments:<\/p>\n\n\n<pre class=\"wp-block-code\"><code># BEGIN HOMELAB CLUSTER NODES\n10.0.0.148  jarvis\n10.0.0.149  thor\n# END HOMELAB CLUSTER NODES <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">The markers make it idempotent, if you run the playbook again, Ansible finds the existing markers and replaces the block instead of appending a duplicate.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Update and upgrade apt packages\n  apt:\n    update_cache: yes\n    upgrade: dist\n    cache_valid_time: 3600 <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">upgrade: dist is equivalent to apt dist-upgrade, it upgrades packages and handles dependency changes. More thorough than apt upgrade.<\/p>\n\n\n<p class=\"wp-block-paragraph\">cache_valid_time: 3600, if apt update was run less than 3600 seconds ago (1 hour), skip it. Saves time on repeated runs.<\/p>\n\n<pre class=\"wp-block-code\"><code>- name: Install essential packages\n  apt:\n    name:\n      - curl\n      - wget\n      - git\n      - vim\n      - htop\n      - net-tools\n      - nmap\n      - iptables\n      - open-iscsi\n      - nfs-common\n      - software-properties-common\n      - ca-certificates\n      - gnupg\n      - lsb-release\n      - unattended-upgrades\n      - apt-transport-https\n    state: present <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">state: present means &#8220;install if not already installed&#8221;. This is idempotent, if a package is already there, Ansible skips it without error.<\/p>\n\n\n<p class=\"wp-block-paragraph\">Passing a list to name: installs all packages in a single apt install call, which is faster than one task per package.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Set timezone\n  timezone:\n    name: \"{{ timezone }}\" <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Uses the community.general.timezone module. {{ timezone }} resolves to America\/Vancouver from [all:vars] in the inventory. This sets the system timezone using timedatectl under the hood.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Disable swap immediately\n  command: swapoff -a\n  when: ansible_swaptotal_mb &gt; 0\n\n- name: Disable swap in fstab permanently\n  replace:\n    path: \/etc\/fstab\n    regexp: '^([^#].*?\\sswap\\s+.*)$'\n    replace: '# \\1' <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">when: ansible_swaptotal_mb &gt; 0, ansible_swaptotal_mb is a fact automatically gathered by Ansible at the start of every play. It&#8217;s the total swap in MB. This condition skips the task if swap is already off, pure idempotency.<\/p>\n\n\n<p class=\"wp-block-paragraph\">The replace module uses a regex to comment out the swap line in \/etc\/fstab:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>^([^#].*?\\sswap\\s+.*)$: matches any non-commented line containing the word swap<\/li>\n\n\n<li># \\1: replaces it with the same line prefixed by #<\/li>\n<\/ul>\n\n<pre class=\"wp-block-code\"><code>- name: Load required kernel modules\n  modprobe:\n    name: \"{{ item }}\"\n    state: present\n  loop:\n    - overlay\n    - br_netfilter\n\n- name: Persist kernel modules on boot\n  copy:\n    dest: \/etc\/modules-load.d\/k3s.conf\n    content: |\n      overlay\n      br_netfilter <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">loop: runs the same task multiple times with different values. {{ item }} is the current loop value, overlay first, then br_netfilter.<\/p>\n\n\n<p class=\"wp-block-paragraph\">modprobe loads a kernel module immediately. The copy task writes to \/etc\/modules-load.d\/k3s.conf so systemd loads them on every boot. Two separate mechanisms, one for now, one for reboots.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Set sysctl parameters for Kubernetes networking\n  sysctl:\n    name: \"{{ item.key }}\"\n    value: \"{{ item.value }}\"\n    state: present\n    reload: yes\n    sysctl_file: \/etc\/sysctl.d\/99-k3s.conf\n  loop:\n    - { key: \"net.bridge.bridge-nf-call-iptables\",  value: \"1\" }\n    - { key: \"net.bridge.bridge-nf-call-ip6tables\", value: \"1\" }\n    - { key: \"net.ipv4.ip_forward\",                 value: \"1\" } <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">loop: here uses a list of dictionaries. Each item has key and value, accessed as {{ item.key }} and {{ item.value }}.<\/p>\n\n\n<p class=\"wp-block-paragraph\">sysctl_file: \/etc\/sysctl.d\/99-k3s.conf writes the parameters to a dedicated file instead of \/etc\/sysctl.conf. Keeps our changes isolated and easy to remove later.<\/p>\n\n\n<p class=\"wp-block-paragraph\">reload: yes applies the changes immediately via sysctl &#8211;system.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Enable iscsid for Longhorn\n  systemd:\n    name: iscsid\n    enabled: yes\n    state: started <\/code><\/pre>\n\n\n<ul class=\"wp-block-list\">\n<li>enabled: yes : starts on boot<\/li>\n\n\n<li>state: started: starts right now<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph\">Both are needed. enabled: yes alone would start it next reboot but not now. state: started alone would start it now but not persist.<\/p>\n\n<pre class=\"wp-block-code\"><code>- name: Install Docker\n  block:\n    - name: Create apt keyrings directory\n      file:\n        path: \/etc\/apt\/keyrings\n        state: directory\n        mode: \"0755\"\n\n    - name: Download Docker GPG key\n      shell: |\n        curl -fsSL https:\/\/download.docker.com\/linux\/ubuntu\/gpg \\\n          | gpg --dearmor -o \/etc\/apt\/keyrings\/docker.gpg\n        chmod a+r \/etc\/apt\/keyrings\/docker.gpg\n      args:\n        creates: \/etc\/apt\/keyrings\/docker.gpg\n\n    - name: Add Docker apt repository\n      apt_repository:\n        repo: \"deb [arch=amd64 signed-by=\/etc\/apt\/keyrings\/docker.gpg] https:\/\/download.docker.com\/linux\/ubuntu {{ ansible_distribution_release }} stable\"\n        state: present\n        filename: docker\n\n    - name: Install Docker packages\n      apt:\n        name:\n          - docker-ce\n          - docker-ce-cli\n          - containerd.io\n          - docker-buildx-plugin\n          - docker-compose-plugin\n        state: present\n        update_cache: yes\n\n    - name: Add user to docker group\n      user:\n        name: \"{{ lab_user }}\"\n        groups: docker\n        append: yes\n\n    - name: Enable and start Docker\n      systemd:\n        name: docker\n        enabled: yes\n        state: started <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">block: groups related tasks together. If any task in the block fails, Ansible can handle it as a unit (with rescue: or always: blocks). It also makes the YAML more readable, it&#8217;s clear these tasks all belong to &#8220;Install Docker&#8221;.<\/p>\n\n\n<p class=\"wp-block-paragraph\">args: creates: \/etc\/apt\/keyrings\/docker.gpg, this makes the shell task idempotent. The shell module normally runs every time. creates: tells Ansible &#8220;if this file already exists, skip this task&#8221;. Without it, we&#8217;d overwrite the GPG key file on every run.<\/p>\n\n\n<p class=\"wp-block-paragraph\">{{ ansible_distribution_release }} , another automatically gathered fact. On Ubuntu 24.04 this resolves to noble. This means the repo URL is correct for whatever Ubuntu version is installed, no hardcoding.<\/p>\n\n\n<p class=\"wp-block-paragraph\">append: yes in the user module, adds docker to the user&#8217;s groups without removing existing groups. Without append: yes, Ansible would replace all groups with just docker.<\/p>\n\n<pre class=\"wp-block-code\"><code>- name: Install dev toolchains on worker node\n  block:\n    - name: Install Python tools\n      apt:\n        name:\n          - python3\n          - python3-pip\n          - python3-venv\n          - python3-dev\n        state: present\n\n    - name: Download Go\n      get_url:\n        url: https:\/\/go.dev\/dl\/go1.22.4.linux-amd64.tar.gz\n        dest: \/tmp\/go.tar.gz\n\n    - name: Extract Go\n      unarchive:\n        src: \/tmp\/go.tar.gz\n        dest: \/usr\/local\n        remote_src: yes\n        creates: \/usr\/local\/go\/bin\/go\n\n    - name: Add Go to PATH\n      lineinfile:\n        path: \/home\/{{ lab_user }}\/.bashrc\n        line: 'export PATH=$PATH:\/usr\/local\/go\/bin'\n        state: present\n\n    - name: Download NodeSource setup script\n      get_url:\n        url: https:\/\/deb.nodesource.com\/setup_20.x\n        dest: \/tmp\/nodesource_setup.sh\n        mode: '0755'\n\n    - name: Run NodeSource setup\n      command: bash \/tmp\/nodesource_setup.sh\n\n    - name: Install Node.js\n      apt:\n        name: nodejs\n        state: present\n        update_cache: yes\n\n  when: node_role == \"worker\" <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">when: node_role == &#8220;worker&#8221; at the block level applies to every task inside the block. This is cleaner than adding when: to each individual task.<\/p>\n\n\n<p class=\"wp-block-paragraph\">get_url is Ansible&#8217;s module for downloading files, equivalent to curl -O or wget. It handles checksums, retries, and is idempotent.<\/p>\n\n\n<p class=\"wp-block-paragraph\">unarchive with remote_src: yes, extracts a file that&#8217;s already on the remote machine (not on the Ansible controller). Without remote_src: yes, Ansible would try to find the file locally on your MacBook.<\/p>\n\n\n<p class=\"wp-block-paragraph\">lineinfile adds a single line to a file if it doesn&#8217;t already exist. It won&#8217;t add duplicates, making it idempotent. Perfect for adding export PATH=&#8230; to .bashrc.<\/p>\n\n<h3 class=\"wp-block-heading\">Role 2: networking<\/h3>\n\n\n<p class=\"wp-block-paragraph\">The networking role configures UFW (Uncomplicated Firewall) on both nodes. The rules differ depending on whether the node is the control plane or a worker.<\/p>\n\n\n<h3 class=\"wp-block-heading\">Full tasks\/main.yml<\/h3>\n\n\n<pre class=\"wp-block-code\"><code>---\n- name: Install UFW\n  apt:\n    name: ufw\n    state: present <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Simple package install. UFW is usually included in Ubuntu but we ensure it&#8217;s there.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Allow SSH\n  ufw:\n    rule: allow\n    port: \"22\"\n    proto: tcp <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Always allow SSH first. If you enable UFW before allowing SSH you&#8217;ll lock yourself out of the machine permanently. This task runs on every node regardless of role.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Open control plane firewall ports\n  ufw:\n    rule: allow\n    port: \"{{ item.port }}\"\n    proto: \"{{ item.proto }}\"\n    comment: \"{{ item.comment }}\"\n  loop:\n    - { port: \"6443\",  proto: \"tcp\", comment: \"K3s API server\" }\n    - { port: \"2379\",  proto: \"tcp\", comment: \"etcd client\" }\n    - { port: \"2380\",  proto: \"tcp\", comment: \"etcd peer\" }\n    - { port: \"10250\", proto: \"tcp\", comment: \"Kubelet\" }\n    - { port: \"10251\", proto: \"tcp\", comment: \"Scheduler\" }\n    - { port: \"10252\", proto: \"tcp\", comment: \"Controller manager\" }\n    - { port: \"8472\",  proto: \"udp\", comment: \"Flannel VXLAN\" }\n    - { port: \"51820\", proto: \"udp\", comment: \"WireGuard\" }\n  when: node_role == \"control-plane\" <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">What each port does:<\/p>\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Port<\/th><th>Protocol<\/th><th>Component<\/th><th>Purpose<\/th><\/tr><\/thead><tbody><tr><td>6443<\/td><td>TCP<\/td><td>K3s API server<\/td><td>kubectl commands hit this port<\/td><\/tr><tr><td>2379<\/td><td>TCP<\/td><td>etcd<\/td><td>Client connections to the key-value store<\/td><\/tr><tr><td>2380<\/td><td>TCP<\/td><td>etcd<\/td><td>Peer communication between etcd instances<\/td><\/tr><tr><td>10250<\/td><td>TCP<\/td><td>Kubelet<\/td><td>API for node-level operations<\/td><\/tr><tr><td>10251<\/td><td>TCP<\/td><td>Scheduler<\/td><td>Internal scheduler communication<\/td><\/tr><tr><td>10252<\/td><td>TCP<\/td><td>Controller Manager<\/td><td>Internal controller communication<\/td><\/tr><tr><td>8472<\/td><td>UDP<\/td><td>Flannel VXLAN<\/td><td>Pod-to-pod network traffic between nodes<\/td><\/tr><tr><td>51820<\/td><td>UDP<\/td><td>WireGuard<\/td><td>Optional encrypted node communication<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n<p class=\"wp-block-paragraph\">when: node_role == &#8220;control-plane&#8221; , these ports only open on jarvis. thor doesn&#8217;t run the API server or etcd.<\/p>\n\n<pre class=\"wp-block-code\"><code>- name: Open worker firewall ports\n  ufw:\n    rule: allow\n    port: \"{{ item.port }}\"\n    proto: \"{{ item.proto }}\"\n    comment: \"{{ item.comment }}\"\n  loop:\n    - { port: \"10250\", proto: \"tcp\", comment: \"Kubelet\" }\n    - { port: \"8472\",  proto: \"udp\", comment: \"Flannel VXLAN\" }\n    - { port: \"51820\", proto: \"udp\", comment: \"WireGuard\" }\n  when: node_role == \"worker\" <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Worker nodes need fewer ports, just Kubelet (for the control plane to manage it) and Flannel (for pod networking).<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Open app service ports\n  ufw:\n    rule: allow\n    port: \"{{ item.port }}\"\n    proto: tcp\n    comment: \"{{ item.comment }}\"\n  loop:\n    - { port: \"80\",    comment: \"HTTP \/ Grafana \/ ArgoCD\" }\n    - { port: \"443\",   comment: \"HTTPS\" }\n    - { port: \"3000\",  comment: \"Grafana direct\" }\n    - { port: \"9090\",  comment: \"Prometheus\" }\n    - { port: \"9093\",  comment: \"Alertmanager\" }\n    - { port: \"3100\",  comment: \"Loki\" }\n    - { port: \"16686\", comment: \"Jaeger UI\" }\n    - { port: \"8080\",  comment: \"Jenkins UI\" }\n    - { port: \"50000\", comment: \"Jenkins agent JNLP\" }\n    - { port: \"5000\",  comment: \"Container registry\" }\n    - { port: \"8200\",  comment: \"Vault UI\" }\n    - { port: \"30000:32767\", comment: \"K3s NodePort range\" } <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">These open the service ports so you can reach them from other devices on the network. 30000:32767 is the NodePort range, K3s exposes services on random ports in this range.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Allow all traffic from cluster network\n  ufw:\n    rule: allow\n    src: \"{{ lab_network }}\"\n\n- name: Allow MetalLB IP pool range inbound\n  ufw:\n    rule: allow\n    src: \"10.0.0.200\/28\"\n    comment: \"MetalLB LoadBalancer pool\"\n\n- name: Enable UFW\n  ufw:\n    state: enabled\n    policy: deny\n    direction: incoming\n\n- name: Reload UFW\n  ufw:\n    state: reloaded <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">src: &#8220;{{ lab_network }}&#8221;: allows all traffic from 10.0.0.0\/24 (your home network). This means any device on your WiFi can reach the nodes without needing individual port rules.<\/p>\n\n\n<p class=\"wp-block-paragraph\">policy: deny + direction: incoming: the default policy is deny all inbound. Only the explicitly allowed ports above are open. This is the secure default.<\/p>\n\n\n<p class=\"wp-block-paragraph\">Order matters: Enable UFW and set the default deny policy after adding the allow rules. If you did it before, you&#8217;d lock yourself out.<\/p>\n\n<h3 class=\"wp-block-heading\">Role 3: k3s_server<\/h3>\n\n\n<p class=\"wp-block-paragraph\">This role installs and configures the K3s control plane on jarvis. It also installs MetalLB, NGINX Ingress, and sets up the kubeconfig.<\/p>\n\n\n<h3 class=\"wp-block-heading\">Full tasks\/main.yml<\/h3>\n\n\n<pre class=\"wp-block-code\"><code>---\n- name: Check if K3s server is already installed\n  stat:\n    path: \/usr\/local\/bin\/k3s\n  register: k3s_binary <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">stat checks if a file exists. register: k3s_binary saves the result into a variable. We use this in the next task to skip installation if K3s is already there.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Install K3s server\n  shell: |\n    curl -sfL https:\/\/get.k3s.io | sh -s - server \\\n      --disable traefik \\\n      --disable servicelb \\\n      --node-name {{ node_name }} \\\n      --advertise-address {{ node_ip }} \\\n      --bind-address {{ node_ip }} \\\n      --write-kubeconfig-mode 644\n  when: not k3s_binary.stat.exists <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">when: not k3s_binary.stat.exists, only runs if \/usr\/local\/bin\/k3s doesn&#8217;t exist. Makes this task idempotent even though it uses shell.<\/p>\n\n\n<p class=\"wp-block-paragraph\">Flags explained:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>&#8211;disable traefik: Don&#8217;t install Traefik ingress, we install NGINX instead<\/li>\n\n\n<li>&#8211;disable servicelb: Don&#8217;t install K3s&#8217;s built-in load balancer, we use MetalLB<\/li>\n\n\n<li>&#8211;node-name jarvis Node name in the cluster<\/li>\n\n\n<li>&#8211;advertise-address 10.0.0.148 IP the API server advertises to workers<\/li>\n\n\n<li>&#8211;bind-address 10.0.0.148 IP the API server listens on<\/li>\n\n\n<li>&#8211;write-kubeconfig-mode 644 Makes kubeconfig readable by non-root users<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph\">Why disable traefik and servicelb? K3s ships with Traefik (ingress) and ServiceLB (load balancer) built in. I disable them because:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>NGINX Ingress is more widely used in production, better to learn that<\/li>\n\n\n<li>MetalLB gives more control over LoadBalancer IP assignment<\/li>\n\n\n<li>Keeping defaults means hidden components you might not understand<\/li>\n<\/ul>\n\n<pre class=\"wp-block-code\"><code>- name: Wait for K3s to be ready\n  wait_for:\n    path: \/var\/lib\/rancher\/k3s\/server\/node-token\n    timeout: 60 <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">wait_for pauses the playbook until a condition is met. Here it waits for the node token file to exist, which appears when K3s has finished initializing. Without this, the next task (reading the token) would fail because K3s hasn&#8217;t finished starting yet.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Read K3s node token\n  slurp:\n    src: \/var\/lib\/rancher\/k3s\/server\/node-token\n  register: k3s_token_raw\n\n- name: Set K3s token fact\n  set_fact:\n    k3s_token: \"{{ k3s_token_raw.content | b64decode | trim }}\"\n\n- name: Save token to dummy host for worker\n  add_host:\n    name: \"k3s_token_holder\"\n    k3s_token: \"{{ k3s_token }}\" <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">slurp reads a remote file and returns its content as base64. k3s_token_raw.content contains the base64-encoded token.<\/p>\n\n\n<p class=\"wp-block-paragraph\">b64decode is a Jinja2 filter that decodes base64. trim removes trailing whitespace\/newlines.<\/p>\n\n\n<p class=\"wp-block-paragraph\">add_host creates a virtual &#8220;dummy&#8221; host in memory with the token as a variable. This is how we pass the token from the control plane play to the worker play, Ansible plays don&#8217;t share variables by default, but add_host bridges that gap.<\/p>\n\n\n<p class=\"wp-block-paragraph\">In the worker role, the token is accessed as:<\/p>\n\n\n<pre class=\"wp-block-code\"><code>token {{ hostvars['k3s_token_holder']['k3s_token'] }} <\/code><\/pre>\n\n\n<pre class=\"wp-block-code\"><code>- name: Wait for K3s API to be available\n  wait_for:\n    host: \"{{ node_ip }}\"\n    port: 6443\n    timeout: 60 <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Waits until port 6443 (the K3s API server) is accepting connections. This ensures the cluster is actually ready before we try to run kubectl commands.<\/p>\n\n<pre class=\"wp-block-code\"><code>- name: Label control plane node\n  shell: |\n    kubectl label node {{ node_name }} \\\n      node-role=control-plane \\\n      workload={{ workload_label }} \\\n      --overwrite\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Kubernetes labels are key-value pairs attached to nodes. We use them for pod scheduling, telling Kubernetes which pods should run on which node.<\/p>\n\n\n<p class=\"wp-block-paragraph\">workload=monitoring on jarvis means we can use nodeSelector in pod specs:<\/p>\n\n\n<pre class=\"wp-block-code\"><code>nodeSelector:\n  workload: monitoring    # \u2190 only schedule on jarvis <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">environment: KUBECONFIG: sets the KUBECONFIG environment variable for this shell command. Without it, kubectl doesn&#8217;t know where to find the cluster config.<\/p>\n\n\n<p class=\"wp-block-paragraph\">&#8211;overwrite, if the label already exists with a different value, update it instead of failing.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Install Helm\n  shell: curl https:\/\/raw.githubusercontent.com\/helm\/helm\/main\/scripts\/get-helm-3 | bash\n  args:\n    creates: \/usr\/local\/bin\/helm <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">creates: \/usr\/local\/bin\/helm, makes this idempotent. The shell script won&#8217;t re-run if Helm is already installed.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Install MetalLB\n  shell: |\n    kubectl apply -f https:\/\/raw.githubusercontent.com\/metallb\/metallb\/v0.14.5\/config\/manifests\/metallb-native.yaml\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml\n\n- name: Wait for MetalLB to be ready\n  shell: |\n    kubectl wait --namespace metallb-system \\\n      --for=condition=ready pod \\\n      --selector=app=metallb \\\n      --timeout=90s\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml\n  retries: 3\n  delay: 15 <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">retries: 3 and delay: 15, if kubectl wait times out (MetalLB pods are still starting), Ansible waits 15 seconds and tries again, up to 3 times. MetalLB sometimes takes longer to initialize than the timeout allows on first install.<\/p>\n\n<pre class=\"wp-block-code\"><code>- name: Configure MetalLB IP pool\n  shell: |\n    cat &lt;&lt;EOF | kubectl apply -f -\n    apiVersion: metallb.io\/v1beta1\n    kind: IPAddressPool\n    metadata:\n      name: homelab-pool\n      namespace: metallb-system\n    spec:\n      addresses:\n      - {{ metallb_ip_range }}\n    ---\n    apiVersion: metallb.io\/v1beta1\n    kind: L2Advertisement\n    metadata:\n      name: homelab-l2\n      namespace: metallb-system\n    EOF\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">This creates two MetalLB custom resources:<\/p>\n\n\n<p class=\"wp-block-paragraph\">IPAddressPool, defines the pool of IPs MetalLB can assign. {{ metallb_ip_range }} resolves to 10.0.0.200-10.0.0.220. When a Kubernetes service of type LoadBalancer is created, MetalLB picks an IP from this pool.<\/p>\n\n\n<p class=\"wp-block-paragraph\">L2Advertisement, tells MetalLB to use Layer 2 mode (ARP). In L2 mode, MetalLB responds to ARP requests for the LoadBalancer IPs, making them reachable on the local network. This is the right mode for a home network without BGP routing.<\/p>\n\n\n<p class=\"wp-block-paragraph\">The cat &lt;&lt;EOF | kubectl apply -f &#8211; pattern is a heredoc piped to kubectl, a clean way to apply inline YAML manifests from a shell command.<\/p>\n\n\n<pre class=\"wp-block-code\"><code>- name: Create .kube dir for user\n  file:\n    path: \/home\/{{ lab_user }}\/.kube\n    state: directory\n    owner: \"{{ lab_user }}\"\n    mode: '0755'\n\n- name: Copy kubeconfig to user home\n  copy:\n    src: \/etc\/rancher\/k3s\/k3s.yaml\n    dest: \/home\/{{ lab_user }}\/.kube\/config\n    remote_src: yes\n    owner: \"{{ lab_user }}\"\n    mode: '0600'\n\n- name: Fix kubeconfig server address\n  replace:\n    path: \/home\/{{ lab_user }}\/.kube\/config\n    regexp: 'https:\/\/127\\.0\\.0\\.1:6443'\n    replace: 'https:\/\/{{ node_ip }}:6443' <\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Order matters here, this is one of the bugs I hit. The copy task fails if the destination directory doesn&#8217;t exist. Creating the directory first fixes it.<\/p>\n\n\n<p class=\"wp-block-paragraph\">K3s writes the kubeconfig with server: https:\/\/127.0.0.1:6443, pointing to localhost. When you copy this to your MacBook and run kubectl, it tries to connect to your MacBook&#8217;s localhost, not jarvis. The replace task fixes the server address to the actual node IP so it works from any machine.<\/p>\n\n\n<h3 class=\"wp-block-heading\">What&#8217;s Next<\/h3>\n\n\n<p class=\"wp-block-paragraph\">In Part 4 I&#8217;ll cover the networking role in more detail and walk through the K3s agent role, how thor joins the cluster, how node tokens work, and how Kubernetes schedules pods across two nodes.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Overview In Part 2 I explained the overall Ansible project structure, how files are organized, what the inventory contains, and how playbooks&hellip;<\/p>\n","protected":false},"author":2,"featured_media":161,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"3","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/part-03-ansible-roles-deep-dive-reza-chegini-0elcc\/","footnotes":""},"categories":[63,10,62],"tags":[65,67,8,66,97],"series":[64],"class_list":["post-158","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ansible","category-devops","category-home-lab","tag-ansible","tag-automation","tag-devops","tag-home-lab","tag-roles","series-home-lab"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/part-03-ansible-roles-deep-dive-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/158","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=158"}],"version-history":[{"count":2,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/158\/revisions"}],"predecessor-version":[{"id":160,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/158\/revisions\/160"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/161"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=158"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=158"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=158"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=158"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}