Linux access management spans groups, user accounts, SSH keys, password hashes, and sudo policy. Ansible automates that workflow with idempotent modules: create the group first, create the user with UID, GID, and shell settings, add keys with ansible.posix.authorized_key, protect password hashes with Ansible Vault, and manage sudo with validated files under /etc/sudoers.d/.
This guide is a practical Rocky Linux 10 walkthrough—not LDAP/IdM integration, not full SSH hardening, and not a deep sudoers policy design course. It assumes you can run playbooks with become from the EX294 lab.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16;
ansible.posix2.1.0;community.general9.5.2.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
ansible.builtin.user, ansible.builtin.group, SSH keys, Vault-backed password hashes, and sudoers validation. It does not cover FreeIPA/LDAP, full SSH server hardening, or plaintext passwords in playbooks.
| Task | Module |
|---|---|
| Create group | ansible.builtin.group |
| Create user | ansible.builtin.user |
| Set UID, GID, shell, home | ansible.builtin.user |
| Add SSH public key | ansible.posix.authorized_key |
Create .ssh permissions |
ansible.builtin.file |
| Store password hash securely | Ansible Vault variable |
| Create sudoers drop-in | ansible.builtin.copy or ansible.builtin.template with validate |
| Manage sudo rule directly | community.general.sudoers |
What This Article Covers
ansible.builtin.groupandansible.builtin.useron Rocky Linux 10- Fixed UID/GID, supplementary groups, shell, home directory, account lock state, and
expires ansible.posix.authorized_keyfor SSH public keys;generate_ssh_keyfor outbound key pairs- Encrypted password hashes and Vault-backed variables—never plaintext passwords
- Sudo via
/etc/sudoers.d/withvisudovalidation orcommunity.general.sudoers - Verification with
id,getent,passwd -S, andsudo -l
Not covered: useradd theory, cross-distro sudo group names, or the full Vault layout beyond password hashes.
Why Manage Users and sudo with Ansible?
Manual useradd / visudo |
Ansible modules |
|---|---|
| Drift between hosts | Same playbook reapplies desired state |
| Easy to skip validation | validate: visudo -cf %s on sudoers files |
| Secrets in shell history | Vault variables for password hashes |
| No check-mode preview | --check on supported tasks where useful |
User and group modules ship with ansible-core. SSH keys need ansible.posix; optional community.general.sudoers manages individual rules without hand-editing a drop-in file. Once accounts exist, manage services and scheduled jobs on those same hosts for agents, cron, and systemd units.
Recommended Workflow for User Access Management
| Step | Action | Module / tool |
|---|---|---|
| 1 | Create groups (GID if required) | ansible.builtin.group |
| 2 | Create users (UID, primary group, shell, home) | ansible.builtin.user |
| 3 | Add supplementary groups (for example wheel) |
ansible.builtin.user with groups |
| 4 | Set encrypted password hash (from Vault) | ansible.builtin.user password |
| 5 | Ensure .ssh permissions |
ansible.builtin.file |
| 6 | Install SSH public keys | ansible.posix.authorized_key |
| 7 | Add scoped sudo rules | copy/template + visudo or community.general.sudoers |
| 8 | Verify with id, getent, sudo -l |
ad-hoc command or ansible.builtin.command |
Create groups before users so the primary GID exists. Add SSH keys after the home directory exists.
Prerequisites on Rocky Linux
From the control node project directory:
cd ~/ansible-projectConfirm the managed host is reachable:
ansible -i inventory/hosts lab -m ansible.builtin.pingSample output:
rocky2 | SUCCESS => {
"changed": false,
"ping": "pong"
}Install collections from a pinned requirements.yml beside your playbooks:
---
collections:
- name: ansible.posix
version: "2.1.0"
- name: community.general
version: "9.5.2"ansible-galaxy collection install -r requirements.ymlPin versions to match your tested ansible-core—newer collection releases may require a newer core than the lab used here.
Plays that change users, groups, or sudoers need become: true. The lab ansible.cfg may already set become = True globally.
Create Groups with ansible.builtin.group
Define the group before any user references it as a primary or supplementary group:
- name: Create devops group
ansible.builtin.group:
name: devops
gid: 2200
state: presentVerify on the managed host:
ansible rocky2 -m ansible.builtin.command -a "getent group devops" -bSample output:
rocky2 | CHANGED | rc=0 >>
devops:x:2200:Use state: absent to remove an empty group when decommissioning accounts.
Create Users with ansible.builtin.user
The user module wraps useradd/usermod with idempotent state: present:
- name: Create demoapp user
ansible.builtin.user:
name: demoapp
uid: 2201
group: devops
shell: /bin/bash
home: /home/demoapp
create_home: true
state: presentAfter the play runs, confirm UID and primary group:
ansible rocky2 -m ansible.builtin.command -a "id demoapp" -bSample output:
rocky2 | CHANGED | rc=0 >>
uid=2201(demoapp) gid=2200(devops) groups=2200(devops),10(wheel)Set UID, Primary Group and Supplementary Groups
Set uid and group (primary GID name) when you need stable IDs across hosts—for example NFS or application ownership.
Add supplementary groups with groups and append: true so Ansible does not remove existing memberships:
- name: Create demoapp user with wheel access
ansible.builtin.user:
name: demoapp
uid: 2201
group: devops
groups: wheel
append: true
shell: /bin/bash
home: /home/demoapp
create_home: true
state: presentOn Rocky Linux, wheel is the common administrative group used by sudoers policy. Membership grants sudo only when the matching %wheel rule is enabled in /etc/sudoers or a file under /etc/sudoers.d/. Scope application-specific sudo in drop-in files where possible instead of relying on broad NOPASSWD: ALL rules.
Configure Shell, Home Directory and Account State
| Parameter | Use |
|---|---|
shell |
Login shell (/bin/bash, /sbin/nologin) |
home |
Home directory path |
create_home |
Create home when user is new |
password_lock |
Lock (true) or unlock (false) password login |
state: absent |
Remove account (remove: true deletes home—use carefully) |
Lock an account without deleting it:
- name: Lock demoapp login
ansible.builtin.user:
name: demoapp
password_lock: trueansible rocky2 -m ansible.builtin.command -a "passwd -S demoapp" -bSample output:
rocky2 | CHANGED | rc=0 >>
demoapp L 2026-07-09 0 99999 7 -1The L status means the password is locked.
Set Account Expiry for Temporary Accounts
The expires parameter sets account expiration as an epoch timestamp on GNU/Linux. Use it for contractors or short-lived logins, and document the human-readable date next to the variable in your inventory or role defaults:
# contractor_expires_human: "2027-01-01"
- name: Create temporary contractor account
ansible.builtin.user:
name: contractor1
expires: 1798848000
shell: /bin/bash
create_home: true
state: presentOn supported systems, a negative expires value can remove an expiration date. Verify behavior on your target OS before relying on it in production plays.
Manage SSH Authorized Keys
Use ansible.posix.authorized_key to manage lines in ~/.ssh/authorized_keys:
- name: Ensure .ssh directory exists
ansible.builtin.file:
path: /home/demoapp/.ssh
state: directory
owner: demoapp
group: devops
mode: "0700"
- name: Add SSH public key for demoapp
ansible.posix.authorized_key:
user: demoapp
state: present
key: "{{ lookup('file', 'files/demo_user_key.pub') }}"
manage_dir: falseSet manage_dir: true (default) if you want the module to create .ssh with safe permissions—or create the directory explicitly as above for clarity.
Verify permissions:
ansible rocky2 -m ansible.builtin.command -a "ls -la /home/demoapp/.ssh/authorized_keys" -bSample output:
rocky2 | CHANGED | rc=0 >>
-rw-------. 1 demoapp devops 96 Jul 9 14:10 /home/demoapp/.ssh/authorized_keysUse mode 0700 for .ssh and 0600 for authorized_keys. Wrong permissions are a common SSH login failure.
Be careful with exclusive: true. It removes keys not managed by the current task, and it is not loop-aware. If you need exclusive key management, pass all desired keys together in one key: value, separated by newlines, instead of looping one key at a time.
Generate SSH Key Pair for a User
Use generate_ssh_key when the managed host needs a local private/public key pair for outbound SSH (for example Git or bastion hops):
- name: Generate SSH key pair for demoapp
ansible.builtin.user:
name: demoapp
generate_ssh_key: true
ssh_key_type: ed25519
ssh_key_file: .ssh/id_ed25519For login access to this account, use ansible.posix.authorized_key to install your control node's or user's public key in authorized_keys.
Set Encrypted Passwords for Users
The password parameter expects a hash, not plaintext. The ansible.builtin.user module expects a hashed password on Linux targets.
Generate a SHA-512 hash interactively on the control node—type or paste the password when openssl waits on stdin; it does not appear in the command line:
openssl passwd -6 -stdinIn a shell script or local terminal, you can prompt without echoing the password:
read -s -p "Password: " PASSprintf '\n'printf '%s' "$PASS" | openssl passwd -6 -stdinunset PASSSample output:
$6$oamGwG2OEGL23DQB$oSIKHyUsIqC7413gWAwSgmMZEUdMqbraqbV5JOziCfJxokK1NumgQQntIAgIoAI/DyQCNQDJQIGrF/BlToL/V/You can also build a hash in a play with the Jinja2 password_hash filter when the plaintext already lives in a Vault variable—never echo plaintext passwords in Ansible task arguments or CI logs. If you generate hashes inside a playbook with password_hash, use a stable salt or store the final hash in Vault. Otherwise a newly generated salt can produce a different hash and make password tasks appear changed unexpectedly.
Do not generate hashes by placing real passwords directly in shell history, CI logs, or Ansible task arguments. Generate the hash securely, then store only the resulting hash in Ansible Vault.
Pass the hash to the user module:
- name: Create demoapp user with password hash
ansible.builtin.user:
name: demoapp
password: "{{ demo_password_hash }}"
update_password: on_createupdate_password: on_create sets the hash only when the account is new—reruns do not reset passwords on every play. Use always only when you intend to rotate the hash each run.
Store Password Hashes and Secrets with Ansible Vault
Do not commit password hashes in plain YAML. Encrypt the variable with Vault:
ansible-vault encrypt_string --name demo_password_hash '$6$...' --vault-password-file /path/to/vault-passPaste the vaulted block into group vars or the play vars: section, then run the playbook with --vault-password-file or --ask-vault-pass. Full encrypt/edit workflows are in Ansible Vault.
Lock, Unlock and Remove User Accounts
| Goal | Task |
|---|---|
| Lock login | password_lock: true |
| Unlock login | password_lock: false |
| Remove user, keep home | state: absent (default remove: false) |
| Remove user and home | state: absent with remove: true |
Before removing accounts, check file ownership (find / -user demoapp), cron jobs, and running services. Deleting a user without planning cleanup can leave orphaned resources.
Manage sudo Access Safely
Prefer drop-in files under /etc/sudoers.d/ instead of editing /etc/sudoers directly. Each file should be mode 0440 and owned by root.
On Rocky Linux, members of wheel may already have sudo through a %wheel rule in /etc/sudoers. Still add scoped rules for application users instead of NOPASSWD: ALL unless policy requires it.
Create sudoers Files with Validation
Use ansible.builtin.copy with validate so visudo checks syntax before Ansible installs the file:
- name: Install validated sudoers drop-in
ansible.builtin.copy:
src: files/demoapp-sudoers
dest: /etc/sudoers.d/demoapp
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %sExample files/demoapp-sudoers:
# Managed by Ansible - demoapp limited sudo
demoapp ALL=(root) NOPASSWD: /usr/bin/systemctl status httpdThe play reports changed when the drop-in is installed. Invalid content fails at validation time instead of breaking sudo on the host.
Manage sudo Rules with community.general.sudoers
Alternatively, manage one rule per task:
- name: Allow demoapp to check httpd status without password
community.general.sudoers:
name: demoapp-httpd-status
state: present
user: demoapp
commands: /usr/bin/systemctl status httpd
nopassword: true
validation: requiredUse state: absent and the same name to remove the rule. The parameter is nopassword, not nopasswd. validation: required runs visudo before applying the rule.
Verify Users, Groups, SSH Keys and sudo Access
After the play, confirm state on the managed host:
ansible rocky2 -m ansible.builtin.command -a "getent group devops" -bansible rocky2 -m ansible.builtin.command -a "id demoapp" -bansible rocky2 -m ansible.builtin.command -a "sudo -l -U demoapp" -bSample output:
rocky2 | CHANGED | rc=0 >>
User demoapp may run the following commands on rocky2:
(root) NOPASSWD: /usr/bin/systemctl status httpdRerun the playbook to confirm idempotency—second runs should show ok and changed=0 for user, group, and key tasks.
Ad-hoc command verification shows CHANGED because the command module cannot infer read-only intent. In playbooks, add changed_when: false on verification tasks:
- name: Verify demoapp account
ansible.builtin.command: id demoapp
register: demoapp_id
changed_when: falseCommon Examples
Create a Linux group and user
- name: Create devops group
ansible.builtin.group:
name: devops
state: present
- name: Create demoapp user
ansible.builtin.user:
name: demoapp
group: devops
shell: /bin/bash
create_home: true
state: presentCreate a user with fixed UID and GID
- name: Create devops group with fixed GID
ansible.builtin.group:
name: devops
gid: 2200
state: present
- name: Create demoapp with fixed UID
ansible.builtin.user:
name: demoapp
uid: 2201
group: devops
state: presentAdd SSH public key for a user
- name: Add SSH public key
ansible.posix.authorized_key:
user: demoapp
key: "{{ lookup('file', 'files/demo_user_key.pub') }}"
state: presentAdd a user to wheel group
- name: Add demoapp to wheel
ansible.builtin.user:
name: demoapp
groups: wheel
append: trueSet an encrypted password from Vault
vars:
demo_password_hash: !vault |
$ANSIBLE_VAULT;1.1;AES256
...
tasks:
- name: Set password on create
ansible.builtin.user:
name: demoapp
password: "{{ demo_password_hash }}"
update_password: on_createCreate a sudoers file with visudo validation
- name: Install sudoers drop-in
ansible.builtin.copy:
src: files/demoapp-sudoers
dest: /etc/sudoers.d/demoapp
mode: "0440"
validate: /usr/sbin/visudo -cf %sRemove or lock an old user account
- name: Lock former employee account
ansible.builtin.user:
name: olduser
password_lock: true
- name: Remove decommissioned account
ansible.builtin.user:
name: olduser
state: absent
remove: falseCommon Mistakes
| Mistake | Fix |
|---|---|
Using exclusive: true in a loop over keys |
Earlier keys are removed; pass all keys in one key: string |
Passing plaintext password to user.password |
Use an encrypted password hash |
| Committing password hashes in plain YAML | Store sensitive values with Vault |
Forgetting become: true |
User, group, and sudo tasks need privilege |
| Adding user before group exists | Create groups first |
Wrong .ssh permissions |
Use 0700 for .ssh, 0600 for authorized_keys |
Editing /etc/sudoers without validation |
Use /etc/sudoers.d/ and visudo -cf %s |
Giving broad NOPASSWD: ALL unnecessarily |
Scope sudo rules to required commands |
| Removing users without checking ownership | Audit files, services, and cron before state: absent |
Best Practices
| Practice | Why |
|---|---|
Pin collection versions in requirements.yml |
Matches tested ansible-core and avoids surprise upgrades |
| Create groups before users | Primary and supplementary GIDs must exist |
| Pin UID/GID when IDs must match across hosts | Avoids drift on cloned VMs |
| Store password hashes in Vault | Keeps secrets out of git history |
Use update_password: on_create unless rotating |
Prevents resetting passwords every run |
Validate sudoers files with visudo |
Broken sudo locks out administrators |
| Scope sudo rules per user or command | Reduces blast radius |
Verify with id, getent, and sudo -l |
Confirms play result on the host |
Use FQCN (ansible.posix.authorized_key) |
Clear collection dependency in shared repos |
Summary
Manage Rocky Linux access in one Ansible workflow: ansible.builtin.group first, then ansible.builtin.user with UID, GID, shell, and supplementary groups such as wheel. Add SSH keys with ansible.posix.authorized_key, store password hashes in Vault, and configure sudo with validated /etc/sudoers.d/ files or community.general.sudoers. Verify with id, getent, and sudo -l, then rely on idempotent reruns to keep hosts aligned.
References
- ansible.builtin.user — user account management
- ansible.builtin.group — group management
- ansible.posix.authorized_key — SSH authorized keys
- community.general.sudoers — sudo rule management
- Ansible Vault — encrypt strings and files
- Privilege escalation — become and sudo

