Docs
Every backup you watch has a unique ping URL, shown on its page (PING_URL below). Your job curls it when it finishes. Send numeric fields and Lastcopy learns what normal looks like, then alerts when a "successful" run doesn't look right.
Quick start
- Sign up. You land on your first backup check with its ping URL ready.
- Add one line to your backup job, like
&& curl -fsS PING_URL -d "bytes=$SIZE" || curl -fsS PING_URL/fail. Copy-paste recipes for restic, borgmatic, pg_dump, mysqldump, Veeam and rsync are below and on every check page. - Let it learn. After 7 runs, baselines are active and a "successful" backup that doesn't look right gets flagged. Add a weekly integrity check and a quarterly restore drill when you're ready for the higher tiers.
The API
GET PING_URL # bare check-in (heartbeat only) POST PING_URL # check-in with metrics (form-encoded or JSON) GET PING_URL/start # job started, enables duration and "started but never finished" POST PING_URL/fail # explicit failure, alerts immediately
The well-known fields are bytes, files, duration, snapshot, repo_size, snapshot_count, status (ok or fail) and exit. Any other numeric field is baselined too.
Recipes
restic
restic backup /srv --json | tail -n1 > /tmp/summary.json curl -fsS -m 10 --retry 3 PING_URL -d "$(jq -r \ '"bytes=\(.data_added)&files=\(.files_new + .files_changed)&duration=\(.total_duration|floor)&snapshot=\(.snapshot_id[0:8])"' \ /tmp/summary.json)"
borgmatic
# /etc/borgmatic/config.yaml
commands:
- after: action
when: [create]
run:
- curl -fsS -m 10 --retry 3 PING_URL -d "status=ok"
- after: error
run:
- curl -fsS -m 10 PING_URL/fail
pg_dump
pg_dump mydb > /backup/mydb.sql && gzip -f /backup/mydb.sql \ && curl -fsS -m 10 --retry 3 PING_URL -d "bytes=$(stat -c%s /backup/mydb.sql.gz)&files=1" \ || curl -fsS -m 10 PING_URL/fail # on macOS use stat -f%z instead of stat -c%s
Dump to a file, then compress. The pipe form (pg_dump | gzip > file) reports gzip's exit code in plain sh, so a failing dump still looks like success unless your shell has pipefail. Cron runs sh.
mysqldump
mysqldump --all-databases > /backup/all.sql && gzip -f /backup/all.sql \ && curl -fsS -m 10 --retry 3 PING_URL -d "bytes=$(stat -c%s /backup/all.sql.gz)&files=1" \ || curl -fsS -m 10 PING_URL/fail
Veeam (post-job PowerShell)
$s = Get-VBRBackupSession | Sort-Object CreationTime | Select-Object -Last 1
Invoke-RestMethod -Uri "PING_URL" -Method Post -Body @{
status = $s.Result
bytes = $s.Info.BackedUpSize
duration = [int]($s.EndTime - $s.CreationTime).TotalSeconds
}
rsync
OUT=$(rsync -a --stats /srv/ backup:/srv/) \
&& curl -fsS -m 10 --retry 3 PING_URL -d "bytes=$(echo "$OUT" | awk '/Total transferred file size/ {gsub(/,/,""); print $5}')" \
|| curl -fsS -m 10 PING_URL/fail
The three assurance tiers
Lastcopy is honest about what each signal proves. Ran means the job checked in on schedule and its numbers look normal, a tripwire for silent failures that says nothing about restorability. Verified means your backup tool read the repository data back and checked it against its own checksums, like restic check --read-data or borg check --verify-data, and passed. Drilled means you actually restored the backup and validated the contents. Auditors (ISO 27001 A.8.13, SOC 2) accept documented restore tests as evidence, and the drill log is that register, assembled automatically.
Tier 2 · repository verification
For restic, check exits non-zero when errors are found. Plain restic check verifies structure only, and reading data is what detects corruption. --read-data-subset=n/t checks slice n of t, so if you rotate the slice weekly you cover the whole repository every t weeks. (The x% form picks a random subset, so repeats don't guarantee coverage.) Note that restic check takes an exclusive lock, so schedule it away from your backup window.
SLICE=$(( $(date +%V) % 5 + 1 )) # rotate 1..5 by ISO week restic check --read-data-subset=$SLICE/5 \ && curl -fsS PING_URL/verify -d "verify=pass&subset=$SLICE/5" \ || curl -fsS PING_URL/verify -d "verify=fail"
borg exits 0 for ok, 1 for a warning and 2 for fatal. --verify-data reads, decrypts and cryptographically verifies all data. It is slow, because the default check is CRC-only. Never automate --repair.
borg check --verify-data :: ; RC=$? if [ $RC -eq 0 ]; then curl -fsS PING_URL/verify -d "verify=pass&method=verify-data" else curl -fsS PING_URL/verify -d "verify=fail&rc=$RC"; fi
For PostgreSQL base backups, pg_verifybackup checksums every file against the manifest and parses required WAL by default (use -n for tar-format backups). It exits non-zero on any failure.
pg_verifybackup /backup/base \ && curl -fsS PING_URL/verify -d "verify=pass&method=pg_verifybackup" \ || curl -fsS PING_URL/verify -d "verify=fail"
mysqldump has no integrity checker of its own. gzip -t validates the compression layer and the final -- Dump completed line catches truncation. Treat both as weak signals and rely on drills instead.
Tier 3 · restore drills
A drill is a restore into a scratch environment with content validation, run on your infrastructure. Lastcopy schedules it (set a drill cadence on the check), nags when it's overdue, and records the measured results as your audit register. Here is a PostgreSQL example.
docker run -d --name drill -e POSTGRES_PASSWORD=x postgres:16 until docker exec drill pg_isready -U postgres -q; do sleep 1; done gunzip -c /backup/mydb.sql.gz | docker exec -i drill psql -U postgres -v ON_ERROR_STOP=1 -1 ROWS=$(docker exec drill psql -U postgres -tAc "SELECT count(*) FROM orders") docker rm -f drill [ "$ROWS" -gt 0 ] \ && curl -fsS PING_URL/drill -d "result=pass&rows_restored=$ROWS&by=$USER" \ || curl -fsS PING_URL/drill -d "result=fail&by=$USER"
A drill passes when the restore exits 0 and the validation queries match expectations (row counts, checksums). A failing drill alerts immediately, because that backup may not be recoverable.
The ping URL is the credential
Treat it like a password. It lives in your backup job and nowhere else. If it ends up somewhere it shouldn't (a ticket, a paste, a shared screen), rotate it from the backup's page and the old URL stops working immediately. Lastcopy never sends the URL in email and the site tells browsers to send no Referer header to any other site.
Notification policy
Designed against alert fatigue. Every email should be actionable, and roll-ups beat repeats.
- One alert per state change. DOWN when the grace period is exhausted, FAILED on an explicit failure, one RECOVERED when it comes back. Never a stream of repeats.
- One email per incident. If several metrics look wrong on the same check-in, they arrive together in one message. Repeat anomalies on the same metric are muted for an hour.
- A daily persistence reminder (on by default, off in Settings). While backups stay broken you get at most one email per day, all of them rolled into one message. Every day a backup stays broken is another day of unprotected data.
- A weekly digest (on by default). Every watched backup, its status and tiers. This one is your proof the monitoring itself is alive.
- Every alert states its evidence, like "bytes = 3.2 MB vs 30-run median 41.8 GB."
How detection works
- Silence. No check-in within period plus grace raises a DOWN alert, and a
/startwith no finish inside the grace window also alerts. - Explicit failure.
/fail, astatus=failfield or a non-zeroexitalerts immediately. - Anomalies. Every numeric field is baselined (median + MAD, trailing 30 runs, minimum 7) and a robust z-score above 5 alerts. Think of a backup 90% smaller than normal that still "succeeded", or a change-rate spike consistent with mass encryption.
When Lastcopy itself is down
The register's no-edit rule includes us, so our own outages are recorded in it rather than smoothed over. Two detectors run all the time. The scheduler heartbeats every 15 seconds, and it also checks the public check-in path the way your job would. A scheduler gap longer than 5 minutes, or a check-in path that stays unreachable longer than that, is written into the register automatically as a service window.
- A check-in sent during a window is lost (your curl fails and cannot be re-sent), so silence inside a window is treated as a recording gap, not a missed backup.
- No DOWN alert fires for a deadline that fell inside a window, and none fires while the check-in path is failing its self-check. The affected backup shows "no data" with an explanation until its next scheduled run has had a full period plus grace to arrive. If nothing arrives by then, normal silence detection resumes.
- Evidence reports show each window with its start, end, length and cause. The expected check-in count is never reduced for our downtime. A check-in that could not be observed is reported as exactly that, unobservable, next to the received count. The gap is disclosed and the totals stand.
- While a window is open or has just closed, the dashboard says so.