Manage Users, Groups, SSH Keys and sudo on Rocky Linux with Ansible

Use Ansible on Rocky Linux 10 to create users and groups with UID/GID, manage SSH authorized keys, generate SSH keys, set account expiry, store password hashes with Vault, and configure validated sudoers files safely.

Published

Updated

Read time 12 min read

Reviewed byDeepak Prasad

Manage Linux users, groups, SSH keys and sudo with Ansible on Rocky Linux 10

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.posix 2.1.0; community.general 9.5.2.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
IMPORTANT
This article covers local user and group management with 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.group and ansible.builtin.user on Rocky Linux 10
  • Fixed UID/GID, supplementary groups, shell, home directory, account lock state, and expires
  • ansible.posix.authorized_key for SSH public keys; generate_ssh_key for outbound key pairs
  • Encrypted password hashes and Vault-backed variables—never plaintext passwords
  • Sudo via /etc/sudoers.d/ with visudo validation or community.general.sudoers
  • Verification with id, getent, passwd -S, and sudo -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.


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:

bash
cd ~/ansible-project

Confirm the managed host is reachable:

bash
ansible -i inventory/hosts lab -m ansible.builtin.ping

Sample output:

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

Install collections from a pinned requirements.yml beside your playbooks:

yaml
---
collections:
  - name: ansible.posix
    version: "2.1.0"
  - name: community.general
    version: "9.5.2"
bash
ansible-galaxy collection install -r requirements.yml

Pin 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:

yaml
- name: Create devops group
  ansible.builtin.group:
    name: devops
    gid: 2200
    state: present

Verify on the managed host:

bash
ansible rocky2 -m ansible.builtin.command -a "getent group devops" -b

Sample output:

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:

yaml
- name: Create demoapp user
  ansible.builtin.user:
    name: demoapp
    uid: 2201
    group: devops
    shell: /bin/bash
    home: /home/demoapp
    create_home: true
    state: present

After the play runs, confirm UID and primary group:

bash
ansible rocky2 -m ansible.builtin.command -a "id demoapp" -b

Sample output:

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:

yaml
- 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: present

On 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:

yaml
- name: Lock demoapp login
  ansible.builtin.user:
    name: demoapp
    password_lock: true
bash
ansible rocky2 -m ansible.builtin.command -a "passwd -S demoapp" -b

Sample output:

output
rocky2 | CHANGED | rc=0 >>
demoapp L 2026-07-09 0 99999 7 -1

The 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:

yaml
# 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: present

On 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:

yaml
- 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: false

Set 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:

bash
ansible rocky2 -m ansible.builtin.command -a "ls -la /home/demoapp/.ssh/authorized_keys" -b

Sample output:

output
rocky2 | CHANGED | rc=0 >>
-rw-------. 1 demoapp devops 96 Jul  9 14:10 /home/demoapp/.ssh/authorized_keys

Use 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):

yaml
- 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_ed25519

For 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:

bash
openssl passwd -6 -stdin

In a shell script or local terminal, you can prompt without echoing the password:

bash
read -s -p "Password: " PASS
bash
printf '\n'
bash
printf '%s' "$PASS" | openssl passwd -6 -stdin
bash
unset PASS

Sample output:

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:

yaml
- name: Create demoapp user with password hash
  ansible.builtin.user:
    name: demoapp
    password: "{{ demo_password_hash }}"
    update_password: on_create

update_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:

bash
ansible-vault encrypt_string --name demo_password_hash '$6$...' --vault-password-file /path/to/vault-pass

Paste 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:

yaml
- 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 %s

Example files/demoapp-sudoers:

output
# Managed by Ansible - demoapp limited sudo
demoapp ALL=(root) NOPASSWD: /usr/bin/systemctl status httpd

The 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:

yaml
- 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: required

Use 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:

bash
ansible rocky2 -m ansible.builtin.command -a "getent group devops" -b
bash
ansible rocky2 -m ansible.builtin.command -a "id demoapp" -b
bash
ansible rocky2 -m ansible.builtin.command -a "sudo -l -U demoapp" -b

Sample output:

output
rocky2 | CHANGED | rc=0 >>
User demoapp may run the following commands on rocky2:
    (root) NOPASSWD: /usr/bin/systemctl status httpd

Rerun 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:

yaml
- name: Verify demoapp account
  ansible.builtin.command: id demoapp
  register: demoapp_id
  changed_when: false

Common Examples

Create a Linux group and user

yaml
- 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: present

Create a user with fixed UID and GID

yaml
- 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: present

Add SSH public key for a user

yaml
- name: Add SSH public key
  ansible.posix.authorized_key:
    user: demoapp
    key: "{{ lookup('file', 'files/demo_user_key.pub') }}"
    state: present

Add a user to wheel group

yaml
- name: Add demoapp to wheel
  ansible.builtin.user:
    name: demoapp
    groups: wheel
    append: true

Set an encrypted password from Vault

yaml
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_create

Create a sudoers file with visudo validation

yaml
- name: Install sudoers drop-in
  ansible.builtin.copy:
    src: files/demoapp-sudoers
    dest: /etc/sudoers.d/demoapp
    mode: "0440"
    validate: /usr/sbin/visudo -cf %s

Remove or lock an old user account

yaml
- 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: false

Common 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


Frequently Asked Questions

1. Do I need become for Ansible user and group tasks?

Yes. Creating users, groups, password hashes, SSH keys under /home, and sudoers files requires root privileges on the managed host. Set become: true on the play or task.

2. Should I pass a plaintext password to ansible.builtin.user?

No. Pass an encrypted password hash in the password parameter. Generate the hash with openssl passwd -6 or Ansible password_hash, then store the hash in Ansible Vault when the play is in git.

3. Which module adds SSH keys for a user?

Use ansible.posix.authorized_key from the ansible.posix collection. Ensure .ssh is mode 0700 and authorized_keys is 0600—authorized_key can manage the directory or you can create it with ansible.builtin.file first.

4. How do I validate sudoers files before applying them?

Place rules in /etc/sudoers.d/ and use ansible.builtin.copy or template with validate: /usr/sbin/visudo -cf %s so invalid syntax never lands on disk.

5. What is the wheel group on Rocky Linux?

On Rocky Linux and RHEL, wheel is the common administrative group used by sudoers policy. If the %wheel rule is enabled in /etc/sudoers, members of wheel can run sudo according to that policy.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …