Ansible Course
User and Permission Management
In this lesson
User and permission management is the practice of using Ansible to create, modify, and remove user accounts, groups, SSH keys, and file access controls in a consistent, auditable, and repeatable way. Managing access manually — adding users by logging into each server, editing sudoers by hand, copying SSH keys one at a time — is error-prone, unauditable, and impossible to scale. Ansible's user, group, and authorized_key modules make access provisioning as reliable and version-controlled as any other infrastructure change, and they ensure that every server in a fleet has exactly the right users with exactly the right permissions at all times.
The user Module
The
ansible.builtin.user
module creates, modifies, or removes user accounts. It is fully idempotent — running it
on a host where the user already exists with the correct settings reports ok
and makes no changes.
Key parameters
name
The login name
of the user account. Required.
state
present
(default) creates or updates the user. absent removes the user and,
with remove: true, their home directory and mail spool.
groups
Supplementary groups
to add the user to. Always pair with append: true to avoid removing
the user from existing groups not listed here.
append
When true,
adds the specified groups to the user's existing groups rather than replacing them.
Always set this to true unless you explicitly intend
to replace all supplementary groups.
shell
Login shell path.
Use /bin/bash for interactive users, /bin/false or
/usr/sbin/nologin for service accounts that should never have
interactive shell access.
create_home
Whether to create
a home directory. Default true. Set to false for
system service accounts that do not need a home directory.
system
When true,
creates a system account with a UID below the system's normal user threshold
(UID_MIN). Use for service accounts running daemons — they appear
in /etc/passwd but not in user-facing account listings.
- name: Create deploy user for application deployments
ansible.builtin.user:
name: deploy
comment: "Application deploy user"
shell: /bin/bash
groups: sudo
append: true # add to sudo — do not replace existing groups
create_home: true
state: present
- name: Create nginx service account (no shell, no home)
ansible.builtin.user:
name: nginx
system: true # system account — low UID, not listed in users
shell: /usr/sbin/nologin # no interactive login
create_home: false
state: present
- name: Remove a decommissioned user and their home directory
ansible.builtin.user:
name: oldemployee
state: absent
remove: true # delete home dir and mail spool
# Loop over a list of users to provision multiple accounts
- name: Provision all application team members
ansible.builtin.user:
name: "{{ item.name }}"
shell: "{{ item.shell | default('/bin/bash') }}"
groups: "{{ item.groups | default('') }}"
append: true
state: present
loop: "{{ app_users }}" # defined in group_vars
The HR Database Analogy
Managing users with Ansible is like maintaining
a company's HR database rather than issuing access cards by hand. The database (your
group_vars user list) is the source of truth — everyone on the list has
access, everyone not on the list does not. When someone joins, you add them to the
list and re-run the playbook. When someone leaves, you remove them and re-run. Every
server in the fleet reflects the current HR database automatically, with a full audit
trail in Git.
The group Module
The
ansible.builtin.group
module creates, modifies, or removes system groups. In most playbooks, group creation
comes before user creation — you cannot add a user to a group that does not exist yet.
# Create application groups before creating users that belong to them
- name: Create application groups
ansible.builtin.group:
name: "{{ item.name }}"
gid: "{{ item.gid | default(omit) }}" # omit = let the OS assign a GID
system: "{{ item.system | default(false) }}"
state: present
loop:
- { name: appusers, gid: 2000 }
- { name: deployers, gid: 2001 }
- { name: monitors, system: true } # system group for monitoring agent
- name: Remove a decommissioned group
ansible.builtin.group:
name: oldteam
state: absent
Managing SSH Keys with authorized_key
The
ansible.posix.authorized_key
module manages SSH public keys in a user's ~/.ssh/authorized_keys file.
It is idempotent — adding a key that is already present reports ok.
This module is the correct way to provision SSH access for users across a fleet;
manually copying keys is error-prone and leaves no audit trail.
# Add a single key from a string variable
- name: Authorise deploy user SSH key
ansible.posix.authorized_key:
user: deploy
key: "{{ deploy_ssh_public_key }}" # stored in group_vars or Vault
state: present
# Add a key from a file on the control node
- name: Authorise ops team SSH key
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', 'files/keys/ops_team.pub') }}"
state: present
# Provision multiple team members' keys in one task
- name: Authorise all developer SSH keys
ansible.posix.authorized_key:
user: "{{ item.username }}"
key: "{{ item.public_key }}"
state: present
loop: "{{ developer_accounts }}" # list of {username, public_key} dicts
# Remove a key — e.g. when an employee leaves
- name: Revoke access for departed team member
ansible.posix.authorized_key:
user: deploy
key: "{{ departed_employee_public_key }}"
state: absent # removes the key from authorized_keys
# Exclusive mode — replace ALL keys with exactly this set
# Use with extreme caution — removes any key not in the list
- name: Set authorised keys exclusively (replaces all existing)
ansible.posix.authorized_key:
user: deploy
key: "{{ item }}"
state: present
exclusive: true
loop: "{{ authorised_keys }}"
TASK [Authorise all developer SSH keys] ***************************************
changed: [web01.example.com] => (item={'username': 'alice', 'public_key': 'ssh-ed25519 AAAA...'})
ok: [web01.example.com] => (item={'username': 'bob', 'public_key': 'ssh-ed25519 BBBB...'})
changed: [web01.example.com] => (item={'username': 'carol', 'public_key': 'ssh-ed25519 CCCC...'})
# Alice and Carol's keys were new (changed). Bob's key was already present (ok).What just happened?
Each loop iteration independently checked whether
the key was already in authorized_keys. Alice and Carol's keys were new
and were added (changed). Bob's key was already authorised, so nothing
changed (ok). This per-key idempotency means running this task after
onboarding a new developer only adds their key — it does not touch anyone else's.
File Permissions at Scale
File permissions in Ansible are managed
through the mode, owner, and group parameters
on the file, copy, and template modules. Setting
permissions correctly — and consistently across your fleet — is a security requirement,
not just a best practice.
0644 — readable, owner writable
Standard for non-sensitive config files — Nginx configs, application settings, cron files. World-readable, only owner can write.
0600 — owner only
Passwords, API keys, private keys, database credentials. Only the owner can read or write. Used for SSH private keys and application secret files.
0755 — world traversable
Web roots, application directories, shared data. Everyone can traverse and read; only owner can create or delete files.
0755 — world executable
Scripts, binaries, init scripts. Owner can read/write/execute; everyone else can read and execute but not modify.
# Recursive permission fix — set all files in a directory tree
- name: Fix permissions on application directory
ansible.builtin.file:
path: /var/www/app
owner: "{{ app_user }}"
group: "{{ app_group }}"
mode: "0755"
recurse: true # apply to all files and subdirectories recursively
# Set ACL to give the web server read access to the app's shared files
- name: Grant www-data read access to shared config via ACL
ansible.posix.acl:
path: /var/www/app/shared/config
entity: www-data
etype: user
permissions: rx
state: present
recursive: true
Sudoers Management
Granting and revoking sudo access through
Ansible keeps privilege escalation auditable and consistent. The correct approach is
using lineinfile
or — better for complex policies — dedicated drop-in files in
/etc/sudoers.d/. Always validate before writing.
# Best practice: deploy a validated drop-in file to /etc/sudoers.d/
# Safer than editing /etc/sudoers directly — one bad file does not break all of sudo
- name: Deploy deploy-user sudoers policy
ansible.builtin.copy:
content: |
# Managed by Ansible — do not edit manually
# Allows deploy user to restart application services without a password
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp, \
/usr/bin/systemctl reload nginx, \
/usr/bin/systemctl status *
dest: /etc/sudoers.d/deploy
owner: root
group: root
mode: "0440" # sudoers files must be 0440 — world-unreadable
validate: "visudo -cf %s" # validate BEFORE writing — prevents lockout
# Remove a sudoers policy when access is revoked
- name: Remove departed team member sudoers file
ansible.builtin.file:
path: /etc/sudoers.d/oldemployee
state: absent
What just happened?
Deploying a drop-in file to
/etc/sudoers.d/ is safer than editing /etc/sudoers
directly because a syntax error in a drop-in file only breaks access to that
file's policies — not the entire sudo system. The validate parameter
runs visudo -cf %s against a temporary copy of the file before
writing it to disk. If validation fails, the original file remains untouched and
the task reports failed. Your users retain sudo access.
Full Scenario: User Lifecycle Playbook
The scenario: An operations team manages access for a growing engineering organisation. Their policy: every engineer has a personal account on every server, with SSH key authentication, no shared accounts, sudo access only for senior engineers, and immediate access revocation when someone leaves. All of this is driven from a single variable file committed to Git.
Variable file — group_vars/all.yml
---
engineering_users:
- name: alice
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJx... alice@laptop"
groups: sudo # senior engineer — gets sudo
state: present
- name: bob
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKq... bob@laptop"
groups: "" # junior engineer — no sudo
state: present
- name: charlie
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMn... charlie@laptop"
groups: ""
state: absent # left the company — revoke access
The playbook — users.yml
---
- name: Manage engineering user accounts across the fleet
hosts: all
become: true
tasks:
- name: Create or remove user accounts
ansible.builtin.user:
name: "{{ item.name }}"
shell: /bin/bash
groups: "{{ item.groups }}"
append: true
state: "{{ item.state }}"
remove: "{{ item.state == 'absent' }}" # delete home dir when removing
loop: "{{ engineering_users }}"
loop_control:
label: "{{ item.name }} ({{ item.state }})"
- name: Manage SSH authorised keys
ansible.posix.authorized_key:
user: "{{ item.name }}"
key: "{{ item.public_key }}"
state: "{{ item.state }}"
loop: "{{ engineering_users }}"
loop_control:
label: "{{ item.name }}"
# When state=absent, this removes the key from authorized_keys
# even if the user account still briefly exists during teardown
PLAY [Manage engineering user accounts across the fleet] ********************* TASK [Create or remove user accounts] *************************************** ok: [web01] => (item=alice (present)) <-- already exists, no change ok: [web01] => (item=bob (present)) <-- already exists, no change changed: [web01] => (item=charlie (absent)) <-- account removed TASK [Manage SSH authorised keys] ******************************************* ok: [web01] => (item=alice) <-- key already in authorized_keys ok: [web01] => (item=bob) <-- key already in authorized_keys changed: [web01] => (item=charlie) <-- key removed from authorized_keys PLAY RECAP ****************************************************************** web01 : ok=2 changed=2 unreachable=0 failed=0 skipped=0
Never Use append: false When Adding a User to Groups
The user module's
default for append is false. This means if you specify
groups: sudo without append: true, Ansible replaces the
user's entire supplementary group membership with just sudo —
potentially removing them from other groups like docker,
www-data, or adm that were added by other playbooks or
system packages. Always set append: true unless you explicitly want
to replace all supplementary groups with exactly the ones listed.
Key Takeaways
append: true when adding users to groups
— the default is false, which replaces all supplementary groups
with only those you specify and silently removes any others.
shell: /usr/sbin/nologin and
system: true — this prevents interactive login, assigns a
low UID, and clearly marks the account as a daemon rather than a human user.
group_vars, not playbooks
— keeping keys in variable files makes it easy to add or revoke access by
editing one place and re-running the playbook across the whole fleet.
/etc/sudoers.d/
— always with mode: "0440" and validate: "visudo -cf %s"
to prevent syntax errors from locking users out of sudo.
state: absent for a departing
engineer and re-running the playbook revokes their access on every server
in the fleet simultaneously.
Teacher's Note
Build the user lifecycle playbook from this lesson for your own team — even if it is just two or three people. The moment you use it to onboard someone new and watch their SSH key appear on every server in seconds, the value of managing access as code becomes immediately, permanently obvious.
Practice Questions
1. When using
ansible.builtin.user to add a user to the docker group
without removing them from any groups they already belong to, which parameter
and value must you include?
2. What file permission mode must be
set on files in /etc/sudoers.d/ for sudo to accept them?
3. Which Ansible module manages SSH
public keys in a user's authorized_keys file?
Quiz
1. You run a user task that specifies
groups: sudo but does not include append: true. The user
was previously in the docker and www-data groups. What
happens?
2. What is the safest way to grant a user specific sudo permissions across your fleet using Ansible?
3. An engineer leaves the company. Using the user lifecycle pattern from this lesson, what is the correct Ansible approach to revoke their access across the entire fleet?
Up Next · Lesson 21
Error Handling
Learn to write playbooks that handle failures gracefully — rescuing from errors, running cleanup tasks on failure, and building automation that is resilient to the unexpected.