Automating macOS Storage Cleanup to Kill Low-Disk Tickets
A production-safe launchd and zsh workflow that self-remediates low disk space on managed Macs before users ever open a support ticket.

Operational brief
Operational outcome
A production-safe launchd and zsh workflow that self-remediates low disk space on managed Macs before users ever open a support ticket.
Target Tier
L1 support
Implement within an approved test scope before wider deployment.
Time to set aside
About 45 minutes
Includes configuration, validation and deployment.
Tutorial navigation
The old way of handling a ‘my Mac says storage is almost full’ ticket goes like this: a technician remotes in, opens About This Mac > Storage, waits two minutes for the categories to calculate, manually clears ~/Library/Caches, empties the Trash, maybe deletes an old Xcode DerivedData folder they find by chance, and closes the ticket. Fifteen minutes later the same user is back because nothing was actually monitored, nothing was logged, and the disk fills up again in three weeks. Multiply that by every engineering laptop, video editor workstation, and field sales Mac in the fleet and you have a permanent, low-value queue that never actually gets fixed at the root.
The new way treats free disk space as a metric to be watched continuously, not a support request to be triaged reactively. A signed zsh script running under a LaunchDaemon checks free space on a schedule, purges known-safe cache and temp locations only when a threshold is breached, writes structured evidence to disk, and reports the outcome to your MDM and ticketing system via a webhook. The technician’s job shifts from manual cleanup to reviewing a dashboard of devices that failed to self-remediate. This playbook builds that pipeline using Jamf Pro and Microsoft Intune as the two most common enterprise deployment paths, but the underlying zsh and launchd logic is platform-agnostic.
#Prerequisites and Permissions
You need the following before touching a production fleet:
- Local admin or root execution context on the endpoint. Jamf Pro policies and Intune shell scripts both run as root by default on macOS, which is required to write to /Library/LaunchDaemons and to purge caches owned by other local users.
- Jamf Pro: a Jamf Pro account with permission to create Scripts, Extension Attributes, Policies, and Webhooks (Settings > Global Management > Webhooks). Minimum required privilege set is Create/Read/Update on Policies and Scripts, not full administrator.
- Microsoft Intune: an account with the Intune Administrator or Endpoint Security Manager role sufficient to create and assign macOS shell scripts under Devices > Scripts.
- A test scope of no more than 5 to 10 devices in a pilot Smart Group (Jamf) or Assigned Group (Intune) before any fleet-wide rollout. Never target ‘All Managed Devices’ on the first pass.
- A logging destination or webhook receiver (ticketing system API, Slack webhook, or a simple internal endpoint) capable of accepting a JSON POST.
#Implementation Steps
#Step 1: Write the remediation script
Action: create /usr/local/kby/remediate_storage.zsh on a build reference Mac. The script checks free space against a threshold, and only acts when the threshold is breached, avoiding unnecessary disk churn on healthy machines.
1#!/bin/zsh
2set -e
3
4THRESHOLD_GB=10
5LOG="/var/log/kby-storage-remediation.log"
6STATUS_JSON="/var/log/kby-storage-status.json"
7WEBHOOK_URL="https://hooks.example.com/kby/storage-events"
8SERIAL=$(ioreg -c IOPlatformExpertDevice -d 2 | awk -F'"' '/IOPlatformSerialNumber/{print $4}')
9
10get_free_gb() {
11 df -g / | tail -1 | awk '{print $4}'
12}
13
14FREE_BEFORE=$(get_free_gb)
15echo "$(date -u +%FT%TZ) check freeGB=${FREE_BEFORE}" >> "$LOG"
16
17if [ "$FREE_BEFORE" -ge "$THRESHOLD_GB" ]; then
18 echo "$(date -u +%FT%TZ) no action needed" >> "$LOG"
19 exit 0
20fi
21
22# Safe, reversible cleanup targets only
23find /Library/Caches -mindepth 1 -maxdepth 2 -mtime +7 -exec rm -rf {} + 2>/dev/null || true
24for USERHOME in /Users/*; do
25 UNAME=$(basename "$USERHOME")
26 [ "$UNAME" = "Shared" ] && continue
27 find "$USERHOME/Library/Caches" -mindepth 1 -maxdepth 2 -mtime +7 -exec rm -rf {} + 2>/dev/null || true
28 find "$USERHOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -mtime +3 -exec rm -rf {} + 2>/dev/null || true
29 rm -rf "$USERHOME/.Trash"/* 2>/dev/null || true
30done
31
32FREE_AFTER=$(get_free_gb)
33STATUS="success"
34[ "$FREE_AFTER" -le "$FREE_BEFORE" ] && STATUS="no_change"
35
36cat > "$STATUS_JSON" <<EOF
37{
38 "deviceSerial": "${SERIAL}",
39 "freeSpaceBeforeGB": ${FREE_BEFORE},
40 "freeSpaceAfterGB": ${FREE_AFTER},
41 "thresholdGB": ${THRESHOLD_GB},
42 "status": "${STATUS}",
43 "timestamp": "$(date -u +%FT%TZ)"
44}
45EOF
46
47echo "$(date -u +%FT%TZ) remediation complete before=${FREE_BEFORE} after=${FREE_AFTER}" >> "$LOG"
48
49curl -s -X POST "$WEBHOOK_URL" -H "Content-Type: application/json" --data-binary @"$STATUS_JSON" >> "$LOG" 2>&1 || echo "$(date -u +%FT%TZ) webhook post failed" >> "$LOG"Expected result: the script exits 0 on both the no-action and remediation paths, and non-zero only on unexpected filesystem errors. Evidence to capture: the contents of /var/log/kby-storage-remediation.log and /var/log/kby-storage-status.json after a manual test run with a lowered THRESHOLD_GB value.
#Step 2: Create the LaunchDaemon
Action: install a LaunchDaemon so the check runs automatically every six hours as root, independent of user login state.
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>Label</key>
6 <string>com.kby.storageremediation</string>
7 <key>ProgramArguments</key>
8 <array>
9 <string>/usr/local/kby/remediate_storage.zsh</string>
10 </array>
11 <key>StartInterval</key>
12 <integer>21600</integer>
13 <key>RunAtLoad</key>
14 <true/>
15 <key>StandardOutPath</key>
16 <string>/var/log/kby-storage-launchd.log</string>
17 <key>StandardErrorPath</key>
18 <string>/var/log/kby-storage-launchd.log</string>
19</dict>
20</plist>Save this as /Library/LaunchDaemons/com.kby.storageremediation.plist with owner root:wheel and permissions 644, then load it:
1sudo chown root:wheel /Library/LaunchDaemons/com.kby.storageremediation.plist
2sudo chmod 644 /Library/LaunchDaemons/com.kby.storageremediation.plist
3sudo launchctl bootstrap system /Library/LaunchDaemons/com.kby.storageremediation.plist
4sudo launchctl enable system/com.kby.storageremediationExpected result: sudo launchctl print system/com.kby.storageremediation returns state = running or waiting, with no exit code errors. Evidence to capture: a screenshot or text output of that print command and the first entry in kby-storage-launchd.log.
#Step 3: Package for Jamf Pro deployment
Action: create a Jamf Pro Script object that bundles the installer logic (writes the zsh script and plist to disk, then loads the LaunchDaemon), and attach it to a Policy scoped to your pilot Smart Group with a recurring check-in trigger.
1#!/bin/zsh
2# Jamf Pro payload script: deploy_storage_remediation.zsh
3mkdir -p /usr/local/kby
4curl -s -o /usr/local/kby/remediate_storage.zsh "https://cdn.example.com/kby/remediate_storage.zsh"
5chmod 755 /usr/local/kby/remediate_storage.zsh
6curl -s -o /Library/LaunchDaemons/com.kby.storageremediation.plist "https://cdn.example.com/kby/com.kby.storageremediation.plist"
7chown root:wheel /Library/LaunchDaemons/com.kby.storageremediation.plist
8chmod 644 /Library/LaunchDaemons/com.kby.storageremediation.plist
9launchctl bootstrap system /Library/LaunchDaemons/com.kby.storageremediation.plist 2>/dev/null || true
10launchctl enable system/com.kby.storageremediation
11exit 0Expected result: the Jamf Pro policy log shows exit code 0 for every targeted device. Evidence to capture: the policy’s completion report exported as CSV from Jamf Pro.
#Step 4: Add a Jamf Pro Extension Attribute
Action: create an Extension Attribute of type String that reads the status JSON so free space and remediation outcome are visible in device inventory and usable in Smart Group criteria.
1#!/bin/zsh
2STATUS_JSON="/var/log/kby-storage-status.json"
3if [ -f "$STATUS_JSON" ]; then
4 FREE_AFTER=$(awk -F': ' '/freeSpaceAfterGB/{gsub(",","",$2); print $2}' "$STATUS_JSON")
5 echo "<result>${FREE_AFTER}</result>"
6else
7 echo "<result>unknown</result>"
8fiExpected result: the inventory record for a test device populates a numeric value within one inventory update cycle. Evidence to capture: a screenshot of the device’s Extension Attribute field in Jamf Pro inventory.
#Step 5: Deploy the equivalent via Microsoft Intune
Action: for tenants standardised on Intune, upload the same deploy_storage_remediation.zsh under Devices > Scripts > Add for macOS, running as root, assigned to the pilot device group. Intune shell scripts run once per check-in cycle rather than persistently, which is precisely why the LaunchDaemon installed inside the script is what provides ongoing enforcement, not the Intune script trigger itself.
Expected result: script run status shows Success in the Intune script run report. Evidence to capture: the per-device run status export from the Intune admin centre.
#Step 6: Wire the webhook to your ticketing system
Action: point the WEBHOOK_URL variable at your ticketing platform’s inbound API or an intermediary function so every remediation event creates a low-priority audit record rather than a ticket, and only escalates when status is not success.
1{
2 "deviceSerial": "C02XXXXXXXX",
3 "freeSpaceBeforeGB": 6,
4 "freeSpaceAfterGB": 18,
5 "thresholdGB": 10,
6 "status": "success",
7 "timestamp": "2024-05-14T09:12:03Z"
8}Expected result: the receiving endpoint returns HTTP 200 and the event appears in the ticketing system’s audit log, not the active queue. Evidence to capture: the HTTP response code logged in kby-storage-remediation.log.
#Step 7: Publish a Self Service option for immediate user control
Action: expose the same Jamf Pro policy in Self Service with a friendly name such as ‘Free Up Disk Space Now’, bypassing the schedule for users who need instant relief without opening a ticket.
Expected result: user-triggered runs appear in the Jamf Pro policy log with the same success criteria as scheduled runs. Evidence to capture: policy log entry showing trigger type ‘Self Service’.
#Verification and Expected Evidence
Confirm the pipeline is working end to end using these checks:
- On a pilot device with THRESHOLD_GB temporarily lowered to a value above current free space, run the script manually and confirm /var/log/kby-storage-status.json shows status success with freeSpaceAfterGB greater than freeSpaceBeforeGB.
- Confirm
sudo launchctl print system/com.kby.storageremediationshows the job as loaded and its last exit status as 0. - Confirm the Jamf Pro or Intune inventory record reflects the Extension Attribute or script run status within one inventory cycle.
- Confirm the webhook receiver logged the JSON payload with a 200 response.
#Rollback
Blast radius is limited to cache and temp directories that macOS and applications regenerate automatically; no user documents, photos, or application binaries are touched. To roll back the automation itself rather than its effects:
1sudo launchctl bootout system/com.kby.storageremediation
2sudo rm -f /Library/LaunchDaemons/com.kby.storageremediation.plist
3sudo rm -f /usr/local/kby/remediate_storage.zshRollback trigger: unbootstrap and remove the LaunchDaemon fleet-wide if more than 2 percent of pilot devices report status values other than success or no_change within 24 hours, or if any help desk ticket links application data loss to the remediation window.
#Failure and Escalation Conditions
The following conditions should wake a human technician rather than be left to the automation:
- A device reports freeSpaceAfterGB still below THRESHOLD_GB after three consecutive remediation cycles, indicating a non-cache storage problem such as a large Time Machine local snapshot, Photos library, or Boot Camp partition that requires manual triage.
- The webhook receiver logs three consecutive non-200 responses from the same device, indicating a network or endpoint configuration fault rather than a disk issue.
- Any exit code other than 0 or 1 from the script, which indicates an unexpected filesystem or permissions error outside the tested paths.
#Measuring Ticket Deflection
Baseline the current monthly count of tickets tagged ‘disk space’ or ‘storage full’ in your ticketing system before rollout. After the pilot group has run for two full weeks, compare ticket volume for the same device population against the two weeks prior. Track the ratio of webhook events with status success against total scoped devices as your automation coverage rate, and track any residual tickets from devices already running the LaunchDaemon as your true escalation rate, since that number represents genuine storage problems the automation correctly declined to touch.
Evidence trail
Sources and verification
Vendor documentation and external technical references used to verify this playbook.
Advanced suggested reading
Ready to go deeper?
These articles come from KBY’s senior engineering editorial. Expect denser architecture, fewer introductory explanations and deeper production trade-offs.
DevOps & Automation
Ephemeral Preview Environments: Namespace-per-PR
How namespace-per-PR ephemeral preview environments handle TTL cleanup, wildcard ingress, resource quotas, and DB isolation without leaking cluster capacity.
Software Architecture
The Multi-Cluster Networking Decision
A practical comparison of hub-and-spoke, full mesh, and transit gateway topologies for multi-cluster Kubernetes, plus the CNI, MTU, and routing decisions that determine whether any of them hold up in production.
Comments
Add a thoughtful note on Automating macOS Storage Cleanup to Kill Low-Disk Tickets. Comments are checked for spam and held for moderation before appearing.