Self-Hosted Raspberry Pi Backups with a Mainframe Aesthetic (COBOL Edition)
I use a bare-metal Raspberry Pi as my home server for self-hosting. Among other Docker containers, I run Immich—a fantastic self-hosted alternative to cloud photo galleries. But when your photo library grows past a few dozen gigabytes, backups become a real pain point.
My daily driver is Fedora, and for a while, I just pulled the files over SSH. But classic SSH backups using tar have two major flaws:
- The black box effect. When you're pulling 25..50..100 GB of data, you're just staring at a hanging terminal. You have no idea if the archive is actually transferring, what your throughput is, or if the pipe just died.
- Hot backups are a gamble. Tarring up live PostgreSQL database files while the containers are actively writing is asking for trouble. Eventually, you'll end up with a corrupted database.
To fix this properly, I wrote a bash script. But instead of standard boring output, I decided to style it like an old-school IBM mainframe running a COBOL batch job. Zero emojis—just hardcore UPPERCASE, execution steps (STEP), data sets (DATA SET), and completion codes (CONDITION CODE).
Under the hood
The script fully automates the process and works according to the following algorithm:
- Graceful shutdown: The script safely spins down the Docker containers on the Pi via SSH. If the stop command fails, it triggers a rollback (starts them back up) and aborts the job (ABEND). The Docker output is intercepted, stripped of ANSI escape codes, and converted to UPPERCASE to keep the mainframe aesthetic.
- Pre-flight sizing: Before piping the tarball, it runs du -sb on the Pi to get the exact payload size in bytes.
- Progress bar & ETA: That size variable is fed into pv (Pipe Viewer) on the receiving end (Fedora). This gives us a beautiful, real-time progress bar with an ETA right in the terminal.
- Retention policy: The script keeps the last 4 archives (roughly a month's worth of weeklies), automatically scratching the old "data sets". It dumps the backups in the exact same directory the script is executed from.
The Code Itself (BASH BATCH PROCESSING)
Running it is as simple as: ./immich_backup.sh. It assumes passwordless sudo is configured on the Pi (which is often the default anyway).
#!/bin/bash
# Settings
REMOTE_ALIAS="rpi5"
NODE_NAME="${REMOTE_ALIAS^^}"
SOURCE_PARENT="/home/rachel"
SOURCE_NAME="immich"
TARGET_DIR="$(dirname "$(realpath "$0")")"
FILENAME="immich_week_$(date +%V).tar"
TARGET_PATH="$TARGET_DIR/$FILENAME"
# Strict logging function
log_msg() {
echo "$(date '+%Y/%m/%d %H:%M:%S') $1"
}
echo "************************************************************************"
echo "* PROGRAM-ID. IMMICH-BACKUP. *"
echo "* AUTHOR. SYSTEM ADMIN. *"
echo "* ENVIRONMENT. BASH BATCH PROCESSING. *"
echo "************************************************************************"
echo "IDENTIFICATION DIVISION."
echo "PROCEDURE DIVISION."
log_msg "INFO - RUN JOB IMMICH-BKP ON NODE $NODE_NAME"
log_msg "STEP 01 - ALLOCATE TARGET DATA SET $TARGET_DIR"
mkdir -p "$TARGET_DIR"
log_msg "STEP 02 - HALT REMOTE DOCKER CONTAINERS"
ssh $REMOTE_ALIAS "cd $SOURCE_PARENT/$SOURCE_NAME && sudo docker compose stop" 2>&1 | while IFS= read -r line; do
clean_line=$(echo "$line" | sed -r 's/\x1B\[[0-9;]*[a-zA-Z]//g')
log_msg "DOCKER: ${clean_line^^}"
done
if [ ${PIPESTATUS[0]} -ne 0 ]; then
log_msg "ABEND - RETURN CODE NOT 00 ON DOCKER STOP"
log_msg "STEP 02A - ATTEMPT ROLLBACK (RESTART CONTAINERS)"
ssh $REMOTE_ALIAS "cd $SOURCE_PARENT/$SOURCE_NAME && sudo docker compose start" >/dev/null 2>&1
log_msg "JOB TERMINATED DUE TO ABEND"
exit 1
fi
log_msg "STEP 02 - CONDITION CODE 0000"
log_msg "STEP 03 - ESTIMATE REMOTE DATA SET SIZE"
ESTIMATED_SIZE=$(ssh $REMOTE_ALIAS "sudo du -sb $SOURCE_PARENT/$SOURCE_NAME" | cut -f1)
if [[ -n "$ESTIMATED_SIZE" && "$ESTIMATED_SIZE" =~ ^[0-9]+$ ]]; then
log_msg "INFO - ESTIMATED SIZE: $ESTIMATED_SIZE BYTES"
log_msg "STEP 03 - CONDITION CODE 0000"
else
log_msg "ABEND - FAILED TO ESTIMATE DATA SET SIZE"
ssh $REMOTE_ALIAS "cd $SOURCE_PARENT/$SOURCE_NAME && sudo docker compose start" >/dev/null 2>&1
exit 1
fi
log_msg "STEP 04 - EXECUTE REMOTE ARCHIVE AND DATA TRANSFER"
ssh $REMOTE_ALIAS "sudo tar -cf - -C $SOURCE_PARENT $SOURCE_NAME" | pv -ptrbe -s "$ESTIMATED_SIZE" > "$TARGET_PATH"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log_msg "STEP 04 - CONDITION CODE 0000, DATA WRITTEN TO $TARGET_PATH"
else
log_msg "ABEND - DATA TRANSFER FAILED"
ssh $REMOTE_ALIAS "cd $SOURCE_PARENT/$SOURCE_NAME && sudo docker compose start" >/dev/null 2>&1
exit 1
fi
log_msg "STEP 05 - COMPUTE DATA SET ALLOCATION SIZE"
if [ -f "$TARGET_PATH" ]; then
FILE_SIZE=$(stat -c %s "$TARGET_PATH")
log_msg "INFO - DATA SET SIZE: $FILE_SIZE BYTES"
log_msg "STEP 05 - CONDITION CODE 0000"
else
log_msg "ABEND - DATA SET NOT FOUND"
exit 1
fi
log_msg "STEP 06 - RESTART REMOTE DOCKER CONTAINERS"
ssh $REMOTE_ALIAS "cd $SOURCE_PARENT/$SOURCE_NAME && sudo docker compose start" 2>&1 | while IFS= read -r line; do
clean_line=$(echo "$line" | sed -r 's/\x1B\[[0-9;]*[a-zA-Z]//g')
log_msg "DOCKER: ${clean_line^^}"
done
log_msg "STEP 06 - CONDITION CODE 0000"
log_msg "STEP 07 - EXECUTE ROTATION PROCEDURE (KEEP 4 GENERATIONS)"
cd "$TARGET_DIR" || exit
ls -t immich_week_*.tar 2>/dev/null | tail -n +5 | xargs -I {} bash -c "echo \"\$(date '+%Y/%m/%d %H:%M:%S') INFO - SCRATCH OLD DATA SET: {}\" | tr '[:lower:]' '[:upper:]' && rm -- \"{}\""
log_msg "STEP 07 - CONDITION CODE 0000"
echo "************************************************************************"
echo "* END OF JOB IMMICH-BKP. MAXIMUM CONDITION CODE 0000 *"
echo "************************************************************************"
The end result is a rock-solid, standalone backup script that is incredibly satisfying to watch run.
Tools & References:
Official Immich project website
---
/en/