Skip to content

Instantly share code, notes, and snippets.

@jniltinho
Last active September 23, 2026 16:50
Show Gist options
  • Select an option

  • Save jniltinho/0ef642a14d81a315d2f47f1953b44af0 to your computer and use it in GitHub Desktop.

Select an option

Save jniltinho/0ef642a14d81a315d2f47f1953b44af0 to your computer and use it in GitHub Desktop.
Linux users with Ansible: SSH keys, sudo and history in Git (linuxpro.com.br)
# secrets and private keys never go into the repository
.vault-pass*
id_*
*.pem
*.key
*.retry

Linux users with Ansible: SSH keys, sudo and history in Git

An Ansible project that declares people's accounts on your servers (name, public SSH key, groups, sudo and a state of active | locked | removed). Every new account, lock or removal is a Git commit, so git log becomes your audit trail.

Full article, with tests and real output on Ubuntu 24.04 and Debian 13 (in Brazilian Portuguese): https://www.linuxpro.com.br/2026/09/usuarios-linux-ansible-ssh-sudo-git/

A complete, tested version of this project, packaged as a reusable role with validation, integration tests and CI, lives at https://github.com/jniltinho/ansible-linux-users

Original layout

Gists have no folders; in the project the files live here:

users-linux/
├── .gitignore
├── ansible.cfg
├── inventory.ini
├── users.yml                     # playbook
├── templates/
│   └── sudoers.j2                # <- sudoers.j2
└── group_vars/
    └── all/
        ├── users.yml             # <- group_vars_all_users.yml
        └── vault.yml             # <- group_vars_all_vault.example.yml (encrypt it!)

Usage

mkdir -p users-linux/templates users-linux/group_vars/all && cd users-linux
# copy the files to the paths above, then edit inventory.ini and the user list
echo 'a-long-passphrase' > ~/.vault-pass-users && chmod 600 ~/.vault-pass-users
openssl passwd -6                                   # hash for the initial password (sudo with password)
ansible-vault encrypt group_vars/all/vault.yml
git init -b main && git add -A && git commit -m "Initial layout"
ansible-lint
ansible-playbook users.yml --check --diff
ansible-playbook users.yml

Requires the ansible package (it ships the ansible.posix collection). Tested with ansible 9.2.0 / ansible-core 2.16.3.

Rules

  • Never commit a private key or a plain-text password.
  • Never delete an entry from the list: change its state to removed.
  • Lock before removing. usermod -L only locks the password; what actually blocks SSH key logins is account expiration (expires).
  • append: true never removes a user from a group that left the list; run once with append: false when you need to take a group away.
  • The keys in this example are public keys generated only for a lab; the IP addresses are documentation addresses (192.0.2.0/24).
[defaults]
inventory = inventory.ini
vault_password_file = ~/.vault-pass-users
nocows = true
retry_files_enabled = false
[privilege_escalation]
become = true
---
# People's accounts on the servers. Every change here is a reviewed commit.
# state: active | locked | removed
# Never delete an entry: change its state to "removed".
users:
- name: ana
full_name: Ana Souza
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFheyJLtuZlL6mZue2Ha2VlsOrCpQokkeAI+MQAbIozQ ana@notebook
groups: [sudo] # sudo asking for her password (hash in the vault)
state: active
- name: bruno
full_name: Bruno Lima
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEvt3LKu4Ue9C2ewbKyC1F41f//kWyOoGLZ5RXeMJEq bruno@notebook
groups: [adm] # can read the logs in /var/log
sudo_nopasswd: true # passwordless sudo, via /etc/sudoers.d
state: removed # left the team on 2026-09-23
- name: carla
full_name: Carla Mendes
keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBVS+xmSmHpXvCptaasmycC0QDTcPDfQ6UaLpnNx9+ai carla@notebook
groups: [adm] # read-only access to logs, no sudo
state: active
---
# Plain-text example. In the project, the file is group_vars/all/vault.yml
# and it ALWAYS goes into Git encrypted:
# openssl passwd -6 # generates the password hash
# ansible-vault encrypt group_vars/all/vault.yml
vault_passwords:
ana: "$6$replace$replace-with-the-hash-generated-by-openssl-passwd-6"
[servers]
srv-ubuntu ansible_host=192.0.2.10
srv-debian ansible_host=192.0.2.11
[servers:vars]
ansible_user=deploy
# Managed by Ansible (users-linux repository). Do not edit by hand.
{% for u in users if u.state == 'active' and u.sudo_nopasswd | default(false) %}
{{ u.name }} ALL=(ALL:ALL) NOPASSWD: ALL
{% endfor %}
---
- name: Linux users declared in Git
hosts: servers
become: true
vars:
active_users: "{{ users | selectattr('state', 'eq', 'active') | list }}"
locked_users: "{{ users | selectattr('state', 'eq', 'locked') | list }}"
removed_users: "{{ users | selectattr('state', 'eq', 'removed') | list }}"
tasks:
- name: Common team group
ansible.builtin.group:
name: team
state: present
- name: Active accounts
ansible.builtin.user:
name: "{{ item.name }}"
comment: "{{ item.full_name }}"
shell: /bin/bash
groups: "{{ ['team'] + item.groups | default([]) }}"
append: true
create_home: true
password: "{{ vault_passwords[item.name] | default(omit) }}"
update_password: on_create
expires: -1
loop: "{{ active_users }}"
loop_control:
label: "{{ item.name }}"
- name: Authorized SSH keys (only the ones in the repository)
ansible.posix.authorized_key:
user: "{{ item.name }}"
path: "/home/{{ item.name }}/.ssh/authorized_keys" # lets --check run before the account exists
key: "{{ item['keys'] | join('\n') }}"
exclusive: true
loop: "{{ active_users }}"
loop_control:
label: "{{ item.name }}"
- name: Passwordless sudo rules
ansible.builtin.template:
src: sudoers.j2
dest: /etc/sudoers.d/90-users-ansible
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Locked accounts - lock the password and expire the account
ansible.builtin.user:
name: "{{ item.name }}"
password_lock: true
expires: 86400 # 1970-01-02: PAM refuses even key-based logins
loop: "{{ locked_users }}"
loop_control:
label: "{{ item.name }}"
- name: Locked accounts - remove SSH keys
ansible.builtin.file:
path: "/home/{{ item.name }}/.ssh/authorized_keys"
state: absent
loop: "{{ locked_users }}"
loop_control:
label: "{{ item.name }}"
- name: Locked accounts - kill processes and open sessions
ansible.builtin.command: pkill -KILL -u {{ item.name }}
register: pkill
changed_when: pkill.rc == 0
failed_when: pkill.rc > 1
loop: "{{ locked_users }}"
loop_control:
label: "{{ item.name }}"
- name: Removed accounts - delete user and home
ansible.builtin.user:
name: "{{ item.name }}"
state: absent
remove: true
loop: "{{ removed_users }}"
loop_control:
label: "{{ item.name }}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment