0Pricing
DevOps Bootcamp · Lesson

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 DevOps Bootcamp lesson on CoddyKit — 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 DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

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/zsh

Use 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"
done

Creating 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"
fi

Setting Initial Passwords Securely

Never hardcode passwords in scripts. Two safe approaches for bulk provisioning are:

  • Generate a random initial password with openssl rand or /dev/urandom, print it once, and force the user to change it on first login
  • Set a pre-hashed password using usermod -p with 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"
done

Group 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_FILE with 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 0440 and ownership to root: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 activechage -l output 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:

  1. Lock the account immediately (usermod -L) — prepends ! to the shadow password hash, preventing login without deleting data
  2. Revoke sudo — remove their sudoers fragment if one exists
  3. Transfer ownership of their files to a manager or archive account
  4. Archive the home directory as a tarball before deletion
  5. 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 || true

Which 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/read loops, skip blank and comment lines, and validate fields before any system call
  • useradd essentials — always use -m (home), -s (shell), -c (comment), and -G (groups); check existence with id first for idempotency
  • Passwords — set via chpasswd stdin to keep plaintext out of process arguments; enforce first-login reset with chage -d 0
  • chage for policy — standardise max age (-M), warning days (-W), and inactivity lock (-I) across all non-system accounts
  • Group membership — use gpasswd -a or usermod -aG (with the -a flag) to append rather than replace memberships
  • sudoers drop-ins — write to /etc/sudoers.d/, validate with visudo -c -f before installing, set 0440 root:root permissions
  • 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.

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, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.

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 DevOps Bootcamp 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 DevOps Bootcamp?

No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

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 DevOps Bootcamp lesson?

Yes. Every DevOps Bootcamp 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

  1. Automating User and Group Provisioning
  2. Controlling systemd Services and Writing Unit Files
  3. Disk, Filesystem, and Mount Automation
  4. Building System Health Check and Alert Scripts
← Back to DevOps Bootcamp