This is Part 10 of the Building a Homelab with AI series. Previously: File Permissions Deep Dive | Next: Anti-Patterns and War Stories
The Manual Configuration Problem
Here’s a scenario every homelab operator has lived. You spin up a new LXC container, SSH in as root, create your user, add them to the sudo group, maybe drop a file in /etc/sudoers.d/. You’ve done it a hundred times. It takes two minutes.
Now multiply that by seven hosts. Then imagine you need to change the sudo policy — maybe adding a service account, or restricting a command set. Do you SSH into all seven machines? Do you remember which ones you’ve already done?
This is the problem Ansible solves: declare the desired state once, apply it everywhere, verify it’s correct every time you run it.
The Bootstrap Approach
In my homelab, sudoers configuration happens at two stages: bootstrap (first contact with a fresh container) and ongoing maintenance.
Stage 1: Bootstrap via Proxmox
Fresh LXC containers have a chicken-and-egg problem: you need sudo to set up sudo. My solution, covered in detail in Post 3, uses Proxmox’s pct exec to reach inside containers as root without SSH or sudo:
- name: Set up passwordless sudo
ansible.builtin.command:
cmd: >
pct exec {{ item.vmid }} -- bash -c
'echo "{{ admin_user }} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/{{ admin_user }}
&& chmod 440 /etc/sudoers.d/{{ admin_user }}'
loop: "{{ lxc_containers }}"
loop_control:
label: "{{ item.name }} ({{ item.vmid }})"
This runs on the Proxmox hypervisor (pve) and uses pct exec to write the sudoers drop-in file directly. No SSH, no sudo, no dependency on the container having anything installed.
There’s a subtlety here: we can’t use visudo -cf for validation inside pct exec because we’re piping through bash -c. The syntax is simple enough (user ALL=(ALL) NOPASSWD: ALL) that a typo is unlikely, but this is one area where the bootstrap playbook trades safety for practicality.
Stage 2: The Standard Bootstrap Playbook
Once the admin user exists and has SSH key auth, the standard bootstrap.yml playbook takes over for subsequent runs and new containers that already have SSH access:
- name: Allow admin user passwordless sudo
ansible.builtin.copy:
content: "{{ admin_user }} ALL=(ALL) NOPASSWD: ALL\n"
dest: "/etc/sudoers.d/{{ admin_user }}"
mode: "0440"
validate: "visudo -cf %s"
This is the gold standard for Ansible sudoers management. Let’s break down why each parameter matters:
| Parameter | Value | Purpose |
|---|---|---|
content | "<user> ALL=(ALL) NOPASSWD: ALL\n" | The sudoers rule, with trailing newline (required by sudoers) |
dest | /etc/sudoers.d/<user> | Drop-in file, named after the user |
mode | "0440" | Read-only for root:root — sudo enforces this |
validate | "visudo -cf %s" | Syntax check before deployment — the %s is replaced with the temp file path |
The validate parameter is the critical one. Ansible writes the content to a temporary file, runs visudo -cf against it, and only moves it to the destination if validation passes. If you have a syntax error, the task fails and nothing changes on the target system. You can’t break sudo by deploying a bad rule.
Building a Reusable Role
For a multi-host homelab, putting sudoers configuration into an Ansible role makes it reusable and testable. Here’s the structure:
ansible/roles/sudoers/
tasks/main.yml
defaults/main.yml
templates/sudoers-user.j2
defaults/main.yml
---
# Default sudoers configuration
sudoers_users:
- name: "{{ admin_user }}"
nopasswd: true
commands: "ALL"
runas: "(ALL)"
# Whether to purge unmanaged drop-in files
sudoers_purge_unmanaged: false
templates/sudoers-user.j2
# Managed by Ansible -- do not edit manually
# Deployed: {{ ansible_date_time.iso8601 }}
{{ item.name }} ALL={{ item.runas | default('(ALL)') }} {% if item.nopasswd | default(false) %}NOPASSWD: {% endif %}{{ item.commands | default('ALL') }}
tasks/main.yml
---
- name: Ensure /etc/sudoers.d/ exists
ansible.builtin.file:
path: /etc/sudoers.d
state: directory
owner: root
group: root
mode: "0750"
- name: Deploy sudoers rules for managed users
ansible.builtin.template:
src: sudoers-user.j2
dest: "/etc/sudoers.d/{{ item.name }}"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
loop: "{{ sudoers_users }}"
loop_control:
label: "{{ item.name }}"
- name: Verify main sudoers file syntax
ansible.builtin.command:
cmd: visudo -c
changed_when: false
register: visudo_check
- name: Display sudoers validation result
ansible.builtin.debug:
var: visudo_check.stdout_lines
Using the Role
In a playbook:
- name: Configure sudoers across fleet
hosts: all
become: true
roles:
- sudoers
With host-specific overrides in host_vars:
# ansible/inventory/host_vars/docker-host.yml
sudoers_users:
- name: <user>
nopasswd: true
commands: "ALL"
- name: monitoring
nopasswd: true
commands: "/usr/bin/systemctl status *, /usr/bin/journalctl, /usr/bin/docker ps"
Now docker-host gets both the admin user’s full-access rule and a restricted monitoring account, while all other hosts get just the admin user.
Enforcing Permissions with Ansible
Sudoers isn’t the only file that needs careful permissions. Here’s a task set that audits and enforces permissions on security-critical files:
- name: Enforce SSH directory permissions
ansible.builtin.file:
path: "/home/{{ admin_user }}/.ssh"
state: directory
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: "0700"
- name: Enforce authorized_keys permissions
ansible.builtin.file:
path: "/home/{{ admin_user }}/.ssh/authorized_keys"
owner: "{{ admin_user }}"
group: "{{ admin_user }}"
mode: "0600"
when: ssh_authorized_keys_stat.stat.exists
- name: Ensure /etc/sudoers has correct permissions
ansible.builtin.file:
path: /etc/sudoers
owner: root
group: root
mode: "0440"
- name: Ensure sshd_config is not world-writable
ansible.builtin.file:
path: /etc/ssh/sshd_config
owner: root
group: root
mode: "0644"
Every run of this playbook is an implicit audit. If something has drifted — a careless chmod, a package upgrade that reset permissions, an attacker who loosened restrictions — Ansible puts it back. Idempotent enforcement.
The Ansible Vault Pattern for Credentials
While we’re talking about permission-sensitive files, let’s address credentials. My homelab uses Ansible Vault for any secret that needs to be in the repo:
# Encrypt a variable file
$ ansible-vault encrypt ansible/inventory/group_vars/all/vault.yml
# View encrypted contents
$ ansible-vault view ansible/inventory/group_vars/all/vault.yml
# Edit in-place (decrypts, opens editor, re-encrypts on save)
$ ansible-vault edit ansible/inventory/group_vars/all/vault.yml
The vault password file lives at <vault-password-file> with mode 0600:
$ ls -l <vault-password-file>
-rw------- 1 <user> <user> 33 Feb 15 18:43 /home/<user>/<vault-password-file>
And it’s in .gitignore:
.vault_pass
This is defense in depth: the vault password never enters the repo. Even if the repo is compromised, the encrypted secrets are useless without the password file that lives only on the Ansible control node.
Testing Sudoers Changes Safely
Before rolling out a sudoers change to the whole fleet, test it on a single host:
# Dry run against one host
$ ansible-playbook playbooks/security.yml --limit dev --check --diff
# Apply to one host
$ ansible-playbook playbooks/security.yml --limit dev
# Verify
$ ssh dev 'sudo -l'
# If it works, roll out to all
$ ansible-playbook playbooks/security.yml
The --check --diff flags are your friends. --check does a dry run (no changes applied), and --diff shows what would change. For sudoers files, this tells you exactly what rule would be added, modified, or removed — without touching the live system.
Recovery: When Sudo Breaks
What if you deploy a bad sudoers rule and lose sudo access? This is where having the Proxmox escape hatch matters:
# From the Proxmox host, fix the broken container
$ pct exec 105 -- bash -c 'visudo -cf /etc/sudoers.d/<user> || rm /etc/sudoers.d/<user>'
# Or just rewrite it
$ pct exec 105 -- bash -c 'echo "<user> ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/<user> && chmod 440 /etc/sudoers.d/<user>'
pct exec bypasses SSH and sudo entirely. It’s root inside the container by definition. This is your break-glass procedure, and knowing it exists lets you experiment with sudoers more confidently.
For non-Proxmox environments (bare metal, VMs without hypervisor console access), the equivalent is:
- Physical console access: Boot into single-user mode or rescue mode
- VM console: Use the hypervisor’s console feature (libvirt
virsh console, VMware VMRC, etc.) - Cloud instances: Use the cloud provider’s serial console or instance metadata to inject a recovery script
Always have a path to root that doesn’t depend on sudo.
Bringing It All Together
Here’s what our automated sudoers management gives us:
- Consistency: Every host has the same sudoers policy, deployed from the same code
- Safety:
visudo -cfvalidation prevents deploying broken rules - Auditability:
git logshows every change to sudoers policy, who made it, and why - Recovery: Proxmox
pct execas the break-glass path if something goes wrong - Idempotency: Running the playbook twice produces the same result — no drift, no surprises
The manual approach — SSH into each host, visudo, type the rule, exit — works when you have two machines. It doesn’t scale to seven, and it leaves no audit trail. Ansible solves both problems.
In the final post, we’ll look at the mistakes — the anti-patterns I’ve seen (and made) over 26 years of Linux administration, and how to avoid them in your homelab.