Shore Up
A printed list of names on one side, and on the other a wall of empty labeled lockers each getting a key and a nameplate fitted, one row at a time.
LinuxSecurity

Provision Linux Users and Groups from a CSV with Bash

Ketan Aagja10 min read

Creating one Linux account by hand is fine. Creating thirty from a spreadsheet a manager emailed you is a job for a script. This walks through a small, boring, reliable bash script that reads a CSV and provisions local users and their groups with the standard shadow-utils tools.

Before you run this

This script reads a CSV file and, for each row, creates a local Linux user account with a home directory, assigns a primary and any supplementary groups (creating the groups if they don't exist), sets an initial password, and forces the user to change that password at first login. It creates accounts on the machine it runs on — it does not touch LDAP, Active Directory, or any central directory.

  • It needs root. useradd, groupadd, and chpasswd all modify /etc/passwd, /etc/shadow, and /etc/group. Run it with sudo.
  • It changes system state and parts are not trivially reversible. Creating a user is undoable with userdel -r, but userdel -r also deletes that user's home directory and mail spool. Read the "Undo" section before you rely on it.
  • Test first. Read the script through before running it. It ships in dry-run mode by default — without the --apply flag it only prints what it would do and changes nothing. Run it dry, eyeball the output, then run it on a throwaway VM or with a one-row CSV containing a single test user before you point it at the real list.
  • The initial passwords live in the CSV as plaintext. That file is a secret while it exists. Keep it off shared locations, chmod 600 it, and delete it when you're done. I force a password change at first login so the CSV password is only ever a one-time value.

I'm assuming Debian 12 or Ubuntu 22.04, bash, and that these are standalone local accounts. On RHEL/AlmaLinux the same tools and flags apply; the only real differences are default shells and whether a package like passwd/shadow-utils is present (it is, on a normal install). Note that adduser is the Debian-preferred interactive wrapper — I use useradd here because it's the scriptable, distribution-neutral tool.

The CSV format

One header row, then one user per line. Columns: username, full name, primary group, supplementary groups, login shell, initial password. Supplementary groups are separated by semicolons so they don't collide with the CSV commas.

username,fullname,primary_group,supplementary_groups,shell,password
jsmith,Jane Smith,staff,sudo;developers,/bin/bash,ChangeMe-8f3k
bwong,Ben Wong,staff,developers,/bin/bash,ChangeMe-2p9x
klee,Kim Lee,contractors,,/usr/sbin/nologin,ChangeMe-5r1t

Replace these with your real values. klee shows a user with no supplementary groups and a nologin shell (a service or restricted account).

The script

#!/usr/bin/env bash
set -euo pipefail

# provision_users.sh — create local users and groups from a CSV.
# Usage:  sudo ./provision_users.sh users.csv          # dry-run, changes nothing
#         sudo ./provision_users.sh users.csv --apply  # actually make changes

CSV_FILE="${1:-}"
APPLY="${2:-}"

if [[ -z "$CSV_FILE" || ! -f "$CSV_FILE" ]]; then
  echo "Usage: $0 <users.csv> [--apply]" >&2
  exit 1
fi

DRY_RUN=1
[[ "$APPLY" == "--apply" ]] && DRY_RUN=0

# run: execute a command, or just print it in dry-run mode
run() {
  if [[ "$DRY_RUN" -eq 1 ]]; then
    echo "DRY-RUN: $*"
  else
    "$@"
  fi
}

# tail -n +2 skips the header row
tail -n +2 "$CSV_FILE" | while IFS=',' read -r username fullname primary groups shell password; do
  [[ -z "$username" ]] && continue   # skip blank lines

  # Create the primary group if it isn't already present
  if ! getent group "$primary" >/dev/null; then
    run groupadd "$primary"
  fi

  # Create each supplementary group (semicolon-separated) if missing
  IFS=';' read -ra extra <<< "$groups"
  for g in "${extra[@]}"; do
    [[ -z "$g" ]] && continue
    if ! getent group "$g" >/dev/null; then
      run groupadd "$g"
    fi
  done
  supp="${groups//;/,}"   # useradd -G wants a comma-separated list

  # Never touch an account that already exists
  if getent passwd "$username" >/dev/null; then
    echo "SKIP: user '$username' already exists"
    continue
  fi

  # Build the useradd arguments:
  #   -m create home dir, -c full name (GECOS), -s login shell,
  #   -g primary group, -G supplementary groups (only if we have any)
  useradd_args=(-m -c "$fullname" -s "$shell" -g "$primary")
  [[ -n "$supp" ]] && useradd_args+=(-G "$supp")
  run useradd "${useradd_args[@]}" "$username"

  # Set the initial password and force a change at first login
  if [[ -n "$password" ]]; then
    if [[ "$DRY_RUN" -eq 1 ]]; then
      echo "DRY-RUN: set initial password for '$username' and expire it"
    else
      echo "$username:$password" | chpasswd   # feed user:pass to chpasswd
      chage -d 0 "$username"                   # expire password -> must change on login
    fi
  fi
done

A few notes on the choices:

  • set -euo pipefail makes the script stop on an unset variable or a failed command instead of blundering on.
  • I check getent passwd before every useradd so re-running the script over the same CSV is safe — existing users are skipped, not clobbered or errored over.
  • chage -d 0 sets the password's last-change date to the epoch, which the login system treats as expired, so the user is forced to set their own password on first login. passwd --expire "$username" does the same thing if you prefer it.
  • I deliberately do not set UIDs. Let the system allocate them from the normal range. If you have a real reason to pin UIDs, useradd -u takes one — check man useradd before you do, because clashes cause trouble.

Make it executable and run the dry pass first:

chmod +x provision_users.sh
sudo ./provision_users.sh users.csv          # prints what it would do
sudo ./provision_users.sh users.csv --apply  # does it

Verify it worked

After an --apply run, confirm a couple of the accounts by hand rather than trusting the script's word:

# The account exists, with the UID/GID and full name you expect
getent passwd jsmith
id jsmith

# Group membership, primary and supplementary
groups jsmith

# Home directory was created and is owned by the user
ls -ld /home/jsmith

# Password is flagged to be changed at next login
sudo chage -l jsmith

chage -l should show the password as expired (a "Password expires" / last-change line at the epoch), which is what forces the first-login change. Confirm the login shell in the getent passwd output matches what you put in the CSV — a nologin user should show /usr/sbin/nologin.

Undo / rollback

Removing a user is where you need to be careful, because the clean-up flag also deletes their files.

# Remove a single user AND their home directory and mail spool.
# This is irreversible — the home directory is gone.
sudo userdel -r jsmith

# Remove a group you created (only works if no user still has it as primary)
sudo groupdel developers

If you want to keep the home directory (for archiving or handover), run userdel without -r — it removes the account but leaves /home/jsmith in place, now owned by an orphaned UID until you reassign it.

There's no batch "undo the whole CSV" button, and I wouldn't want one — deleting accounts in bulk is exactly the kind of thing you should do deliberately, one at a time, after checking each. If you need to reverse a big run, feed the same username column back through a small loop that calls userdel per line, and dry-run that first too.

For the authoritative flag list, see the useradd(8), groupadd(8), chpasswd(8), and chage(1) man pages on your own system — they're the canonical reference for the exact behaviour on your distribution.