Post

Automating Linux User Configuration Updates

Managing user configurations at scale is one of those sysadmin challenges that starts small and quietly grows into a maintenance headache. When you update /etc/skel or change password aging defaults in /etc/login.defs, only newly created accounts pick up those changes — every existing user is left behind. In this guide, we’ll build two Bash scripts that close that gap, schedule them intelligently, and wire up proper logging so you always know what changed and when.

Understanding the Problem

The Skeleton Directory

The /etc/skel directory holds the default dotfiles that are copied into every new user’s home directory at account creation time. Common files include:

File Purpose
.bashrc Interactive shell configuration
.profile Login environment settings
.bash_profile Bash-specific login configuration
.bash_logout Commands executed on logout

The catch is that this copy happens once, at account creation. Updating /etc/skel/.bashrc today does nothing for the 50 users already on the system — their dotfiles are untouched copies from whenever their accounts were made.

Password Aging Policies

The same problem applies to password aging. Default policy values live in /etc/login.defs:

1
2
3
PASS_MAX_DAYS   99999
PASS_MIN_DAYS   0
PASS_WARN_AGE   7

Change these values and only future accounts inherit the new policy. Existing users keep whatever aging settings were in place when their accounts were created — which is often no aging policy at all.

The result: Two separate configuration gaps that silently widen with every policy update you make.

Automated Solution Architecture

Our approach covers both problems with two focused scripts and flexible scheduling options:

Task Challenge Solution
Skeleton Updates Changes to /etc/skel only affect new users Script copies updates to all existing home directories
Password Aging Policy changes in /etc/login.defs only affect new users Script applies chage settings to all existing accounts
Scheduling Manual execution is error-prone at command for one-time runs; cron for recurring automation

Both scripts share common design principles: root-only execution, UID range filtering to skip system accounts, timestamped logging, and graceful error handling.

Part 1: Skeleton Directory Synchronization

Script: update_skel_to_users.sh

This script reads all regular user accounts from /etc/passwd, checks that their home directory exists, backs up any files it is about to replace, then copies the current /etc/skel contents with correct ownership and permissions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#!/bin/bash
#######################################################
# Script: update_skel_to_users.sh
# Purpose: Sync /etc/skel updates to existing users
# Author: System Administrator
#######################################################

LOGFILE="/var/log/skel_sync.log"
SKEL_DIR="/etc/skel"
MIN_UID=1000   # Minimum UID for regular users
MAX_UID=60000  # Maximum UID for regular users

log_message() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE"
}

if [[ $EUID -ne 0 ]]; then
    echo "Error: This script must be run as root"
    exit 1
fi

log_message "=== Starting skeleton directory synchronization ==="

USERS=$(awk -F: -v min=$MIN_UID -v max=$MAX_UID \
    '$3 >= min && $3 <= max && $7 !~ /nologin|false/ {print $1":"$6}' /etc/passwd)

UPDATED_COUNT=0
ERROR_COUNT=0

while IFS=: read -r username homedir; do
    if [[ ! -d "$homedir" ]]; then
        log_message "Warning: Home directory $homedir does not exist for user $username"
        continue
    fi

    log_message "Processing user: $username ($homedir)"

    for skelfile in "$SKEL_DIR"/.[!.]*; do
        if [[ -f "$skelfile" ]]; then
            filename=$(basename "$skelfile")
            target="$homedir/$filename"

            # Backup existing file if present
            if [[ -f "$target" ]]; then
                cp "$target" "${target}.backup.$(date +%Y%m%d)"
            fi

            if cp "$skelfile" "$target"; then
                chown "$username:$username" "$target"
                chmod --reference="$skelfile" "$target"
                log_message "  ✓ Updated: $filename"
                (( UPDATED_COUNT++ ))
            else
                log_message "  ✗ Error: Failed to copy $filename"
                (( ERROR_COUNT++ ))
            fi
        fi
    done
done <<< "$USERS"

log_message "=== Synchronization complete ==="
log_message "Files updated: $UPDATED_COUNT"
log_message "Errors encountered: $ERROR_COUNT"

exit 0

What the Script Does

  • UID filteringawk selects only accounts with UIDs between 1000 and 60000 and excludes shell-less accounts (nologin, false), so system accounts like nobody or www-data are never touched.
  • Automatic backups — before overwriting any file, a dated copy is created (e.g. .bashrc.backup.20251009), giving you a recovery path if something goes wrong.
  • Ownership and permission preservationchown resets ownership to the target user, and chmod --reference mirrors the exact permissions from the skeleton file.
  • Comprehensive logging — every action is timestamped and written to /var/log/skel_sync.log.

Tip: Run ls -la ~/.bashrc.backup.* on any user account after the script runs to confirm backups were created before the first production use.

Part 2: Password Aging Policy Automation

Understanding Password Aging

The chage command manages the password aging fields stored in /etc/shadow. Each user has independent aging values, and the script applies a consistent policy set across all regular accounts.

Parameter chage Option Description Typical Value
Maximum Days -M Days until password change is required 90 days
Minimum Days -m Days that must pass before user can change password 1 day
Warning Days -W Days of advance warning before expiry 7 days
Inactive Days -I Days of inactivity before account locks 30 days

Script: update_password_aging.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/bin/bash
#######################################################
# Script: update_password_aging.sh
# Purpose: Apply password aging policies to existing users
# Author: System Administrator
#######################################################

LOGFILE="/var/log/password_aging.log"

# Password aging policy settings (in days)
MAX_DAYS=90     # Maximum password age
MIN_DAYS=1      # Minimum days between password changes
WARN_DAYS=7     # Warning days before expiration
INACTIVE_DAYS=30  # Days of inactivity before account lock

MIN_UID=1000
MAX_UID=60000

log_message() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE"
}

if [[ $EUID -ne 0 ]]; then
    echo "Error: This script must be run as root"
    exit 1
fi

log_message "=== Starting password aging policy update ==="
log_message "Policy: MAX=$MAX_DAYS, MIN=$MIN_DAYS, WARN=$WARN_DAYS, INACTIVE=$INACTIVE_DAYS"

USERS=$(awk -F: -v min=$MIN_UID -v max=$MAX_UID \
    '$3 >= min && $3 <= max && $7 !~ /nologin|false/ {print $1}' /etc/passwd)

SUCCESS_COUNT=0
ERROR_COUNT=0

while IFS= read -r username; do
    log_message "Processing user: $username"

    if chage -M "$MAX_DAYS" -m "$MIN_DAYS" -W "$WARN_DAYS" -I "$INACTIVE_DAYS" "$username" 2>>"$LOGFILE"; then
        log_message "  ✓ Updated aging policy for: $username"
        (( SUCCESS_COUNT++ ))

        CURRENT_SETTINGS=$(chage -l "$username" 2>/dev/null | grep -E "Maximum|Minimum|Warning")
        log_message "  Current settings:
$CURRENT_SETTINGS"
    else
        log_message "  ✗ Error: Failed to update aging policy for $username"
        (( ERROR_COUNT++ ))
    fi
done <<< "$USERS"

log_message "=== Password aging update complete ==="
log_message "Users updated successfully: $SUCCESS_COUNT"
log_message "Errors encountered: $ERROR_COUNT"

exit 0

After each successful chage call, the script immediately reads back the applied settings with chage -l and writes them to the log — giving you a verifiable audit trail of exactly what was set for each user.

Installation and Setup

Step 1: Create the Scripts Directory

Store administrative scripts in /usr/local/sbin to keep them separate from package-managed binaries and ensure they are only accessible to root:

1
2
sudo mkdir -p /usr/local/sbin/user-management
cd /usr/local/sbin/user-management

Step 2: Create and Secure the Scripts

Create each script file, paste in the code from the sections above, then lock down permissions:

1
2
3
4
5
6
7
# Create the files
sudo nano update_skel_to_users.sh
sudo nano update_password_aging.sh

# Restrict to root only
sudo chmod 700 update_skel_to_users.sh update_password_aging.sh
sudo chown root:root *.sh

Important: chmod 700 ensures only root can read, write, or execute these scripts. Never make them world-readable since they contain policy logic and interact with sensitive system files.

Scheduling: Three Execution Options

Because skeleton files and password policies don’t change on a fixed schedule, you have three sensible approaches depending on your workflow.

Option 1: Manual Execution (Immediate)

Run scripts directly whenever you’ve made a change that needs propagating:

1
2
3
4
5
# After updating /etc/skel files
sudo /usr/local/sbin/user-management/update_skel_to_users.sh

# After changing password aging policy
sudo /usr/local/sbin/user-management/update_password_aging.sh

Option 2: One-Time Scheduling with at

The at command schedules a job to run once at a specified time — ideal for running maintenance during off-hours without setting up a recurring schedule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Install 'at' if needed
sudo apt install at      # Debian/Ubuntu
sudo yum install at      # RHEL/CentOS

# Schedule both scripts for tonight at midnight
echo "/usr/local/sbin/user-management/update_skel_to_users.sh" | sudo at midnight
echo "/usr/local/sbin/user-management/update_password_aging.sh" | sudo at midnight + 5 minutes

# Schedule for a specific date and time
echo "/usr/local/sbin/user-management/update_skel_to_users.sh" | sudo at 23:00 February 15

# Review and manage pending jobs
sudo atq           # List scheduled jobs
sudo atrm 1        # Remove job number 1

The 5-minute offset between jobs prevents them from competing for the same resources and makes logs easier to read.

Option 3: Recurring Schedule with Cron

For environments where skeleton files or password policies change frequently, add cron entries to root’s crontab:

1
sudo crontab -e
1
2
3
4
5
6
7
# Daily execution at midnight
0 0 * * *  /usr/local/sbin/user-management/update_skel_to_users.sh
5 0 * * *  /usr/local/sbin/user-management/update_password_aging.sh

# Or weekly, every Sunday at midnight
0 0 * * 0  /usr/local/sbin/user-management/update_skel_to_users.sh
5 0 * * 0  /usr/local/sbin/user-management/update_password_aging.sh

Cron field reference — fields are ordered minute hour day-of-month month day-of-week:

Field Range Description
Minute 0–59 Minute of the hour
Hour 0–23 Hour of the day (0 = midnight)
Day of Month 1–31 Day of the month
Month 1–12 Month of the year
Day of Week 0–6 Day of the week (0 = Sunday)

Choosing the Right Method

Method When to Use Best For
Manual (sudo ./script.sh) Run right now Testing, one-time updates, immediate changes
One-time (at command) Schedule once for off-hours Running during maintenance windows
Recurring (cron) Automatic daily/weekly schedule Frequent policy changes, large organizations

Recommendation: Most administrators use manual execution or one-time at scheduling. Skeleton files and password policies rarely change on a predictable schedule, so a recurring cron job often runs unnecessarily. Reserve cron for environments with high user turnover or frequent policy updates.

Verification and Testing

Always test scripts manually before relying on scheduled execution.

Run Scripts and Watch the Output

1
2
3
4
5
6
# Run and observe real-time log output
sudo /usr/local/sbin/user-management/update_skel_to_users.sh
sudo tail -f /var/log/skel_sync.log

sudo /usr/local/sbin/user-management/update_password_aging.sh
sudo tail -f /var/log/password_aging.log

Verify Password Aging for a Specific User

1
sudo chage -l username

This displays all aging fields — last change date, expiry date, warning period, and inactive period — so you can confirm the policy was applied correctly.

Verify Cron Configuration

1
2
3
4
5
6
# List root's current cron jobs
sudo crontab -l

# Confirm cron daemon is running
sudo systemctl status cron    # Debian/Ubuntu
sudo systemctl status crond   # RHEL/CentOS

Log Management with Logrotate

Without rotation, /var/log/skel_sync.log and /var/log/password_aging.log grow indefinitely. Create a logrotate configuration to manage them automatically:

1
sudo nano /etc/logrotate.d/user-management
1
2
3
4
5
6
7
8
9
/var/log/skel_sync.log /var/log/password_aging.log {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
    create 0640 root root
}

The key directives explained: rotate 30 keeps 30 days of history before deleting old files; compress gzips rotated logs to save disk space; delaycompress skips compressing the most recently rotated file in case it is still being written to; missingok suppresses errors if a log file has not been created yet; notifempty skips rotation of empty files.

Advanced: Email Notifications

For production environments, add email alerts so administrators are notified of errors without having to monitor logs manually:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Add near the top of either script
ADMIN_EMAIL="admin@example.com"

send_email_notification() {
    local subject="$1"
    local body="$2"
    echo "$body" | mail -s "$subject" "$ADMIN_EMAIL"
}

# Add at the bottom of update_skel_to_users.sh
send_email_notification "Skeleton Sync Complete" \
    "Updated: $UPDATED_COUNT files
Errors: $ERROR_COUNT
Log: $LOGFILE"

Other enhancements worth considering: a --dry-run flag that logs planned changes without applying them; an exclusion list for special service accounts; checksum-based differential updates that only copy files that have actually changed in /etc/skel.

Troubleshooting Common Issues

Problem Possible Cause Solution
Scripts don’t execute Missing execute permission sudo chmod +x script.sh
Cron job not running Cron daemon stopped sudo systemctl start cron
Permission denied errors Not running as root Use sudo or add to root’s crontab
Files not updating Incorrect UID range Adjust MIN_UID and MAX_UID variables
No log output Log directory missing sudo mkdir -p /var/log

Security Considerations

A few practices that should accompany any user management automation:

  • Backups are automatic but verify them before first production use — the .backup.YYYYMMDD files in each home directory are your rollback path.
  • Test on non-production systems first, particularly the skeleton sync script, since it modifies files in every user’s home directory.
  • Communicate password policy changes to users before enforcement — a 90-day maximum with no prior warning causes unnecessary support tickets.
  • Review logs after every run until you have confidence in the scripts’ behavior on your specific system and user base.
  • UID range 1000–60000 covers standard regular users on most distributions, but verify this matches your environment with awk -F: '$3 >= 1000 {print $1, $3}' /etc/passwd.

Quick Reference

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Run skeleton sync
sudo /usr/local/sbin/user-management/update_skel_to_users.sh

# Run password aging update
sudo /usr/local/sbin/user-management/update_password_aging.sh

# Monitor logs in real time
sudo tail -f /var/log/skel_sync.log
sudo tail -f /var/log/password_aging.log

# Check aging policy for a user
sudo chage -l username

# List scheduled at jobs
sudo atq

# List root cron jobs
sudo crontab -l

# View skeleton directory
ls -la /etc/skel

# Check cron daemon status
sudo systemctl status crond   # RHEL/CentOS
sudo systemctl status cron    # Debian/Ubuntu

Conclusion

Skeleton directory synchronization and password aging enforcement are two of the most commonly overlooked gaps in Linux user management. The scripts in this guide close both gaps with consistent, logged, root-safe automation that respects existing files through automatic backups and skips system accounts through UID range filtering.

For most environments, manual or one-time at scheduling is the right approach — trigger propagation when you actually make a policy change rather than running nightly regardless. Reserve cron scheduling for high-turnover environments where policies shift frequently and the overhead of recurring runs is genuinely justified.

Pair these scripts with the logrotate configuration and you have a complete, low-maintenance solution for keeping all your users in sync with current policy.

Additional Resources


This post is licensed under CC BY 4.0 by the author.