Sitemap
Level Up Coding

Coding tutorials and news. The developer homepage gitconnected.com && skilled.dev && levelup.dev

How I automated encrypted Proton Pass backups on my Mac (before I forget how I did it)

Turning late-night password paranoia into a set-it-and-forget-it automated routine with pass-cli and age.

18 min read4 hours ago

--

Press enter or click to view image in full size

Let’s be honest: we all love preaching the 3–2–1 backup rule until we realize our digital keys live entirely in the cloud of our password manager.

Don’t get me wrong — Proton Pass is great. Its zero-knowledge encryption model gives peace of mind, but the paranoid sysadmin in me kept asking the same question late at night: “What if I ever lose access to my account, forget a passphrase, or just want an offline, cold, encrypted local copy on my own drive?”

For the longest time, the only way to back up Proton Pass was manual: open the desktop app or browser extension, click export (staring nervously at the unencrypted plaintext prompt), type your master password, and save a raw JSON file to your desktop. Zero automation. Zero consistency.

The turning point came around October 2024, when Proton released the official Proton Pass CLI (pass-cli) along with its background agent daemon (pass agent). That finally unlocked persistent local sessions and made headless, automated backups possible.

Since I know myself — and I know that six months from now I’ll switch machines and wonder “how on earth did I set that up?” — I’m documenting the whole setup step by step.

The Challenge: Security vs. Automation

When you automate password backups, three immediate problems arise:

  1. Unattended Authentication: Nobody wants a background job that wakes you up at 3:00 AM asking for a master password or a 2FA prompt. Once you log in, pass-cli keeps the session stored locally, so the script can run unattended.
  2. The Plaintext Problem: Leaving an unencrypted JSON dump sitting on your drive defeats the purpose of using a password manager. Each item must go straight from pass-cli into the encryption step through a pipe, so the plaintext never touches your disk.
  3. Scale and fidelity: With thousands of entries and file attachments, re-reading everything on every change is painfully slow, so the backup has to be incremental. It should also mirror what you see in Proton Pass (vaults, folders, trash) and follow your deletions, without ever letting a glitch wipe your backup.

Choosing the Encryption Tool: Why age Wins

Before writing the script, I weighed the standard encryption options:

  1. GPG (GnuPG): The battle-tested veteran. It works, but managing keyrings, trust levels, and subkeys for a simple background script can quickly turn into a headache.
  2. Encrypted macOS APFS Disk Images (hdiutil): Native to macOS with zero third-party dependencies, but mounting, unmounting, and handling disk locks via script is clunky.
  3. OpenSSL (openssl enc): Preinstalled everywhere, but flag syntax and key derivation defaults frequently shift across LibreSSL and OpenSSL versions, risking restore issues down the road.
  4. age (Actually Good Encryption): The clear winner.

Why age?

age is a modern, lightweight encryption tool built specifically to replace GPG for everyday scripts and file encryption. There are no complicated keyrings: you generate a single key pair, use the public key to encrypt (which is safe to embed right inside your script), and store the private key safely offline. It’s clean, fast, and dead simple.

Step 1: Install the Tools

Open Terminal on your Mac.

If you use Homebrew:

# 1. Install Proton Pass CLI
brew install protonpass/tap/pass-cli

# (Alternatively, you can use the official installer script)
# curl -fsSL https://proton.me/download/pass-cli/install.sh | bash

# 2. Install age (encryption) and jq (JSON parsing)
brew install age jq

Verify that the commands are accessible:

pass-cli --version
age --version
jq --version

The script also needs a recent pass-cli, one that can list trashed items. Check that this prints a line mentioning — filter-state; if it prints nothing, update pass-cli:

pass-cli item list --help | grep filter-state

Step 2: Generate an age Key Pair

Create a dedicated key pair for your backups:

# Create a secure directory for the key
mkdir -p ~/.config/age
chmod 700 ~/.config/age
# Generate your key pair
age-keygen -o ~/.config/age/protonpass_key.txt

Open the file or view it in the terminal (cat ~/.config/age/protonpass_key.txt). You will see two lines:

  • # public key: age1ql3... (Copy this public key—you'll paste it into your script to encrypt files)
  • AGE-SECRET-KEY-1... (This is your private key—store a copy somewhere safe offline, such as a physical printout or an external drive!)

Step 3: Authenticate the CLI and Start the Agent

If you look at older documentation, you might see mentions of starting a background daemon with pass-cli agent start. You can forget about that! Proton recently made things even smoother: the CLI now integrates directly with the native macOS Keychain.

Connect the CLI to your Proton account:

pass-cli login

Follow the browser prompt to authenticate. Once logged in, your session token is securely locked in your Mac’s Keychain. It stays persistent, meaning our automated script will be able to run silently in the background without ever prompting you for a password again.

Let’s run a quick test to make sure the CLI can read your vaults:

pass-cli vault list --output json

If the session ever expires (or you log out), the backup script shows a macOS notification asking you to run pass-cli login again.

Step 4: The Incremental Backup Script

Now we need the script that does the heavy lifting. Three facts about pass-cli shape its design: pass-cli item list returns only metadata (title, type, dates, vault, folder), never usernames or passwords. Credentials come only from pass-cli item view, one item at a time. pass-cli export archive can dump everything, but always the whole lot in a single HTML file. That is fine as a weekly safety net, and far too slow and heavy to run on every change with thousands of items and attachments. So the script reads the cheap metadata list, works out what changed, and only opens items that are new or modified. The first run reads everything and takes a while; after that a run costs a few seconds. The only extra dependency is jq, which parses the JSON reliably even with thousands of items.

Here is what the script will do:

  1. Change detection: One item list call per vault, for active and for trashed items. Each item’s metadata row (including its modification time) is hashed with SHA-256 and compared with the hash saved on the previous run.
  2. Incremental reads: Only new or modified items are opened with pass-cli item view, up to 4 in parallel. If nothing changed, a run takes seconds.
  3. Mirrored structure: Every item becomes its own encrypted file, stored under the same vault and folder tree you see in Proton Pass. The last 5 versions of each item are kept.
  4. Trash: Trashed items are backed up too, in a parallel trash/ tree. If you restore one, its backup moves back automatically. Renames and moves: If an item is renamed, moved to another folder, trashed or restored, its existing backup is moved, not downloaded again.
  5. Deletions: Items permanently deleted in Proton Pass are moved to deleted/<date>/ and purged after 90 days. A safety brake refuses to touch anything if more than 30% of your items suddenly look deleted, which points to an API hiccup rather than a real cleanup.
  6. Attachments: Downloaded once, encrypted, and stored next to the item they belong to.
  7. Safety: A lock prevents overlapping runs, an item is marked as backed up only after it was saved successfully (failures are retried next time), and you get a macOS notification if the session has expired.
  8. No plaintext on disk: Item data goes straight from pass-cli into age through a pipe. Only attachments pass briefly through a private temporary folder.

Let’s create the script:

mkdir -p ~/Scripts
nano ~/Scripts/ProtonPass_Backup.sh

Paste the following code.

⚠️ Keep in mind!
-
Replace the AGE_RECIPIENT placeholder (age1INSERT_YOUR_PUBLIC_KEY) with your actual public key from Step 2.
- DEST_DIR defaults to ~/Backups/ProtonPass: change it at the top of the script if you prefer another folder.

#!/usr/bin/env bash
# Incremental Proton Pass backup that mirrors your vault/folder tree.
# One age-encrypted file per item version, written only when the item changed.
#
# Usage: ProtonPass_Backup.sh -> incremental backup
# ProtonPass_Backup.sh --full -> full encrypted snapshot (HTML archive + attachments)
#
# Resulting layout:
# $DEST_DIR/items/<Vault>/<Folder>/<Subfolder>/<Title> [<item_id>]/<timestamp>.json.age
# $DEST_DIR/items/.../<Title> [<item_id>]/attachments/<attachment_id>.age
# $DEST_DIR/trash/<same tree> items currently in the Proton Pass trash
# $DEST_DIR/deleted/<YYYY-MM-DD>/... items that disappeared from Proton Pass
# (kept DELETED_RETENTION_DAYS, then purged)
#
# Sync rules:
# - renamed / moved / trashed / restored items are relocated in the backup (no re-download)
# - items permanently deleted in Proton Pass are moved to deleted/, NOT erased immediately,
# so an accidental deletion (or a partial API failure) can never wipe your backup
# - safety brake: if too many items look deleted in a single run, nothing is moved
#

set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"

DEST_DIR="${DEST_DIR:-$HOME/Backups/ProtonPass}"
STATE_DIR="${STATE_DIR:-$HOME/.local/state/protonpass_backup}"
AGE_RECIPIENT="${AGE_RECIPIENT:-age1INSERT_YOUR_PUBLIC_KEY}"
KEEP_VERSIONS=5 # versions kept per item
PARALLEL=4 # concurrent pass-cli calls (don't go too high: rate limits)
DELETED_RETENTION_DAYS=90 # how long items deleted from Proton Pass stay in deleted/
MAX_DELETE_PCT=30 # safety brake: refuse to move more than this % of items at once

IDX="$STATE_DIR/index" # one file per item: <sig> TAB <state> TAB <relative dir>

# jq filters (adjust if a future pass-cli changes its JSON output);
JQ_VAULTS='(if type=="array" then . else (.vaults // []) end)[]
| "\(.share_id)\t\((.name // .content.name // .share_id) | tostring | gsub("[\\p{Cc}]"; "_"))"'

JQ_FOLDERS='(if type=="array" then . else (.folders // []) end)
| map({key: ((.folder_id // .id) | tostring),
value: {n: ((.content.name // .name // .title // "folder") | tostring),
p: ((.parent_folder_id // .parent_id // "") | tostring)}})
| from_entries'

# Output per item: <safe key> TAB <item id> TAB <relative dir> TAB <list entry as compact JSON>
JQ_LINES='
def clean: tostring | gsub("[\\p{Cc}/\\\\:]"; "_") | sub("^\\.+"; "_") | .[0:80];
def fpath($id; $d):
if $d > 20 or $id == "" then []
else ($fm[$id]) as $f
| if $f == null then ["_unknown_folder"] else (fpath($f.p; $d + 1) + [$f.n | clean]) end
end;
(if type=="array" then . else (.items // []) end)[]
| (.id | tostring) as $id
| ((.folder_id // .item.folder_id // "") | tostring) as $fid
| ((.content.title // .title // $id) | clean) as $t
| ([$vn | clean] + fpath($fid; 0) + [$t + " [" + ($id | clean) + "]"] | join("/")) as $rel
| "\($id | gsub("[^A-Za-z0-9._=-]"; "_"))\t\($id)\t\($rel)\t\(del(.folder_id, .state) | tojson)"'

LOG_OUT="${LOG_OUT:-/tmp/protonpass_backup.log}"
LOG_ERR="${LOG_ERR:-/tmp/protonpass_backup.err}"
if [[ "${1:-}" == "--full" ]]; then
LOG_OUT="/tmp/protonpass_backup_full.log"
LOG_ERR="/tmp/protonpass_backup_full.err"
fi
ERR_MSG=""

log() { echo "[$(date '+%F %T')] $*"; }
safe() { printf '%s' "$1" | tr -c 'A-Za-z0-9._=-' '_'; }
root_of() { if [[ "$1" == "trashed" ]]; then echo "trash"; else echo "items"; fi; }
notify() {
local msg="$1"
local target_log="$LOG_ERR"
[[ -s "$target_log" ]] || target_log="$LOG_OUT"

# Play an audible system alert sound
afplay /System/Library/Sounds/Sosumi.aiff >/dev/null 2>&1 &

# Display a modal window in front of other apps and open logs if requested
local choice
choice=$(osascript \
-e 'on run argv' \
-e ' beep 2' \
-e ' tell application "System Events"' \
-e ' activate' \
-e ' set res to display alert "Proton Pass Backup — Error" message (item 1 of argv) as critical buttons {"Close", "Open Logs"} default button "Open Logs"' \
-e ' return button returned of res' \
-e ' end tell' \
-e 'end run' \
-- "$msg" 2>/dev/null || true)

if [[ "$choice" == "Open Logs" ]]; then
if [[ -e "$target_log" ]]; then
open -R "$target_log" >/dev/null 2>&1 || open "$(dirname "$target_log")" >/dev/null 2>&1 || true
else
open "$(dirname "$LOG_ERR")" >/dev/null 2>&1 || true
fi
fi
}

# ---------------------------------------------------------------------------
# WORKER: back up a single item (invoked by xargs). The item JSON is piped
# straight into age, so it never touches the disk in plaintext.
# ---------------------------------------------------------------------------
if [[ "${1:-}" == "--worker" ]]; then
sid=$2; iid=$3; sig=$4; state=$5; rel=$6
ksid=$(safe "$sid"); key=$(safe "$iid")
dir="$DEST_DIR/$(root_of "$state")/$rel"
tmp=$(mktemp -d); chmod 700 "$tmp"; trap 'rm -rf "$tmp"' EXIT

json=$(pass-cli item view --share-id="$sid" --item-id="$iid" --output=json)
[[ -n "$json" ]] || { echo "item $iid: empty response" >&2; exit 1; }

# Attachments: identified by id and immutable -> downloaded only once.
# They live inside the item's own folder, so they follow it when it is moved.
while read -r aid; do
[[ -n "$aid" ]] || continue
out="$dir/attachments/$(safe "$aid").age"
[[ -f "$out" ]] && continue
mkdir -p "$dir/attachments"
pass-cli item attachment download --share-id="$sid" --item-id="$iid" \
--attachment-id="$aid" --output="$tmp/$aid"
age -r "$AGE_RECIPIENT" -o "$out.part" "$tmp/$aid"
mv "$out.part" "$out"
rm -f "$tmp/$aid"
done < <(printf '%s' "$json" | jq -r '[.. | objects | select(has("attachments")) | .attachments[]? | .id] | unique[]' 2>/dev/null || true)

mkdir -p "$dir"
ts=$(date -u +%Y%m%dT%H%M%SZ)
printf '%s' "$json" | age -r "$AGE_RECIPIENT" -o "$dir/$ts.json.age.part"
mv "$dir/$ts.json.age.part" "$dir/$ts.json.age"

# Update the index ONLY after a successful save: on failure the item is retried next run
mkdir -p "$IDX/$ksid"
printf '%s\t%s\t%s\n' "$sig" "$state" "$rel" > "$IDX/$ksid/$key"

# Keep only the latest N versions
ls -1t "$dir"/*.json.age | tail -n +$((KEEP_VERSIONS + 1)) | xargs -I{} rm -f {} || true
exit 0
fi

# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
mkdir -p "$DEST_DIR" "$STATE_DIR" "$IDX"
chmod 700 "$DEST_DIR" "$STATE_DIR"

# Lock to prevent overlapping runs (WatchPaths and StartInterval can collide)
LOCK="$STATE_DIR/lock"
if ! mkdir "$LOCK" 2>/dev/null; then
if [[ -n "$(find "$LOCK" -maxdepth 0 -mmin +120)" ]]; then # stale lock
rmdir "$LOCK"; mkdir "$LOCK"
else
log "Already running, exiting."; exit 0
fi
fi
WORK=$(mktemp -d); chmod 700 "$WORK"
cleanup() {
local exit_code=$?
rm -rf "$WORK" 2>/dev/null || true
rmdir "$LOCK" 2>/dev/null || true

if [[ $exit_code -ne 0 ]]; then
log "CRITICAL: Script exited with error code $exit_code." >&2
# Trigger audible macOS alert dialog on failure with log path
notify "${ERR_MSG:-Backup incomplete!}"$'\n\n'"Logs: $LOG_ERR"
fi
}
trap cleanup EXIT

# Valid session? If not, notify instead of failing silently
if ! pass-cli info >/dev/null 2>&1; then
ERR_MSG="pass-cli session expired: run 'pass-cli login'"
log "ERROR: not authenticated." >&2; exit 1
fi

# --- Full snapshot (safety net, e.g. once a week) ----------------------------
if [[ "${1:-}" == "--full" ]]; then
mkdir -p "$DEST_DIR/full"
pass-cli export archive --include-attachments -o "$WORK/archive.html" # includes trashed items
age -r "$AGE_RECIPIENT" -o "$DEST_DIR/full/archive_$(date +%F).html.age" "$WORK/archive.html"
find "$DEST_DIR/full" -name 'archive_*.html.age' -mtime +60 -delete
log "Full snapshot created."; exit 0
fi

# The trash needs `item list --filter-state`: fail early on an old pass-cli
if ! pass-cli item list --help 2>&1 | grep -q -- '--filter-state'; then
log "ERROR: this pass-cli has no --filter-state option. Update it (brew upgrade pass-cli)."
exit 1
fi

TODO="$WORK/todo"; SEEN="$WORK/seen"; ALLSIDS="$WORK/allsids"; OKSIDS="$WORK/oksids"
: > "$TODO"; : > "$SEEN"; : > "$ALLSIDS"; : > "$OKSIDS"
n_todo=0; n_reloc=0; n_cand=0; rc=0

relocate() { # old_state old_rel new_state new_rel -> 0 if moved
local src="$DEST_DIR/$(root_of "$1")/$2" dst="$DEST_DIR/$(root_of "$3")/$4"
[[ -d "$src" ]] || return 1
[[ -e "$dst" ]] && return 1 # destination busy: fall back to a fresh backup there
mkdir -p "$(dirname "$dst")" && mv "$src" "$dst"
}

# --- 1. List vaults, folders and items (active + trashed) --------------------
VJSON=$(pass-cli vault list --output json) || { log "ERROR: cannot list vaults."; exit 1; }
VAULTS=$(printf '%s' "$VJSON" | jq -r "$JQ_VAULTS")
[[ -n "$VAULTS" ]] || { log "ERROR: no vaults found."; exit 1; }

while IFS=$'\t' read -r sid vname; do
ksid=$(safe "$sid"); echo "$ksid" >> "$ALLSIDS"
mkdir -p "$IDX/$ksid"

# Folder tree of this vault -> {folder_id: {n: name, p: parent_id}}
fm_failed=0
if fout=$(pass-cli folder list --share-id="$sid" --output=json 2>"$WORK/ferr") \
&& FM=$(printf '%s' "$fout" | jq -c "$JQ_FOLDERS" 2>>"$WORK/ferr") && [[ -n "$FM" ]]; then
:
else
FM='{}'; fm_failed=1
log "WARNING: cannot read folders of vault '$vname': $(head -c 200 "$WORK/ferr" | tr '\n' ' ')"
fi

vault_ok=1
for state in active trashed; do
if ! out=$(pass-cli item list --share-id="$sid" --filter-state="$state" --output=json 2>/dev/null); then
log "WARNING: cannot list $state items of vault '$vname'; deletions skipped for it."
vault_ok=0; rc=1; continue
fi
# If folders could not be read but items do live in folders, skip the vault this run
# instead of filing everything at the vault root (it would be moved back later)
if [[ "$fm_failed" -eq 1 ]] && printf '%s' "$out" | jq -e '(if type=="array" then . else (.items // []) end) | any(.[]; ((.folder_id // "") | tostring) != "")' >/dev/null 2>&1; then
log "WARNING: vault '$vname' has items in folders but its folders could not be read: skipped."
vault_ok=0; rc=1; continue
fi
if ! lines=$(printf '%s' "$out" | jq -r --argjson fm "$FM" --arg vn "$vname" "$JQ_LINES"); then
log "WARNING: unexpected JSON for $state items of vault '$vname'; deletions skipped for it."
vault_ok=0; rc=1; continue
fi
[[ -n "$lines" ]] || continue

while IFS=$'\t' read -r key iid rel json; do
echo "$ksid/$key" >> "$SEEN"
sig=$(printf '%s' "$json" | shasum -a 256 | cut -d' ' -f1)

osig=""; ostate=""; orel=""
if [[ -f "$IDX/$ksid/$key" ]]; then
IFS=$'\t' read -r osig ostate orel < "$IDX/$ksid/$key"
fi

need=0
if [[ -z "$osig" ]]; then
need=1 # new item
else
if [[ "$ostate" != "$state" || "$orel" != "$rel" ]]; then
# Moved / renamed / trashed / restored: relocate the existing backup
if relocate "$ostate" "$orel" "$state" "$rel"; then
printf '%s\t%s\t%s\n' "$osig" "$state" "$rel" > "$IDX/$ksid/$key"
n_reloc=$((n_reloc + 1))
else
need=1
fi
fi
[[ "$osig" == "$sig" ]] || need=1 # content changed
fi

if [[ "$need" -eq 1 ]]; then
printf '%s\0' "$sid" "$iid" "$sig" "$state" "$rel" >> "$TODO"
n_todo=$((n_todo + 1))
fi
done <<< "$lines"
done
[[ "$vault_ok" -eq 1 ]] && echo "$ksid" >> "$OKSIDS"
done <<< "$VAULTS"

# --- 2. Only new/modified items, in parallel ---------------------------------
if [[ "$n_todo" -gt 0 ]]; then
log "$n_todo item(s) to back up, $n_reloc relocated."
# Run workers through bash explicitly: no dependency on the executable bit or on $0 being a path
if ! xargs -0 -n 5 -P "$PARALLEL" bash "$0" --worker < "$TODO"; then
log "WARNING: some items were not saved, they will be retried on the next run."
rc=1
fi
elif [[ "$n_reloc" -gt 0 ]]; then
log "No content changes, $n_reloc item(s) relocated."
fi

# --- 3. Deletions: items no longer present in Proton Pass --------------------
# Known = in the index; seen = returned by the listings of this run.
KNOWN="$WORK/known"; SEENS="$WORK/seen.sorted"; CAND="$WORK/cand"
( cd "$IDX" && find . -mindepth 2 -type f | sed 's#^\./##' ) | LC_ALL=C sort > "$KNOWN"
LC_ALL=C sort -u "$SEEN" > "$SEENS"
LC_ALL=C comm -23 "$KNOWN" "$SEENS" > "$CAND"

# Keep only candidates from vaults that were listed completely, or vaults that vanished
: > "$WORK/cand.ok"
while IFS= read -r k; do
[[ -n "$k" ]] || continue
ksid=${k%%/*}
if grep -qxF -- "$ksid" "$ALLSIDS" && ! grep -qxF -- "$ksid" "$OKSIDS"; then continue; fi
echo "$k" >> "$WORK/cand.ok"
done < "$CAND"

n_cand=$(grep -c . "$WORK/cand.ok" || true)
n_known=$(grep -c . "$KNOWN" || true)

if (( n_cand > 25 && n_cand * 100 > n_known * MAX_DELETE_PCT )); then
log "WARNING: $n_cand of $n_known items look deleted. Safety brake: nothing moved." >&2
ERR_MSG="Backup safety brake: $n_cand items look deleted, skipped. Check pass-cli."
rc=1
elif (( n_cand > 0 )); then
TODAY=$(date +%F)
while IFS= read -r k; do
[[ -n "$k" ]] || continue
IFS=$'\t' read -r _ ostate orel < "$IDX/$k"
src="$DEST_DIR/$(root_of "$ostate")/$orel"
if [[ -d "$src" ]]; then
dst="$DEST_DIR/deleted/$TODAY/$(root_of "$ostate")/$orel"
[[ -e "$dst" ]] && dst="$dst.$(date +%s)"
mkdir -p "$(dirname "$dst")" && mv "$src" "$dst"
fi
rm -f "$IDX/$k"
done < "$WORK/cand.ok"
log "$n_cand deleted item(s) moved to deleted/$TODAY."
fi

# --- 4. Housekeeping ----------------------------------------------------------
if [[ -d "$DEST_DIR/deleted" ]]; then
find "$DEST_DIR/deleted" -mindepth 1 -maxdepth 1 -type d -mtime +"$DELETED_RETENTION_DAYS" -exec rm -rf {} +
fi
for d in "$DEST_DIR/items" "$DEST_DIR/trash"; do
[[ -d "$d" ]] && find "$d" -mindepth 1 -type d -empty -delete 2>/dev/null || true
done
find "$IDX" -mindepth 1 -maxdepth 1 -type d -empty -delete 2>/dev/null || true

if [[ "$rc" -eq 0 && "$n_todo" -eq 0 && "$n_reloc" -eq 0 && "$n_cand" -eq 0 ]]; then log "No changes."; fi
exit "$rc"

Save and exit.

Get Riccardo Mestrone’s stories in your inbox

Join Medium for free to get updates from this writer.

Now, make the script executable and run a manual test:

chmod +x ~/Scripts/ProtonPass_Backup.sh
~/Scripts/ProtonPass_Backup.sh

The first run opens every item once, so with large vaults it can take a while. Check the result with:

find ~/Backups/ProtonPass -type f | head

You should see one folder per item, mirroring your vaults and folders, each holding an encrypted .json.age file. Run the script a second time right away: it should just print

No changes.

We now have an intelligent, self-sufficient backup script.

What the backup looks like

~/Backups/ProtonPass/
├── items/ <Vault>/<Folder>/<Subfolder>/<Item title> [<item id>]/<timestamp>.json.age
│ (attachments live in an attachments/ folder inside each item folder)
├── trash/ same tree, for items currently in the Proton Pass trash
├── deleted/ <date>/ ... items permanently deleted in Proton Pass, purged after 90 days
└── full/ weekly encrypted archive (see Step 5b)

Renaming or moving an item in Proton Pass moves its backup folder; restoring from the trash moves it back to items/.

It’s time to put it on autopilot.

Step 5: Putting It on Autopilot with macOS

If we were on a Linux server, we would instinctively reach for cron. But on a Mac laptop, cron is notoriously unreliable—if the lid is closed at the scheduled time, the job just skips. Instead, macOS has a built-in automation superpower called launchd.

Using launchd, we can make our backup completely event-driven rather than just relying on a dumb timer. By giving it a few specific triggers, we cover every possible scenario:

  1. RunAtLoad: Fires the script the moment you turn on your Mac or log in.
  2. WatchPaths: We tell macOS to watch the folder where the Proton Pass desktop app stores its local data. When you change a password from the desktop app, macOS usually detects the disk write and fires our script. Changes made from other devices are caught by the heartbeat below.
  3. StartInterval: A 15-minute heartbeat. If your Mac is just sitting idle on your desk and you update a password from your iPhone, this periodic check ensures the desktop pulls the latest cloud sync and backs it up.

Let’s create the LaunchAgent configuration file:

nano ~/Library/LaunchAgents/com.user.protonpassbackup.plist

Paste the following XML code.

⚠️ Important! Replace YOUR_USERNAME (twice) with your actual macOS short username (you can check it by typing whoami in the terminal).

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.protonpassbackup</string>

<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOUR_USERNAME/Scripts/ProtonPass_Backup.sh</string>
</array>

<!-- 1. Run immediately at system startup / user login -->
<key>RunAtLoad</key>
<true/>

<!-- 2. Monitor filesystem writes to Proton Pass desktop app database -->
<key>WatchPaths</key>
<array>
<string>/Users/YOUR_USERNAME/Library/Application Support/Proton Pass/Partitions/app</string>
</array>

<!-- 3. 15-minute heartbeat to catch remote updates from phone or browser extension -->
<key>StartInterval</key>
<integer>900</integer>

<!-- Minimum 15 seconds between two launches (a rate limit, not a true debounce) -->
<key>ThrottleInterval</key>
<integer>15</integer>

<!-- Execution and error logging for easy debugging -->
<key>StandardOutPath</key>
<string>/tmp/protonpass_backup.log</string>
<key>StandardErrorPath</key>
<string>/tmp/protonpass_backup.err</string>
</dict>
</plist>

Save and exit. Now, load the agent into the system so it starts running silently in the background:

# Restart the background service: discard any errors if the old version wasn't loaded (2>/dev/null || true), then load the new one.
launchctl unload ~/Library/LaunchAgents/com.user.protonpassbackup.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/com.user.protonpassbackup.plist

Step 5b: A weekly full snapshot

The incremental backup is the workhorse, but I also keep a complete encrypted archive as a safety net. It is a single HTML file produced by pass-cli export archive, with attachments and trashed items included, encrypted with age. Create a second LaunchAgent:

nano ~/Library/LaunchAgents/com.user.protonpassbackup.full.plist

Paste the following XML code. Important: Replace YOUR_USERNAME with your actual macOS short username (you can check it by typing whoami in the terminal).

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.protonpassbackup.full</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOUR_USERNAME/Scripts/ProtonPass_Backup.sh</string>
<string>--full</string>
</array>
<!-- Every Sunday at 12:00 (if the Mac is asleep, it runs on wake) -->
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>0</integer>
<key>Hour</key>
<integer>12</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/protonpass_backup_full.log</string>
<key>StandardErrorPath</key>
<string>/tmp/protonpass_backup_full.err</string>
</dict>
</plist>

Save and exit. Now, again, load the agent into the system so it starts running silently in the background:

launchctl load ~/Library/LaunchAgents/com.user.protonpassbackup.full.plist

Step 6: The “Paranoid” Recovery Test

Now decrypt the most recent backup of one item.

Grab that offline private key you hid away earlier (AGE-SECRET-KEY-1...) and paste it temporarily into a plain text file on your Desktop called recovery_key.txt.

Now, run this single terminal command to find one random encrypted item and decrypt it to your Desktop:

f=$(find ~/Backups/ProtonPass/items -name '*.json.age' -not -path '*/attachments/*' | head -1); age -d -i ~/Desktop/recovery_key.txt "$f" > ~/Desktop/restored_item.json

Open restored_item.json. If you see the item’s details in plaintext JSON, the pipeline works. To restore everything at once, decrypt the latest version of every item, rebuilding the same tree under ~/Desktop/restored/

cd ~/Backups/ProtonPass/items
find . -type f -name '*.json.age' -not -path '*/attachments/*' -print0 | while IFS= read -r -d '' f; do
d=$(dirname "$f")
[[ "$f" == "./$(ls -1t "$d"/*.json.age | head -1 | sed 's#^\./##')" ]] || continue
mkdir -p ~/Desktop/restored/"$d"
age -d -i ~/Desktop/recovery_key.txt "$f" > ~/Desktop/restored/"$d"/item.json
done

Trashed items live under ~/Backups/ProtonPass/trashand permanently deleted ones under ~/Backups/ProtonPass/deleted: restore them the same way with age -d. Attachments sit in each item’s attachments /folders

To open the full archive, decrypt it and open the result in any browser:

age -d -i ~/Desktop/recovery_key.txt ~/Backups/ProtonPass/full/archive_YYYY-MM-DD.html.age > ~/Desktop/archive.html

Now, do the smart thing and securely delete the restored files and recovery_key.txtto cover your tracks:

rm -rf ~/Desktop/restored ~/Desktop/restored_item.json ~/Desktop/recovery_key.txt

Wrapping Up

That’s it. You now have an invisible, event-driven local backup system on your Mac that:

  • Connects to Proton Pass without prompting you for a password, and notifies you if the session expires.
  • Triggers within minutes of a change: right away from the desktop app, at most 15 minutes for changes made elsewhere.
  • Is truly incremental: only new or modified items (and new attachments) are read and encrypted, even with thousands of entries.
  • Mirrors your vault and folder structure, including the trash, and follows renames, moves and deletions without ever losing data on its own.
  • Keeps the last 5 versions of every item, a 90-day safety net for deleted items, and a weekly full encrypted archive.
  • Encrypts everything instantly with age, never writing plaintext item data to disk and keeping the decryption key completely off the machine.

You can finally sleep soundly knowing your digital life isn’t entirely at the mercy of the cloud. And best of all: the next time I format my Mac, I won’t have to try and reverse-engineer my own terminal history — I’ll just come back and read my own article.

Riccardo Mestrone
Riccardo Mestrone

Written by Riccardo Mestrone

Google Cloud Authorized Trainer | Google Developer Expert (GDE) | Cloud Architect | Tech Communicator | Advisor | Marketing strategist