Managing user accounts across dozens or hundreds of Linux servers by hand is a recipe for inconsistency and security gaps. Ansible’s ansible.builtin.user and ansible.builtin.group modules let you create, modify, and remove users and groups in a repeatable, auditable way – no manual SSH sessions required.
This guide covers every common user management task with Ansible: creating users, setting passwords securely, managing groups, deploying SSH keys, configuring sudo access, removing accounts, bulk provisioning from variable files, enforcing password policies, and building a complete role-based playbook. All examples use the fully qualified collection names (FQCN) and work on RHEL 10, Rocky Linux 10, AlmaLinux 10, Ubuntu 24.04, and Debian 13.
Prerequisites
Before you begin, make sure you have the following in place:
- Ansible 2.15+ installed on your control node – see our guide on automating tasks with Ansible if you need to get started
- SSH key-based access to your target servers (password auth works but keys are preferred)
- An inventory file listing your managed hosts
- Sudo or root privileges on the target servers (user management requires elevated access)
- Python 3 on the target servers (installed by default on all modern distros)
A minimal inventory file looks like this:
sudo vi /etc/ansible/hosts
Add your servers under a group:
[webservers]
192.168.1.10
192.168.1.11
[dbservers]
192.168.1.20
[all:vars]
ansible_user=admin
ansible_become=yes
Step 1: Create Users with the ansible.builtin.user Module
The ansible.builtin.user module handles all user account operations on Linux. At its simplest, you need only the name parameter to create an account.
Create a playbook to add a user:
vi create_user.yml
Add the following content:
---
- name: Create user accounts
hosts: all
become: yes
tasks:
- name: Create user devops with home directory
ansible.builtin.user:
name: devops
comment: "DevOps Engineer"
shell: /bin/bash
create_home: yes
state: present
- name: Create system user for application
ansible.builtin.user:
name: apprunner
system: yes
shell: /usr/sbin/nologin
create_home: no
comment: "Application service account"
Run the playbook:
ansible-playbook create_user.yml
A successful run shows “changed” for each new user created:
PLAY [Create user accounts] ******************************************************************
TASK [Gathering Facts] ***********************************************************************
ok: [192.168.1.10]
ok: [192.168.1.11]
TASK [Create user devops with home directory] ************************************************
changed: [192.168.1.10]
changed: [192.168.1.11]
TASK [Create system user for application] ****************************************************
changed: [192.168.1.10]
changed: [192.168.1.11]
PLAY RECAP ***********************************************************************************
192.168.1.10 : ok=3 changed=2 unreachable=0 failed=0
192.168.1.11 : ok=3 changed=2 unreachable=0 failed=0
Verify the user exists on your target servers:
ansible all -m command -a "id devops"
The output confirms the user account details – UID, GID, and group membership:
192.168.1.10 | CHANGED | rc=0 >>
uid=1001(devops) gid=1001(devops) groups=1001(devops)
192.168.1.11 | CHANGED | rc=0 >>
uid=1001(devops) gid=1001(devops) groups=1001(devops)
Step 2: Set Passwords with Secure Hashing
Ansible requires passwords in hashed format – you never pass plain text passwords to the user module. Use the password_hash filter to generate SHA-512 hashes, and store the actual password in Ansible Vault to keep it out of plain text files.
First, create a vault-encrypted variable file:
ansible-vault create vars/secrets.yml
Add the password variable inside the vault file:
user_password: "S3cureP@ssw0rd!"
Now reference that variable in your playbook:
vi set_password.yml
Add the following content:
---
- name: Set user passwords securely
hosts: all
become: yes
vars_files:
- vars/secrets.yml
tasks:
- name: Set password for devops user
ansible.builtin.user:
name: devops
password: "{{ user_password | password_hash('sha512', 'saltvalue') }}"
update_password: on_create
- name: Force password change on first login
ansible.builtin.command:
cmd: chage -d 0 devops
changed_when: true
Run it with the vault password prompt:
ansible-playbook set_password.yml --ask-vault-pass
The update_password: on_create setting ensures the password is only set when the user is first created. Subsequent playbook runs will not overwrite a password that the user may have changed. If you want to force a password reset every time, set update_password: always instead.
You can also generate a password hash on the command line for quick use:
ansible all -i localhost, -m debug -a "msg={{ 'MyPassword123' | password_hash('sha512', 'saltvalue') }}" -c local
This outputs the SHA-512 hash that you can paste directly into a playbook or variable file. See our detailed guide on generating encrypted passwords for Ansible for more methods.
Step 3: Manage Groups with Ansible
The ansible.builtin.group module creates and removes groups. You typically create groups first, then assign users to them.
vi manage_groups.yml
Add the following content:
---
- name: Manage groups and group membership
hosts: all
become: yes
tasks:
- name: Create developer group
ansible.builtin.group:
name: developers
state: present
gid: 2001
- name: Create operations group
ansible.builtin.group:
name: operations
state: present
- name: Add devops user to both groups
ansible.builtin.user:
name: devops
groups:
- developers
- operations
append: yes
- name: Create dbadmin user with primary group
ansible.builtin.user:
name: dbadmin
group: operations
groups:
- developers
append: yes
shell: /bin/bash
Run the playbook:
ansible-playbook manage_groups.yml
Verify group membership after the run:
ansible all -m command -a "id devops"
The output should show both supplementary groups:
192.168.1.10 | CHANGED | rc=0 >>
uid=1001(devops) gid=1001(devops) groups=1001(devops),2001(developers),2002(operations)
The append: yes parameter is critical. Without it, Ansible replaces all existing supplementary groups with only the ones you specify. Always set append: yes when adding users to additional groups to avoid accidentally removing them from groups they already belong to.
Step 4: Deploy SSH Authorized Keys
Deploying SSH public keys is one of the most common user management tasks. The ansible.builtin.authorized_key module handles this cleanly – it adds keys to a user’s ~/.ssh/authorized_keys file without disturbing existing keys.
vi deploy_ssh_keys.yml
Add the following content:
---
- name: Deploy SSH authorized keys
hosts: all
become: yes
tasks:
- name: Add SSH key for devops user
ansible.builtin.authorized_key:
user: devops
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxampleKeyHereReplacWithYourActualPublicKey devops@workstation"
state: present
exclusive: no
- name: Add multiple SSH keys from file
ansible.builtin.authorized_key:
user: dbadmin
key: "{{ lookup('file', 'files/dbadmin_keys.pub') }}"
state: present
- name: Generate SSH keypair for apprunner
ansible.builtin.user:
name: devops
generate_ssh_key: yes
ssh_key_bits: 4096
ssh_key_type: ed25519
ssh_key_file: .ssh/id_ed25519
ssh_key_comment: "devops@ansible-managed"
Run the playbook:
ansible-playbook deploy_ssh_keys.yml
Verify the keys are deployed:
ansible all -m command -a "ls -la /home/devops/.ssh/"
You should see the authorized_keys file along with any generated keypairs in the .ssh directory. The exclusive: no setting (the default) adds your key without removing existing keys. Set exclusive: yes if you want to enforce that only the specified keys are present – useful for strict access control.
Step 5: Configure Sudo Access
Granting sudo privileges through Ansible ensures consistent access policies across all servers. Drop a sudoers file into /etc/sudoers.d/ rather than editing the main sudoers file directly.
vi configure_sudo.yml
Add the following content:
---
- name: Configure sudo access
hosts: all
become: yes
tasks:
- name: Grant full sudo to devops user (no password)
ansible.builtin.copy:
content: "devops ALL=(ALL) NOPASSWD: ALL\n"
dest: /etc/sudoers.d/devops
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
- name: Grant sudo to developers group (with password)
ansible.builtin.copy:
content: "%developers ALL=(ALL) ALL\n"
dest: /etc/sudoers.d/developers
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
- name: Grant limited sudo - only restart services
ansible.builtin.copy:
content: "dbadmin ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart postgresql*, /usr/bin/systemctl status postgresql*\n"
dest: /etc/sudoers.d/dbadmin
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
Run the playbook:
ansible-playbook configure_sudo.yml
The validate: "visudo -cf %s" parameter is essential. It checks the syntax of the sudoers file before writing it. A malformed sudoers file can lock you out of sudo entirely, so always validate. The file permissions must be 0440 – sudoers rejects files with other permissions.
Verify the sudo configuration works:
ansible all -m command -a "sudo -l -U devops"
The output lists the commands the user is allowed to run with sudo:
192.168.1.10 | CHANGED | rc=0 >>
User devops may run the following commands on server:
(ALL) NOPASSWD: ALL
Step 6: Remove Users and Clean Up
When employees leave or accounts are no longer needed, Ansible removes users and all their associated files in one pass.
vi remove_users.yml
Add the following content:
---
- name: Remove users and clean up
hosts: all
become: yes
tasks:
- name: Kill all processes owned by the user
ansible.builtin.command:
cmd: "pkill -u apprunner"
register: pkill_result
failed_when: pkill_result.rc not in [0, 1]
changed_when: pkill_result.rc == 0
- name: Remove apprunner user and home directory
ansible.builtin.user:
name: apprunner
state: absent
remove: yes
force: yes
- name: Remove sudoers file for removed user
ansible.builtin.file:
path: /etc/sudoers.d/apprunner
state: absent
- name: Remove empty group after user deletion
ansible.builtin.group:
name: apprunner
state: absent
Run the playbook:
ansible-playbook remove_users.yml
The remove: yes parameter deletes the user’s home directory and mail spool. The force: yes parameter removes the user even if they are currently logged in. Always kill running processes first with pkill – removing a user while their processes are running can leave orphaned processes consuming resources.
Step 7: Bulk User Management from a Variables File
Managing individual user tasks does not scale when you have 20 or 50 users to provision. Define all users in a YAML variables file and loop through them.
Create the variables file:
vi vars/users.yml
Define your users as a list:
---
users:
- name: jsmith
comment: "John Smith"
groups: ["developers", "operations"]
shell: /bin/bash
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeyForJsmith [email protected]"
sudo: "jsmith ALL=(ALL) NOPASSWD: ALL"
- name: agarcia
comment: "Ana Garcia"
groups: ["developers"]
shell: /bin/bash
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeyForAgarcia [email protected]"
sudo: "%developers ALL=(ALL) ALL"
- name: mchen
comment: "Mike Chen"
groups: ["operations", "dbadmins"]
shell: /bin/bash
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeyForMchen [email protected]"
sudo: "mchen ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart *"
removed_users:
- jdoe
- bwilson
Create the playbook that uses these variables:
vi bulk_users.yml
Add the following content:
---
- name: Bulk user management
hosts: all
become: yes
vars_files:
- vars/users.yml
tasks:
- name: Create required groups
ansible.builtin.group:
name: "{{ item }}"
state: present
loop:
- developers
- operations
- dbadmins
- name: Create user accounts
ansible.builtin.user:
name: "{{ item.name }}"
comment: "{{ item.comment }}"
groups: "{{ item.groups }}"
shell: "{{ item.shell }}"
append: yes
state: present
loop: "{{ users }}"
- name: Deploy SSH keys
ansible.builtin.authorized_key:
user: "{{ item.name }}"
key: "{{ item.ssh_key }}"
state: present
loop: "{{ users }}"
- name: Configure sudo access
ansible.builtin.copy:
content: "{{ item.sudo }}\n"
dest: "/etc/sudoers.d/{{ item.name }}"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
loop: "{{ users }}"
when: item.sudo is defined
- name: Remove decommissioned users
ansible.builtin.user:
name: "{{ item }}"
state: absent
remove: yes
force: yes
loop: "{{ removed_users }}"
- name: Remove sudoers files for removed users
ansible.builtin.file:
path: "/etc/sudoers.d/{{ item }}"
state: absent
loop: "{{ removed_users }}"
Run the bulk provisioning:
ansible-playbook bulk_users.yml
This approach makes user management declarative. Add a new hire to vars/users.yml, move a departed employee to removed_users, and run the playbook. Every server converges to the desired state.
Step 8: Enforce Password Policies
Password expiration and aging policies prevent stale credentials from lingering on your servers. The ansible.builtin.user module handles account expiration, while the ansible.builtin.command module with chage sets password aging rules. For strong password complexity requirements, see our guide on setting strong password policies on Linux.
vi password_policy.yml
Add the following content:
---
- name: Enforce password policies
hosts: all
become: yes
tasks:
- name: Set password aging defaults for new users
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: '^PASS_MAX_DAYS', line: 'PASS_MAX_DAYS 90' }
- { regexp: '^PASS_MIN_DAYS', line: 'PASS_MIN_DAYS 7' }
- { regexp: '^PASS_WARN_AGE', line: 'PASS_WARN_AGE 14' }
- name: Set password aging for existing user devops
ansible.builtin.command:
cmd: "chage -M 90 -m 7 -W 14 devops"
changed_when: true
- name: Set account expiration date
ansible.builtin.user:
name: contractor01
expires: "{{ ('2026-12-31' | to_datetime('%Y-%m-%d')).strftime('%s') | float }}"
- name: Lock inactive user account
ansible.builtin.user:
name: inactive_user
password_lock: yes
- name: Install libpam-pwquality for password complexity (Debian/Ubuntu)
ansible.builtin.apt:
name: libpam-pwquality
state: present
when: ansible_os_family == "Debian"
- name: Install pam_pwquality for password complexity (RHEL/Rocky)
ansible.builtin.dnf:
name: pam_pwquality
state: present
when: ansible_os_family == "RedHat"
- name: Configure password complexity rules
ansible.builtin.lineinfile:
path: /etc/security/pwquality.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: '^# minlen', line: 'minlen = 12' }
- { regexp: '^# dcredit', line: 'dcredit = -1' }
- { regexp: '^# ucredit', line: 'ucredit = -1' }
- { regexp: '^# lcredit', line: 'lcredit = -1' }
- { regexp: '^# ocredit', line: 'ocredit = -1' }
Run the playbook:
ansible-playbook password_policy.yml
Verify the password aging settings applied to a user:
ansible all -m command -a "chage -l devops"
The output shows the password aging configuration:
192.168.1.10 | CHANGED | rc=0 >>
Last password change : Mar 22, 2026
Password expires : Jun 20, 2026
Password inactive : never
Account expires : never
Minimum number of days between password change : 7
Maximum number of days between password change : 90
Number of days of warning before password expires : 14
Step 9: Complete Playbook – Role-Based User Management
In production, structure your user management as an Ansible role for reuse across projects. This complete example combines everything covered above into a clean, maintainable role structure.
Create the role directory structure:
mkdir -p roles/user_management/{tasks,defaults,vars,files}
Define default variables:
vi roles/user_management/defaults/main.yml
Add the following defaults:
---
default_shell: /bin/bash
password_max_days: 90
password_min_days: 7
password_warn_age: 14
min_password_length: 12
users: []
removed_users: []
groups: []
Create the main task file:
vi roles/user_management/tasks/main.yml
Add all the user management tasks:
---
- name: Create groups
ansible.builtin.group:
name: "{{ item.name }}"
gid: "{{ item.gid | default(omit) }}"
system: "{{ item.system | default(false) }}"
state: present
loop: "{{ groups }}"
tags: [groups]
- name: Create user accounts
ansible.builtin.user:
name: "{{ item.name }}"
comment: "{{ item.comment | default(omit) }}"
uid: "{{ item.uid | default(omit) }}"
group: "{{ item.primary_group | default(omit) }}"
groups: "{{ item.groups | default(omit) }}"
append: yes
shell: "{{ item.shell | default(default_shell) }}"
create_home: "{{ item.create_home | default(true) }}"
system: "{{ item.system | default(false) }}"
password: "{{ item.password | default(omit) }}"
update_password: "{{ item.update_password | default('on_create') }}"
state: present
loop: "{{ users }}"
tags: [users]
- name: Deploy SSH authorized keys
ansible.builtin.authorized_key:
user: "{{ item.name }}"
key: "{{ item.ssh_key }}"
state: present
exclusive: "{{ item.ssh_exclusive | default(false) }}"
loop: "{{ users }}"
when: item.ssh_key is defined
tags: [ssh]
- name: Configure sudo access
ansible.builtin.copy:
content: "{{ item.sudo }}\n"
dest: "/etc/sudoers.d/{{ item.name }}"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
loop: "{{ users }}"
when: item.sudo is defined
tags: [sudo]
- name: Set password aging policies
ansible.builtin.command:
cmd: "chage -M {{ password_max_days }} -m {{ password_min_days }} -W {{ password_warn_age }} {{ item.name }}"
loop: "{{ users }}"
when: item.system | default(false) == false
changed_when: true
tags: [password-policy]
- name: Remove decommissioned users
ansible.builtin.user:
name: "{{ item }}"
state: absent
remove: yes
force: yes
loop: "{{ removed_users }}"
tags: [cleanup]
- name: Remove sudoers files for removed users
ansible.builtin.file:
path: "/etc/sudoers.d/{{ item }}"
state: absent
loop: "{{ removed_users }}"
tags: [cleanup]
- name: Remove groups that are no longer needed
ansible.builtin.group:
name: "{{ item }}"
state: absent
loop: "{{ removed_groups | default([]) }}"
tags: [cleanup]
Create a site playbook that uses the role:
vi site.yml
Add the following:
---
- name: Manage users across all servers
hosts: all
become: yes
roles:
- role: user_management
vars:
groups:
- { name: developers, gid: 2001 }
- { name: operations, gid: 2002 }
- { name: dbadmins, gid: 2003 }
users:
- name: jsmith
comment: "John Smith - Senior Developer"
groups: ["developers", "operations"]
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeyForJsmith [email protected]"
sudo: "jsmith ALL=(ALL) NOPASSWD: ALL"
- name: agarcia
comment: "Ana Garcia - Developer"
groups: ["developers"]
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeyForAgarcia [email protected]"
- name: mchen
comment: "Mike Chen - DBA"
groups: ["operations", "dbadmins"]
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKeyForMchen [email protected]"
sudo: "mchen ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart postgresql*"
- name: deploy
comment: "Deployment service account"
system: yes
shell: /usr/sbin/nologin
create_home: no
removed_users:
- jdoe
- bwilson
Run the complete role-based playbook:
ansible-playbook site.yml
You can also run specific tags to limit what gets executed:
ansible-playbook site.yml --tags ssh
This runs only the SSH key deployment tasks, skipping everything else. Useful when you need to quickly push a new key without touching user accounts or sudo configs.
Ansible User Module Parameter Reference
The ansible.builtin.user module supports many parameters. This table covers the most commonly used ones for day-to-day user management.
| Parameter | Description | Example |
|---|---|---|
name | Username to create or manage (required) | name: devops |
state | Whether the user should exist – present or absent | state: absent |
password | Hashed password (use password_hash filter) | password: "{{ pass | password_hash('sha512') }}" |
update_password | always resets every run, on_create sets only on first creation | update_password: on_create |
uid | Numeric user ID | uid: 1050 |
group | Primary group name | group: operations |
groups | List of supplementary groups | groups: [developers, docker] |
append | Add to groups without removing existing memberships | append: yes |
shell | Login shell | shell: /bin/bash |
comment | GECOS field – full name or description | comment: "Jane Doe" |
create_home | Whether to create a home directory | create_home: no |
home | Set a custom home directory path | home: /opt/appuser |
system | Create a system account (low UID, no aging) | system: yes |
remove | Remove home directory when state is absent | remove: yes |
force | Force removal even if user is logged in | force: yes |
expires | Account expiration as epoch timestamp | expires: 1798761600 |
password_lock | Lock the password (prepend ! to hash) | password_lock: yes |
generate_ssh_key | Generate an SSH keypair for the user | generate_ssh_key: yes |
ssh_key_bits | Number of bits for generated SSH key | ssh_key_bits: 4096 |
ssh_key_type | Type of SSH key to generate | ssh_key_type: ed25519 |
Conclusion
Ansible turns user management from a tedious, error-prone manual process into a repeatable, version-controlled operation. Every user account, group membership, SSH key, and sudo policy is defined in code and applied consistently across your entire fleet.
For production environments, store all passwords in Ansible Vault, use role-based playbook structures for maintainability, and keep your user variable files in Git so every change is tracked and auditable. Combine this with regular account audits and password rotation policies to maintain a strong security posture across your infrastructure.