Automating User and Group Provisioning
Create, modify, and audit accounts in bulk using useradd, chage, and sudoers fragment management.
Automating User and Group Provisioning is a free Linux Command Line & Bash Scripting Mastery lesson on CoddyKit. This is lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Linux Command Line & Bash Scripting Mastery learning path, and your progress syncs across the web and the CoddyKit app. The Linux Command Line & Bash Scripting Mastery course includes 4 lessons in total.
Why Automate User Provisioning?
Managing users one at a time with useradd works fine for a handful of accounts, but enterprise environments routinely onboard dozens or hundreds of users simultaneously. Manual commands become error-prone, inconsistent, and unauditable.
Bash scripting lets you:
- Create users with standardised settings (shell, home directory, password policy) every time
- Read a CSV or text file of new hires and provision them in a single run
- Log every action so you have an audit trail for compliance
- Integrate with configuration management pipelines (Ansible, Chef, Jenkins)
This lesson walks through building a production-grade user provisioning script from scratch, covering useradd, chage, usermod, group management, sudoers drop-ins, and post-run auditing.
Reading a Bulk User List
The canonical input format for bulk provisioning is a delimited text file — one record per line. A typical CSV might look like:
username,full_name,group,shell
alice,Alice Smith,developers,/bin/bash
bob,Bob Jones,ops,/bin/zshUse IFS and read inside a while loop to parse it safely. Skipping the header line with tail -n +2 keeps the logic clean.
Key defensive practices:
- Strip leading/trailing whitespace from each field
- Skip blank lines and comment lines starting with
# - Validate that mandatory fields are non-empty before calling any system commands
#!/usr/bin/env bash
# parse_users.sh — safely read a CSV of users
set -euo pipefail
USER_FILE="${1:-users.csv}"
[[ -f "$USER_FILE" ]] || { echo "ERROR: $USER_FILE not found"; exit 1; }
tail -n +2 "$USER_FILE" | while IFS=',' read -r username full_name group shell; do
# trim whitespace
username="${username// /}"
[[ -z "$username" || "$username" == \#* ]] && continue
echo "Parsed -> user=$username group=$group shell=$shell"
doneCreating Users with useradd
useradd is the low-level utility that writes to /etc/passwd, /etc/shadow, and /etc/group. The most important flags for scripting are:
-m— create the home directory-s— set the login shell-c— GECOS comment field (full name)-G— supplementary groups (comma-separated)-e— account expiry date (YYYY-MM-DD)
Always check whether the user already exists with id before calling useradd; running it on an existing user returns exit code 9 and prints an error that could clutter logs.
Note: useradd requires root. Wrap your script with a privilege check at the top.
#!/usr/bin/env bash
# create_user.sh — idempotent single-user creation
set -euo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
USERNAME="$1"
FULL_NAME="${2:-}"
GROUP="${3:-staff}"
SHELL="${4:-/bin/bash}"
if id "$USERNAME" &>/dev/null; then
echo "[SKIP] User $USERNAME already exists"
else
useradd \
--create-home \
--shell "$SHELL" \
--comment "$FULL_NAME" \
--groups "$GROUP" \
"$USERNAME"
echo "[OK] Created $USERNAME"
fiSetting Initial Passwords Securely
Never hardcode passwords in scripts. Two safe approaches for bulk provisioning are:
- Generate a random initial password with
openssl randor/dev/urandom, print it once, and force the user to change it on first login - Set a pre-hashed password using
usermod -pwith a SHA-512 hash so the plaintext never appears in the process list
chpasswd is the recommended tool for scripted password setting — it reads username:password pairs from stdin, so the password never appears in the command line arguments (visible via ps).
After setting the password, use chage -d 0 to force an immediate password reset at next login.
#!/usr/bin/env bash
# set_temp_password.sh
set -euo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
USERNAME="$1"
# Generate a 16-char random password (alphanumeric only)
TMP_PASS=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16)
# Set password via chpasswd (password never in argv)
echo "${USERNAME}:${TMP_PASS}" | chpasswd
# Force password change on next login
chage -d 0 "$USERNAME"
echo "[OK] Temporary password for $USERNAME: $TMP_PASS"
echo "[OK] User must change password on first login"Managing Password Ageing with chage
chage (change age) controls the password ageing policy stored in /etc/shadow. Enterprise security policies typically mandate:
- Maximum password age (e.g., 90 days)
- Minimum days before a password can be changed again
- Warning period before expiry
- Account inactivity lock after last password use
Key chage flags:
-M <days>— maximum password age-m <days>— minimum password age-W <days>— warning days before expiry-I <days>— inactive days before account lock-E <date>— absolute account expiry-l— list current settings for a user
#!/usr/bin/env bash
# apply_password_policy.sh — enforce org-wide ageing policy
set -euo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
# Policy constants
MAX_AGE=90
MIN_AGE=1
WARN_DAYS=14
INACTIVE_DAYS=30
apply_policy() {
local user="$1"
chage \
-M "$MAX_AGE" \
-m "$MIN_AGE" \
-W "$WARN_DAYS" \
-I "$INACTIVE_DAYS" \
"$user"
echo "[OK] Policy applied to $user"
}
# Apply to all non-system users (UID >= 1000)
awk -F: '$3 >= 1000 && $3 < 65534 { print $1 }' /etc/passwd | while read -r user; do
apply_policy "$user"
doneGroup Management in Bulk
Groups are the primary mechanism for controlling resource access. A provisioning script must ensure that required groups exist before adding users to them — useradd -G nonexistent will fail.
Use groupadd idempotently by checking the exit code: it returns 9 if the group already exists. The idiom getent group <name> is a portable, readable alternative to grepping /etc/group.
gpasswd -a adds a user to a group without replacing existing memberships (unlike usermod -G which replaces the supplementary group list).
#!/usr/bin/env bash
# ensure_groups.sh — create groups if missing, then add users
set -euo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
REQUIRED_GROUPS=(developers ops security auditors)
for grp in "${REQUIRED_GROUPS[@]}"; do
if getent group "$grp" &>/dev/null; then
echo "[SKIP] Group $grp already exists"
else
groupadd "$grp"
echo "[OK] Created group $grp"
fi
done
# Safely add a user to a group (append, don't replace)
add_to_group() {
local user="$1" group="$2"
gpasswd -a "$user" "$group" 2>/dev/null && echo "[OK] $user -> $group"
}Full Bulk Provisioning Script
Putting it all together: a single script reads a CSV, creates users and groups, sets password policy, logs every action, and handles errors gracefully without stopping the entire batch.
Important design decisions in the script below:
- A
LOG_FILEwith timestamps captures all operations for auditing - Errors for individual users are logged but do not abort the loop (
|| log_error) - The script is idempotent — safe to re-run after partial failures
- All output goes to both the terminal and the log file via
tee
#!/usr/bin/env bash
# bulk_provision.sh — production user provisioning
set -uo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
USER_FILE="${1:-users.csv}"
LOG_FILE="/var/log/user_provision_$(date +%F).log"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG_FILE"; }
err() { log "ERROR: $*"; }
log "=== Provisioning started from $USER_FILE ==="
tail -n +2 "$USER_FILE" | while IFS=',' read -r username fullname group shell; do
username="${username// /}"
[[ -z "$username" || "$username" == \#* ]] && continue
group="${group:-staff}"
shell="${shell:-/bin/bash}"
# Ensure group exists
getent group "$group" &>/dev/null || groupadd "$group"
# Create user idempotently
if id "$username" &>/dev/null; then
log "[SKIP] $username exists"
else
useradd -m -s "$shell" -c "$fullname" -G "$group" "$username" || { err "useradd failed for $username"; continue; }
TMP="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 14)"
echo "${username}:${TMP}" | chpasswd
chage -M 90 -m 1 -W 14 -I 30 -d 0 "$username"
log "[OK] $username created (group=$group) tmp_pass=$TMP"
fi
done
log "=== Provisioning complete ==="sudoers Drop-in Fragment Management
Editing /etc/sudoers directly is dangerous — a syntax error locks everyone out of sudo. The safe approach is to use drop-in files in /etc/sudoers.d/, each validated with visudo -c -f before being placed in position.
Best practices for sudoers fragments:
- Name files after the team or role they grant (e.g.,
10-developers,20-ops) - Use group-based rules (
%developers ALL=(ALL) NOPASSWD: /usr/bin/systemctl) rather than per-user lines - Always set permissions to
0440and ownership toroot:root - Validate with
visudo -c— it exits non-zero on any syntax error
#!/usr/bin/env bash
# write_sudoers_fragment.sh
set -euo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
FRAGMENT_NAME="${1:-10-developers}"
SUDOERS_DIR="/etc/sudoers.d"
TMP_FILE="$(mktemp)"
# Write the fragment to a temp file first
cat > "$TMP_FILE" << 'EOF'
# Developers: restart services and view journals without full root
%developers ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart *, /usr/bin/journalctl
%ops ALL=(ALL) NOPASSWD: ALL
EOF
# Validate BEFORE installing
if visudo -c -f "$TMP_FILE"; then
install -m 0440 -o root -g root "$TMP_FILE" "${SUDOERS_DIR}/${FRAGMENT_NAME}"
echo "[OK] Installed ${SUDOERS_DIR}/${FRAGMENT_NAME}"
else
echo "[ERROR] sudoers syntax check failed — fragment NOT installed"
rm -f "$TMP_FILE"
exit 1
fi
rm -f "$TMP_FILE"Auditing Existing Accounts
After provisioning (and on a regular schedule), you should audit the user database for anomalies:
- UID 0 accounts — any account with UID 0 other than root is a critical security finding
- Accounts with no password — entries with an empty or
!password field in/etc/shadow - Expired accounts still active —
chage -loutput can be parsed in bulk - Users with shells but no home directory — misconfiguration that breaks login
Generating a structured report and emailing it to the security team is straightforward with mail or by appending to a monitored log path.
#!/usr/bin/env bash
# audit_users.sh — produce a security-relevant user report
set -uo pipefail
REPORT="/var/log/user_audit_$(date +%F).txt"
echo "=== User Audit Report $(date) ===" > "$REPORT"
echo "" >> "$REPORT"
echo "--- Accounts with UID 0 (should be root only) ---" >> "$REPORT"
awk -F: '$3 == 0 { print $1 }' /etc/passwd >> "$REPORT"
echo "" >> "$REPORT"
echo "--- Accounts with empty password field ---" >> "$REPORT"
awk -F: '($2 == "" || $2 == "!") && $3 >= 1000 { print $1 }' /etc/shadow 2>/dev/null >> "$REPORT" || echo " (requires root)" >> "$REPORT"
echo "" >> "$REPORT"
echo "--- Normal users (UID 1000-60000) ---" >> "$REPORT"
awk -F: '$3 >= 1000 && $3 < 60000 { printf "%-20s uid=%-6s shell=%s\n", $1, $3, $7 }' /etc/passwd >> "$REPORT"
cat "$REPORT"
echo "Report saved to $REPORT"Locking, Unlocking, and Removing Accounts
Offboarding is as important as onboarding. When a user leaves, the correct sequence is:
- Lock the account immediately (
usermod -L) — prepends!to the shadow password hash, preventing login without deleting data - Revoke sudo — remove their sudoers fragment if one exists
- Transfer ownership of their files to a manager or archive account
- Archive the home directory as a tarball before deletion
- Delete with
userdel -r— removes home and mail spool
usermod -U unlocks an account (removes the ! prefix), useful for temporary suspension.
#!/usr/bin/env bash
# offboard_user.sh — lock, archive, then optionally delete
set -euo pipefail
[[ $EUID -ne 0 ]] && { echo "Must run as root"; exit 1; }
USERNAME="$1"
ARCHIVE_DIR="/srv/archived-homes"
mkdir -p "$ARCHIVE_DIR"
# 1. Lock account
usermod -L "$USERNAME"
echo "[OK] Account $USERNAME locked"
# 2. Remove sudoers fragment if present
SUDOERS_FILE="/etc/sudoers.d/${USERNAME}"
[[ -f "$SUDOERS_FILE" ]] && rm -f "$SUDOERS_FILE" && echo "[OK] Removed sudoers fragment"
# 3. Archive home directory
HOME_DIR="$(getent passwd "$USERNAME" | cut -d: -f6)"
if [[ -d "$HOME_DIR" ]]; then
tar -czf "${ARCHIVE_DIR}/${USERNAME}_$(date +%F).tar.gz" -C "$(dirname "$HOME_DIR")" "$(basename "$HOME_DIR")"
echo "[OK] Home archived to ${ARCHIVE_DIR}/${USERNAME}_$(date +%F).tar.gz"
fi
echo "[NOTICE] Review archive, then run: userdel -r $USERNAME"Testing the Script with a Dry-Run Mode
Production provisioning scripts must be testable without side effects. Implement a dry-run mode using a DRY_RUN flag that replaces all mutating commands with echo stubs.
The pattern is simple: define a run() helper that either executes or echoes the command depending on the flag. This approach means:
- Every code path is exercised during testing
- The output shows exactly what would happen on a live run
- CI pipelines can validate the logic without root access
Complement dry-run with a dedicated test user prefix (e.g., test_) that makes it easy to clean up after integration tests.
#!/usr/bin/env bash
# provision_with_dryrun.sh
set -euo pipefail
DRY_RUN="${DRY_RUN:-false}"
# Wrapper: execute or echo
run() {
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY-RUN] $*"
else
"$@"
fi
}
create_user() {
local user="$1" group="$2"
if id "$user" &>/dev/null; then
echo "[SKIP] $user exists"
return
fi
run useradd -m -s /bin/bash -G "$group" "$user"
run chage -M 90 -m 1 -W 14 -d 0 "$user"
echo "[OK] $user provisioned (dry=$DRY_RUN)"
}
# Test run
DRY_RUN=true create_user testuser developers
echo "---"
create_user realuser developers 2>/dev/null || trueWhich Command Should You Use to Add a User to a Supplementary Group Without Removing Existing Group Memberships?
In a bulk provisioning script you need to assign an existing user to the auditors group. The user is already a member of developers and staff. Which command preserves all existing memberships while adding the new one?
Lesson Recap: Automating User and Group Provisioning
In this lesson you built a complete, production-grade user provisioning toolkit. Here are the key takeaways:
- Parse input defensively — use
IFS/readloops, skip blank and comment lines, and validate fields before any system call useraddessentials — always use-m(home),-s(shell),-c(comment), and-G(groups); check existence withidfirst for idempotency- Passwords — set via
chpasswdstdin to keep plaintext out of process arguments; enforce first-login reset withchage -d 0 chagefor policy — standardise max age (-M), warning days (-W), and inactivity lock (-I) across all non-system accounts- Group membership — use
gpasswd -aorusermod -aG(with the-aflag) to append rather than replace memberships - sudoers drop-ins — write to
/etc/sudoers.d/, validate withvisudo -c -fbefore installing, set0440 root:rootpermissions - Offboarding — lock (
usermod -L), archive home, then delete; never skip the archive step - Dry-run mode — wrap mutating commands in a
run()helper so pipelines can verify logic without root side effects
Combining these patterns gives you a repeatable, auditable, and safe automation layer for Linux identity management at any scale.
Learn Bash with an AI tutor — free
Write and run real code in your browser, get instant help from a 24/7 AI tutor, and pick up where you left off on the web or in the app.
- Courses
- 22
- Lessons
- 88
Frequently Asked Questions
Is the “Automating User and Group Provisioning” lesson free?
Yes — the full text of “Automating User and Group Provisioning” is free to read here on the web. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Linux Command Line & Bash Scripting Mastery course, upgrade to CoddyKit PRO. The Linux Command Line & Bash Scripting Mastery course includes 4 lessons in total.
What will I learn in “Automating User and Group Provisioning”?
Create, modify, and audit accounts in bulk using useradd, chage, and sudoers fragment management. You practise Linux Command Line & Bash Scripting Mastery with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Linux Command Line & Bash Scripting Mastery?
No prior experience is required. Linux Command Line & Bash Scripting Mastery on CoddyKit is structured for beginners through advanced learners, so you can start here or from the beginning and move at your own pace. This is lesson 1 of 4.
How long does the “Automating User and Group Provisioning” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Linux Command Line & Bash Scripting Mastery lesson?
Yes. Every Linux Command Line & Bash Scripting Mastery lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Automating User and Group Provisioning
- Controlling systemd Services and Writing Unit Files
- Disk, Filesystem, and Mount Automation
- Building System Health Check and Alert Scripts