
Provision Linux Users and Groups from a CSV with Bash
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, andchpasswdall modify/etc/passwd,/etc/shadow, and/etc/group. Run it withsudo. - It changes system state and parts are not trivially reversible. Creating a user is undoable with
userdel -r, butuserdel -ralso 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
--applyflag 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 600it, 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 pipefailmakes the script stop on an unset variable or a failed command instead of blundering on.- I check
getent passwdbefore everyuseraddso re-running the script over the same CSV is safe — existing users are skipped, not clobbered or errored over. chage -d 0sets 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 -utakes one — checkman useraddbefore 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.
