Automation

Manage Users and Groups on Linux with Ansible

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.

Original content from computingforgeeks.com - post 56384

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.

ParameterDescriptionExample
nameUsername to create or manage (required)name: devops
stateWhether the user should exist – present or absentstate: absent
passwordHashed password (use password_hash filter)password: "{{ pass | password_hash('sha512') }}"
update_passwordalways resets every run, on_create sets only on first creationupdate_password: on_create
uidNumeric user IDuid: 1050
groupPrimary group namegroup: operations
groupsList of supplementary groupsgroups: [developers, docker]
appendAdd to groups without removing existing membershipsappend: yes
shellLogin shellshell: /bin/bash
commentGECOS field – full name or descriptioncomment: "Jane Doe"
create_homeWhether to create a home directorycreate_home: no
homeSet a custom home directory pathhome: /opt/appuser
systemCreate a system account (low UID, no aging)system: yes
removeRemove home directory when state is absentremove: yes
forceForce removal even if user is logged inforce: yes
expiresAccount expiration as epoch timestampexpires: 1798761600
password_lockLock the password (prepend ! to hash)password_lock: yes
generate_ssh_keyGenerate an SSH keypair for the usergenerate_ssh_key: yes
ssh_key_bitsNumber of bits for generated SSH keyssh_key_bits: 4096
ssh_key_typeType of SSH key to generatessh_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.

Related Articles

Automation Install and Configure Ansible on Ubuntu / Debian / RHEL / Rocky Linux Automation Install and Configure Jira on Debian 13 / Ubuntu 24.04 Ansible Ansible check if software package is installed on Linux Fedora How To Install Terraform on Fedora 42

Leave a Comment

Press ESC to close