Scott Coffman Logo

Sysadmin automation tips (PowerShell + Bash examples)

December 21, 2025 10 min read

Ticket driven sysadmin automation ideas with short PowerShell and Bash scripts for Active Directory user provisioning, password resets, Linux health checks, and simple reporting.

Sysadmin automation scripts that save you time

Automation usually starts the same way. The same tickets keep landing, you keep running the same checklist, and you still end up doing it manually.

If you want automation that actually helps your team, start with your ticket queue, not a shiny new platform.

Start with the ticket queue

Your ticket system is a list of repeat work. Treat it like one.

  1. Pull the last two to four weeks of tickets.
  2. Group by request type, not severity.
  3. For each group, write down:
    • how often it shows up
    • how long it takes when you do it by hand

Pick the ones that show up weekly or daily. Those are usually the easiest wins.

Also look for requests that block someone from working. Unblocking people often matters more than shaving a few minutes off your own time.

Good first targets

  1. User provisioning and offboarding
  2. Password resets and account unlocks
  3. Routine health checks
  4. Recurring reports, the classic “Can you pull me…?”

PowerShell scripts

Active Directory user provisioning

Account creation is a good target because accuracy matters. The steps are simple, but it is easy to miss something when you are juggling tickets.

This example creates users from a CSV, copies group membership from a template user, generates a temporary password, and forces a password change at next sign in.

Security note: do not email temporary passwords. Use whatever your approved process is.

<#
.SYNOPSIS
Bulk create AD users from a CSV, copy groups from a template user, generate temp passwords, and force password change at next logon.

Outputs a results CSV and optionally emails a run summary with that CSV attached.

Deliver the temp password via an approved channel, then have the user change it at first sign in.

REQUIRES
ActiveDirectory module, permissions to create users and add group membership.

CSV COLUMNS
GivenName,Surname,SamAccountName,Department,Title,ManagerSam,Email
#>

[CmdletBinding()]
param(
  [Parameter(Mandatory = $true)]
  [ValidateScript({ Test-Path $_ })]
  [string]$CsvPath,

  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$DomainUPNSuffix, # example: yourdomain.com

  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$TargetOU, # example: OU=Users,DC=yourdomain,DC=com

  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$TemplateUserSam, # example: jsmith

  [switch]$WhatIfOnly,

  # Notification settings
  [string]$SmtpServer = "smtp.yourdomain.com",
  [int]$SmtpPort = 587,
  [string]$MailFrom = "automation@yourdomain.com",
  [string[]]$MailTo = @("it-team@yourdomain.com"),
  [switch]$UseSsl,
  [PSCredential]$SmtpCredential,
  [switch]$NotifyOnSuccess
)

$ErrorActionPreference = "Stop"

Import-Module ActiveDirectory
Add-Type -AssemblyName System.Web

function New-TempPasswordPlain {
  param(
    [int]$Length = 16,
    [int]$NonAlnum = 3
  )
  [System.Web.Security.Membership]::GeneratePassword($Length, $NonAlnum)
}

function Send-RunEmail {
  param(
    [Parameter(Mandatory = $true)][string]$Subject,
    [Parameter(Mandatory = $true)][string]$Body,
    [Parameter(Mandatory = $true)][string]$AttachmentPath
  )

  $mailParams = @{
    SmtpServer  = $SmtpServer
    Port        = $SmtpPort
    From        = $MailFrom
    To          = ($MailTo -join ",")
    Subject     = $Subject
    Body        = $Body
    Attachments = $AttachmentPath
  }

  if ($UseSsl) { $mailParams["UseSsl"] = $true }
  if ($null -ne $SmtpCredential) { $mailParams["Credential"] = $SmtpCredential }

  Send-MailMessage @mailParams
}

function Get-TrimmedValue {
  param([object]$Value)
  if ($null -eq $Value) { return "" }
  return ($Value.ToString()).Trim()
}

$runId = (Get-Date).ToString("yyyyMMdd_HHmmss")
$resultsPath = Join-Path -Path $PSScriptRoot -ChildPath "ad_user_provision_results_$runId.csv"

$rows = Import-Csv -Path $CsvPath

$templateGroups = Get-ADPrincipalGroupMembership -Identity $TemplateUserSam |
  Where-Object { $_.Name -ne "Domain Users" }

$results = New-Object System.Collections.Generic.List[object]

foreach ($r in $rows) {
  $given = Get-TrimmedValue $r.GivenName
  $sur   = Get-TrimmedValue $r.Surname
  $sam   = Get-TrimmedValue $r.SamAccountName

  $displayName = ("$given $sur").Trim()

  if ([string]::IsNullOrWhiteSpace($sam)) {
    $results.Add([PSCustomObject]@{
      User     = $displayName
      Username = ""
      UPN      = ""
      Status   = "Error"
      Notes    = "Missing SamAccountName"
    })
    continue
  }

  $upn = "$sam@$DomainUPNSuffix"

  try {
    $existing = Get-ADUser -Filter "SamAccountName -eq '$sam'" -ErrorAction SilentlyContinue
    if ($null -ne $existing) {
      $results.Add([PSCustomObject]@{
        User     = $displayName
        Username = $sam
        UPN      = $upn
        Status   = "Skipped"
        Notes    = "User already exists"
      })
      continue
    }

    if ($WhatIfOnly) {
      $results.Add([PSCustomObject]@{
        User     = $displayName
        Username = $sam
        UPN      = $upn
        Status   = "WhatIf"
        Notes    = "Would create user and copy groups from $TemplateUserSam"
      })
      continue
    }

    $tempPasswordPlain = New-TempPasswordPlain
    $tempPassword = ConvertTo-SecureString $tempPasswordPlain -AsPlainText -Force

    $newUserParams = @{
      Name                  = $displayName
      GivenName             = $given
      Surname               = $sur
      DisplayName           = $displayName
      SamAccountName        = $sam
      UserPrincipalName     = $upn
      Department            = Get-TrimmedValue $r.Department
      Title                 = Get-TrimmedValue $r.Title
      Path                  = $TargetOU
      AccountPassword       = $tempPassword
      Enabled               = $true
      ChangePasswordAtLogon = $true
    }

    $email = Get-TrimmedValue $r.Email
    if (-not [string]::IsNullOrWhiteSpace($email)) {
      $newUserParams["EmailAddress"] = $email
    }

    $managerSam = Get-TrimmedValue $r.ManagerSam
    if (-not [string]::IsNullOrWhiteSpace($managerSam)) {
      $mgrDn = (Get-ADUser -Identity $managerSam).DistinguishedName
      $newUserParams["Manager"] = $mgrDn
    }

    New-ADUser @newUserParams

    foreach ($g in $templateGroups) {
      Add-ADGroupMember -Identity $g.DistinguishedName -Members $sam
    }

    $results.Add([PSCustomObject]@{
      User     = $displayName
      Username = $sam
      UPN      = $upn
      Status   = "Created"
      Notes    = "Created user and copied groups from template user. Temp password generated, deliver via approved channel."
    })
  }
  catch {
    $results.Add([PSCustomObject]@{
      User     = $displayName
      Username = $sam
      UPN      = $upn
      Status   = "Error"
      Notes    = $_.Exception.Message
    })
  }
}

$results | Export-Csv -NoTypeInformation -Path $resultsPath

$created = ($results | Where-Object Status -eq "Created").Count
$skipped = ($results | Where-Object Status -eq "Skipped").Count
$whatif  = ($results | Where-Object Status -eq "WhatIf").Count
$errors  = ($results | Where-Object Status -eq "Error").Count

$subjectState = if ($errors -gt 0) { "ERRORS" } else { "OK" }
$subject = "AD provisioning run $subjectState $runId"

$body = @"
AD provisioning run completed.

Created: $created
Skipped: $skipped
WhatIf:  $whatif
Errors:  $errors

Results CSV attached.
"@

if ($errors -gt 0 -or $NotifyOnSuccess) {
  Send-RunEmail -Subject $subject -Body $body -AttachmentPath $resultsPath
}

$results | Format-Table -AutoSize
Write-Host "Results saved to: $resultsPath"

CSV example

GivenName,Surname,SamAccountName,Department,Title,ManagerSam,Email
John,Smith,jsmith,IT,System Administrator,sysadminManager,john.smith@yourdomain.com
Jane,Doe,jdoe,Finance,Analyst,financeManager,jane.doe@yourdomain.com

Password resets and account unlocks

Password resets are common and time sensitive. They are also easy to standardize.

This script resets a password, unlocks the account, and prints clean output you can paste into ticket notes. It forces a password change at next sign in by default. Some environments dislike that, I have seen Horizon prefer $false.

<#
.SYNOPSIS
Reset a domain user's password, unlock the account, and force a password change at next logon by default.

Prints a simple object that is easy to paste into ticket notes.

Deliver the temp password via an approved channel, then have the user change it at first sign in.

REQUIRES
ActiveDirectory module, permissions to reset domain user passwords.
#>

[CmdletBinding()]
param(
  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$SamAccountName,

  [switch]$DoNotForceChangeAtNextLogon,

  [string]$SmtpServer = "smtp.yourdomain.com",
  [int]$SmtpPort = 587,
  [string]$MailFrom = "automation@yourdomain.com",
  [string[]]$MailTo = @("it-team@yourdomain.com"),
  [switch]$UseSsl,
  [PSCredential]$SmtpCredential,
  [switch]$NotifyOnSuccess
)

$ErrorActionPreference = "Stop"

Import-Module ActiveDirectory
Add-Type -AssemblyName System.Web

function Send-Notify {
  param(
    [Parameter(Mandatory = $true)][string]$Subject,
    [Parameter(Mandatory = $true)][string]$Body
  )

  $mailParams = @{
    SmtpServer = $SmtpServer
    Port       = $SmtpPort
    From       = $MailFrom
    To         = ($MailTo -join ",")
    Subject    = $Subject
    Body       = $Body
  }

  if ($UseSsl) { $mailParams["UseSsl"] = $true }
  if ($null -ne $SmtpCredential) { $mailParams["Credential"] = $SmtpCredential }

  Send-MailMessage @mailParams
}

$runId = (Get-Date).ToString("s")

try {
  $newPasswordPlain = [System.Web.Security.Membership]::GeneratePassword(16, 3)
  $newPassword = ConvertTo-SecureString $newPasswordPlain -AsPlainText -Force

  Set-ADAccountPassword -Identity $SamAccountName -NewPassword $newPassword -Reset
  Unlock-ADAccount -Identity $SamAccountName

  $changeAtNext = -not $DoNotForceChangeAtNextLogon
  Set-ADUser -Identity $SamAccountName -ChangePasswordAtLogon $changeAtNext

  $out = [PSCustomObject]@{
    Username          = $SamAccountName
    Action            = "Password reset and account unlocked"
    ChangeAtNextLogon = $changeAtNext
    Timestamp         = $runId
    NextStep          = "Deliver temp password via approved channel"
  }

  if ($NotifyOnSuccess) {
    Send-Notify -Subject "Password reset OK $SamAccountName" -Body ($out | Out-String)
  }

  $out | Format-List

  Write-Host "Temp password generated. Do not paste it into tickets or email. Deliver via approved channel."
}
catch {
  $msg = $_.Exception.Message
  $body = "Password reset FAILED for $SamAccountName at $runId`nError: $msg"
  Send-Notify -Subject "Password reset FAILED $SamAccountName" -Body $body
  throw
}

Bash scripts

Linux health checks that prevent tickets

Some automation does not answer tickets, it reduces how many you get.

Aim for alerts you will actually read. If every run emails noise, people ignore it.

This script checks disk usage, a couple of services, and basic connectivity. It emails only when it finds a problem by default. Set ALWAYS_NOTIFY=1 to email every run.

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

HOSTNAME_SHORT="$(hostname -s)"
NOW="$(date -Is)"
THRESHOLD_PERCENT="${THRESHOLD_PERCENT:-85}"
ALWAYS_NOTIFY="${ALWAYS_NOTIFY:-0}"

MAIL_TO="${MAIL_TO:-it-team@yourdomain.com}"
MAIL_SUBJECT_PREFIX="${MAIL_SUBJECT_PREFIX:-Health check}"

services=("ssh" "cron")
targets=("1.1.1.1" "8.8.8.8")

report="$(mktemp)"
issues=0

{
  echo "Health check for ${HOSTNAME_SHORT}"
  echo "Timestamp: ${NOW}"
  echo

  echo "Disk usage at or above ${THRESHOLD_PERCENT}%:"
  disk_rows="$(df -Ph | awk -v t="${THRESHOLD_PERCENT}" 'NR>1 {u=$5; gsub("%","",u); if (u+0 >= t) print}')"
  if [[ -n "${disk_rows}" ]]; then
    df -Ph | awk 'NR==1 {print}'
    echo "${disk_rows}"
    issues=1
  else
    echo "  None"
  fi
  echo

  echo "Service status:"
  for svc in "${services[@]}"; do
    if systemctl is-active --quiet "${svc}"; then
      echo "  ${svc}: active"
    else
      echo "  ${svc}: NOT active"
      issues=1
    fi
  done
  echo

  echo "Connectivity:"
  for t in "${targets[@]}"; do
    if ping -c 1 -W 1 "${t}" >/dev/null 2>&1; then
      echo "  ${t}: reachable"
    else
      echo "  ${t}: NOT reachable"
      issues=1
    fi
  done
} > "${report}"

subject_state="OK"
if [[ "${issues}" -ne 0 ]]; then
  subject_state="ISSUES"
fi

if [[ "${issues}" -ne 0 || "${ALWAYS_NOTIFY}" -eq 1 ]]; then
  if command -v mail >/dev/null 2>&1; then
    mail -s "${MAIL_SUBJECT_PREFIX} ${subject_state} ${HOSTNAME_SHORT}" "${MAIL_TO}" < "${report}"
  else
    echo "mail command not found, skipping email" >&2
  fi
fi

cat "${report}"
rm -f "${report}"

Simple reporting automation

A lot of tickets are really just, “Can you pull a report?”

Here is a small Linux script that prints recent account related events from common auth logs. Paths vary by distro, so adjust as needed. This version also emails the output when mail is available.

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

DAYS="${DAYS:-14}"
SINCE="$(date -d "${DAYS} days ago" +%F)"

MAIL_TO="${MAIL_TO:-it-team@yourdomain.com}"
MAIL_SUBJECT_PREFIX="${MAIL_SUBJECT_PREFIX:-Account events}"

report="$(mktemp)"

{
  echo "Account related events in the last ${DAYS} days, since ${SINCE}"
  echo

  LOGS=("/var/log/auth.log" "/var/log/secure")
  found_any=0

  for log in "${LOGS[@]}"; do
    if [[ -f "${log}" ]]; then
      found_any=1
      echo "From ${log}"
      grep -E "useradd|new user|password changed|userdel" "${log}" | tail -n 200 || true
      echo
    fi
  done

  if [[ "${found_any}" -eq 0 ]]; then
    echo "No supported auth log found at /var/log/auth.log or /var/log/secure"
  fi
} > "${report}"

if command -v mail >/dev/null 2>&1; then
  mail -s "${MAIL_SUBJECT_PREFIX} ${DAYS}d $(hostname -s)" "${MAIL_TO}" < "${report}"
else
  echo "mail command not found, skipping email" >&2
fi

cat "${report}"
rm -f "${report}"

What I automate and what I skip

I use a quick filter:

  • Start here: tasks that show up weekly or daily and take real time
  • Next: tasks where mistakes cause outages, security problems, or rework
  • Last: rare tasks that are low risk and fast to do manually

If something happens once a year, it usually is not worth a script, unless failure would hurt.

If a script only works once, it is not automation. It is a future ticket.

A few rules that keep this stuff usable:

  1. Do one job per script
  2. Log actions in plain language
  3. Use parameters, do not bake values into the file
  4. Write down required permissions and what good output looks like
  5. Fail loudly with clear errors

If the process keeps changing, fix the process first. A script will not save you from a moving target. If the task is rare and low risk, doing it manually may be fine.

Pick one ticket type that shows up every week, script the most repetitive step, add logging, and write documentation so someone else can run it. That is the kind of automation that holds up later.

Related Posts

About the Author

Scott Coffman is a DevOps and web systems consultant who writes about automation, infrastructure, and building reliable systems.

Learn more at scottcoffman.com .