Loading cart...
Automatic SD Card Backup with Linux or macOS
A step-by-step guide to building an automatic photo backup station. Plug in your camera's SD card, walk away, and let your computer handle the rest.
View all parts
- 1. Automatic SD Card Backup with Linux or macOS
- 2. Split Images for Instagram Without Killing Your iPhone Storage
- 3. Building a Headless eCommerce Stack: Astro + WooCommerce + Gelato + Cloudflare
- 4. Running a Website for £1.50 a Month
I got tired of manually copying photos from my camera. So I built a system that does it automatically - plug in the SD card, walk away, done. Here’s how to set up your own on Linux or macOS.
Overview
The system uses any Linux or macOS machine with a USB card reader. Could be a Raspberry Pi, an old laptop, a Mac Mini, or even a NAS with USB ports. When an SD card is inserted, the system detects it and triggers a backup script. The script sorts files by type, rsyncs to network storage, sends a notification, and safely ejects.
- Linux uses udev rules to detect device insertion
- macOS uses a LaunchAgent with
StartOnMount
The backup script logic is nearly identical on both platforms.
flowchart LR
SD[Camera SD Card] --> Reader[USB Card Reader]
Reader --> Linux[Linux Machine]
subgraph Linux Process
udev[udev detects device]
mount[Mount SD card]
sort[Sort JPEG/RAW/Other]
rsync[Rsync to NAS]
notify[Send notification]
unmount[Unmount card]
udev --> mount --> sort --> rsync --> notify --> unmount
end
Linux --> NAS[(NAS Storage)]
Linux --> ntfy[ntfy Server]
ntfy --> Phone[Phone Notification]
Requirements
Hardware
| Component | Notes |
|---|---|
| Any Linux machine | Raspberry Pi, old laptop, mini PC, or NAS with USB |
| USB SD Card Reader | Any cheap one works - mine cost £8 |
| Network Storage | SMB/CIFS share, local drive, or even the same machine |
Software
- Any Linux distribution (Debian, Ubuntu, Raspberry Pi OS, etc.)
- rsync (usually pre-installed)
- ntfy (optional, for notifications)
Step 1: Mount Your NAS
First, set up a persistent mount to your NAS. Create a credentials file:
sudo nano /etc/samba/credentials
username=your_nas_user
password=your_nas_password
sudo chmod 600 /etc/samba/credentials
Add to /etc/fstab:
//192.168.1.69/photos /mnt/network/photos cifs credentials=/etc/samba/credentials,uid=1000,gid=1000,iocharset=utf8,file_mode=0777,dir_mode=0777,nofail,x-systemd.automount 0 0
Create the mount point and mount:
sudo mkdir -p /mnt/network/photos
sudo mount -a
Step 2: Create the Backup Script
Create the main backup script:
sudo nano /usr/local/bin/sdcard-backup.sh
#!/bin/bash
# Configuration
CONFIG_FILE="/etc/sdcard-backup.conf"
LOG_FILE="/var/log/sdcard-backup.log"
# Load config
source "$CONFIG_FILE"
# Arguments
DEVICE="$1"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
notify() {
local message="$1"
local priority="${2:-default}"
if [ "$NOTIFY_NTFY" = "true" ]; then
curl -s -k \
-H "Priority: $priority" \
-d "$message" \
"$NTFY_SERVER/$NTFY_TOPIC" > /dev/null 2>&1
fi
log "$message"
}
# Exit if no device specified
if [ -z "$DEVICE" ]; then
log "ERROR: No device specified"
exit 1
fi
DEVICE_PATH="/dev/$DEVICE"
# Check device exists
if [ ! -b "$DEVICE_PATH" ]; then
log "ERROR: Device $DEVICE_PATH not found"
exit 1
fi
notify "Processing SD card: $DEVICE" "low"
# Create unique mount point
MOUNT_POINT="/mnt/sdcard-backup-$$"
mkdir -p "$MOUNT_POINT"
# Detect filesystem and mount
FS_TYPE=$(blkid -o value -s TYPE "$DEVICE_PATH")
log "Detected filesystem: $FS_TYPE"
case "$FS_TYPE" in
vfat|exfat)
mount -t "$FS_TYPE" -o ro,uid=1000,gid=1000 "$DEVICE_PATH" "$MOUNT_POINT"
;;
ntfs)
mount -t ntfs-3g -o ro,uid=1000,gid=1000 "$DEVICE_PATH" "$MOUNT_POINT"
;;
*)
mount -o ro "$DEVICE_PATH" "$MOUNT_POINT"
;;
esac
if [ $? -ne 0 ]; then
notify "Failed to mount $DEVICE" "high"
rmdir "$MOUNT_POINT"
exit 1
fi
log "Mounted $DEVICE_PATH at $MOUNT_POINT"
# Find source directory (Fujifilm structure: DCIM/*_FUJI)
SOURCE_DIR=$(find "$MOUNT_POINT/DCIM" -maxdepth 1 -type d -name "*_FUJI" 2>/dev/null | head -1)
if [ -z "$SOURCE_DIR" ]; then
# Fallback to generic DCIM
SOURCE_DIR="$MOUNT_POINT/DCIM"
fi
if [ ! -d "$SOURCE_DIR" ]; then
notify "No DCIM folder found on SD card" "high"
umount "$MOUNT_POINT"
rmdir "$MOUNT_POINT"
exit 1
fi
notify "Organizing files by type..."
# Count files before backup
JPEG_BEFORE=$(find "$NAS_JPEG_PATH" -name "*.JPG" -o -name "*.jpg" 2>/dev/null | wc -l)
RAW_BEFORE=$(find "$NAS_RAW_PATH" -name "*.RAF" -o -name "*.raf" 2>/dev/null | wc -l)
# Sync JPEGs
if [ "$SORT_BY_FILETYPE" = "true" ]; then
rsync -av --ignore-existing $RSYNC_OPTIONS \
--include="*/" --include="*.JPG" --include="*.jpg" --exclude="*" \
"$SOURCE_DIR/" "$NAS_JPEG_PATH/" 2>&1 | tee -a "$LOG_FILE"
# Sync RAW files
rsync -av --ignore-existing $RSYNC_OPTIONS \
--include="*/" --include="*.RAF" --include="*.raf" --exclude="*" \
"$SOURCE_DIR/" "$NAS_RAW_PATH/" 2>&1 | tee -a "$LOG_FILE"
# Sync everything else
rsync -av --ignore-existing $RSYNC_OPTIONS \
--exclude="*.JPG" --exclude="*.jpg" --exclude="*.RAF" --exclude="*.raf" \
"$SOURCE_DIR/" "$NAS_OTHER_PATH/" 2>&1 | tee -a "$LOG_FILE"
else
rsync -av --ignore-existing $RSYNC_OPTIONS \
"$SOURCE_DIR/" "$NAS_JPEG_PATH/" 2>&1 | tee -a "$LOG_FILE"
fi
# Check for I/O errors
if dmesg | tail -20 | grep -q "I/O error"; then
notify "WARNING: I/O errors detected - SD card may be failing!" "high"
fi
# Count files after backup
JPEG_AFTER=$(find "$NAS_JPEG_PATH" -name "*.JPG" -o -name "*.jpg" 2>/dev/null | wc -l)
RAW_AFTER=$(find "$NAS_RAW_PATH" -name "*.RAF" -o -name "*.raf" 2>/dev/null | wc -l)
JPEG_NEW=$((JPEG_AFTER - JPEG_BEFORE))
RAW_NEW=$((RAW_AFTER - RAW_BEFORE))
# Unmount
sync
umount "$MOUNT_POINT"
rmdir "$MOUNT_POINT"
notify "Backup complete! JPEG: $JPEG_NEW new, RAW: $RAW_NEW new. Safe to remove card."
log "Backup completed successfully"
Make it executable:
sudo chmod +x /usr/local/bin/sdcard-backup.sh
Step 3: Create the Configuration File
sudo nano /etc/sdcard-backup.conf
# File type sorting (true = separate JPEG/RAW/Other folders)
SORT_BY_FILETYPE="true"
# NAS destination paths
NAS_JPEG_PATH="/mnt/network/photos/Camera/JPEG"
NAS_RAW_PATH="/mnt/network/photos/Camera/RAW"
NAS_OTHER_PATH="/mnt/network/photos/Camera/OTHER"
# Rsync options
RSYNC_OPTIONS="--stats"
# Notifications via ntfy
NOTIFY_NTFY="true"
NTFY_SERVER="https://your-ntfy-server:8443"
NTFY_TOPIC="sdcard-backup-a1b2c3" # Use a random suffix (openssl rand -hex 3) to prevent others subscribing
Adjust the paths for your camera and NAS setup.
Step 4: Set Up the udev Rule
This is the magic bit. Create a udev rule that triggers the backup script when an SD card is inserted:
sudo nano /etc/udev/rules.d/99-sdcard-backup.rules
ACTION=="add", KERNEL=="sd[a-z][0-9]", SUBSYSTEM=="block", ENV{ID_FS_USAGE}=="filesystem", RUN+="/bin/systemd-run --no-block /usr/local/bin/sdcard-backup.sh %k"
Reload the rules:
sudo udevadm control --reload-rules
Step 5: Create the Log File
sudo touch /var/log/sdcard-backup.log
sudo chmod 666 /var/log/sdcard-backup.log
Architecture Deep Dive
Here’s what happens when you insert an SD card:
sequenceDiagram
participant SD as SD Card
participant USB as USB Reader
participant udev as udev daemon
participant systemd as systemd-run
participant script as sdcard-backup.sh
participant NAS as NAS Storage
participant ntfy as ntfy Server
participant Phone as Phone
SD->>USB: Card inserted
USB->>udev: USB device detected
udev->>udev: Match rule (sd[a-z][0-9] + filesystem)
udev->>systemd: Run backup script (non-blocking)
systemd->>script: Execute sdcard-backup.sh sda1
script->>ntfy: "Processing SD card: sda1"
ntfy->>Phone: Push notification
script->>script: Mount /dev/sda1
script->>script: Detect DCIM/*_FUJI folder
script->>ntfy: "Organizing files by type..."
script->>NAS: rsync JPEGs
script->>NAS: rsync RAW files
script->>NAS: rsync Other files
script->>script: Unmount SD card
script->>ntfy: "Backup complete! JPEG: 63 new, RAW: 126 new"
ntfy->>Phone: Push notification
The key detail is systemd-run --no-block. This spawns the backup script as a separate process so udev doesn’t wait for it to complete. Without this, udev would timeout and kill the script partway through the backup.
This works identically whether you’re running on a Raspberry Pi, an old ThinkPad, or a full server - udev is a standard Linux feature.
Testing
Insert an SD card and check the log:
tail -f /var/log/sdcard-backup.log
You should see:
2026-03-01 14:32:15 - Processing SD card: sda1
2026-03-01 14:32:15 - Detected filesystem: exfat
2026-03-01 14:32:15 - Mounted /dev/sda1 at /mnt/sdcard-backup-1234
2026-03-01 14:32:16 - Organizing files by type...
2026-03-01 14:32:45 - Backup complete! JPEG: 63 new, RAW: 126 new. Safe to remove card.
To manually trigger a backup for testing:
sudo /usr/local/bin/sdcard-backup.sh sda1
Notifications with ntfy
I use a self-hosted ntfy server for push notifications. You can use the public ntfy.sh server or run your own.
To subscribe:
- Install the ntfy app on your phone
- Add your server (or use
ntfy.shfor the public one) - Subscribe to your topic (e.g.,
sdcard-backup-a1b2c3)
Use a random suffix to prevent others subscribing to or spamming your topic. Generate one with openssl rand -hex 3.
Notification priorities:
| Event | Priority | When |
|---|---|---|
| Card detected | low | SD card inserted |
| Backup started | default | Beginning file sort |
| Backup complete | default | All files copied |
| Mount failed | high | Couldn’t read card |
| I/O errors | high | Card may be failing |
Camera-Specific Paths
The script looks for Fujifilm’s folder structure (DCIM/*_FUJI). For other cameras, adjust the SOURCE_DIR detection in the script:
| Brand | Folder Pattern |
|---|---|
| Fujifilm | DCIM/*_FUJI |
| Canon | DCIM/100CANON |
| Sony | DCIM/100MSDCF |
| Nikon | DCIM/100NCD90 (varies by model) |
Or just use DCIM as the source and skip the brand-specific detection.
File Structure
After backups, your NAS will have:
/mnt/network/photos/Camera/
├── JPEG/
│ ├── DSCF0001.JPG
│ ├── DSCF0002.JPG
│ └── ...
├── RAW/
│ ├── DSCF0001.RAF
│ ├── DSCF0002.RAF
│ └── ...
└── OTHER/
├── DSCF0001.MOV
└── ...
The --ignore-existing flag means files already on the NAS won’t be copied again. Incremental backups are fast.
Troubleshooting
| Problem | Solution |
|---|---|
| Backup not triggering | sudo udevadm control --reload-rules then re-insert card |
| Mount fails | Check filesystem support: sudo apt install exfat-fuse ntfs-3g |
| NAS not accessible | Verify mount: `mount |
| Permission denied | Check NAS credentials and mount options |
| Script runs but no files copied | Check source path matches your camera’s folder structure |
View system logs for udev events:
journalctl -f -u systemd-udevd
macOS Setup
macOS doesn’t have udev, but you can achieve the same result with a LaunchAgent that triggers when any disk is mounted. The agent runs a script that checks if the mounted volume is a camera SD card before proceeding.
macOS Architecture
flowchart LR
SD[Camera SD Card] --> Reader[USB Card Reader]
Reader --> Mac[macOS]
subgraph Mac Process
launchd[launchd detects mount]
check[Check for DCIM folder]
rsync[Rsync to NAS]
notify[Send notification]
eject[Eject card]
launchd --> check --> rsync --> notify --> eject
end
Mac --> NAS[(NAS Storage)]
Mac --> ntfy[ntfy Server]
ntfy --> Phone[Phone Notification]
Step 1: Create the Backup Script
Create the script at ~/bin/sdcard-backup.sh:
mkdir -p ~/bin
nano ~/bin/sdcard-backup.sh
#!/bin/bash
# Configuration
CONFIG_FILE="$HOME/.config/sdcard-backup.conf"
LOG_FILE="$HOME/Library/Logs/sdcard-backup.log"
# Load config
source "$CONFIG_FILE"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
notify() {
local message="$1"
local priority="${2:-default}"
if [ "$NOTIFY_NTFY" = "true" ]; then
curl -s -k \
-H "Priority: $priority" \
-d "$message" \
"$NTFY_SERVER/$NTFY_TOPIC" > /dev/null 2>&1
fi
log "$message"
}
# Find mounted camera SD cards (look for DCIM folder)
for volume in /Volumes/*; do
if [ -d "$volume/DCIM" ]; then
VOLUME_NAME=$(basename "$volume")
# Skip if we've already processed this volume recently (within 60 seconds)
LOCK_FILE="/tmp/sdcard-backup-$VOLUME_NAME.lock"
if [ -f "$LOCK_FILE" ]; then
LOCK_AGE=$(($(date +%s) - $(stat -f %m "$LOCK_FILE")))
if [ $LOCK_AGE -lt 60 ]; then
log "Skipping $VOLUME_NAME - recently processed"
continue
fi
fi
touch "$LOCK_FILE"
notify "Processing SD card: $VOLUME_NAME" "low"
# Find source directory (Fujifilm structure: DCIM/*_FUJI)
SOURCE_DIR=$(find "$volume/DCIM" -maxdepth 1 -type d -name "*_FUJI" 2>/dev/null | head -1)
if [ -z "$SOURCE_DIR" ]; then
SOURCE_DIR="$volume/DCIM"
fi
notify "Organizing files by type..."
# Count files before backup
JPEG_BEFORE=$(find "$NAS_JPEG_PATH" -name "*.JPG" -o -name "*.jpg" 2>/dev/null | wc -l)
RAW_BEFORE=$(find "$NAS_RAW_PATH" -name "*.RAF" -o -name "*.raf" 2>/dev/null | wc -l)
# Sync JPEGs
if [ "$SORT_BY_FILETYPE" = "true" ]; then
rsync -av --ignore-existing $RSYNC_OPTIONS \
--include="*/" --include="*.JPG" --include="*.jpg" --exclude="*" \
"$SOURCE_DIR/" "$NAS_JPEG_PATH/" 2>&1 | tee -a "$LOG_FILE"
# Sync RAW files
rsync -av --ignore-existing $RSYNC_OPTIONS \
--include="*/" --include="*.RAF" --include="*.raf" --exclude="*" \
"$SOURCE_DIR/" "$NAS_RAW_PATH/" 2>&1 | tee -a "$LOG_FILE"
# Sync everything else
rsync -av --ignore-existing $RSYNC_OPTIONS \
--exclude="*.JPG" --exclude="*.jpg" --exclude="*.RAF" --exclude="*.raf" \
"$SOURCE_DIR/" "$NAS_OTHER_PATH/" 2>&1 | tee -a "$LOG_FILE"
else
rsync -av --ignore-existing $RSYNC_OPTIONS \
"$SOURCE_DIR/" "$NAS_JPEG_PATH/" 2>&1 | tee -a "$LOG_FILE"
fi
# Count files after backup
JPEG_AFTER=$(find "$NAS_JPEG_PATH" -name "*.JPG" -o -name "*.jpg" 2>/dev/null | wc -l)
RAW_AFTER=$(find "$NAS_RAW_PATH" -name "*.RAF" -o -name "*.raf" 2>/dev/null | wc -l)
JPEG_NEW=$((JPEG_AFTER - JPEG_BEFORE))
RAW_NEW=$((RAW_AFTER - RAW_BEFORE))
notify "Backup complete! JPEG: $JPEG_NEW new, RAW: $RAW_NEW new. Safe to eject."
# Optional: auto-eject the card
# diskutil eject "$volume"
log "Backup completed successfully for $VOLUME_NAME"
fi
done
Make it executable:
chmod +x ~/bin/sdcard-backup.sh
Step 2: Create the Configuration File
mkdir -p ~/.config
nano ~/.config/sdcard-backup.conf
# File type sorting (true = separate JPEG/RAW/Other folders)
SORT_BY_FILETYPE="true"
# NAS destination paths (mount your NAS share first, or use a local path)
NAS_JPEG_PATH="/Volumes/photos/Camera/JPEG"
NAS_RAW_PATH="/Volumes/photos/Camera/RAW"
NAS_OTHER_PATH="/Volumes/photos/Camera/OTHER"
# Rsync options
RSYNC_OPTIONS="--stats"
# Notifications via ntfy
NOTIFY_NTFY="true"
NTFY_SERVER="https://ntfy.sh"
NTFY_TOPIC="sdcard-backup-a1b2c3"
Step 3: Create the LaunchAgent
This tells macOS to run the backup script whenever a disk is mounted:
nano ~/Library/LaunchAgents/com.sdcard-backup.plist
<?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.sdcard-backup</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>sleep 2 && $HOME/bin/sdcard-backup.sh</string>
</array>
<key>StartOnMount</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/sdcard-backup.out</string>
<key>StandardErrorPath</key>
<string>/tmp/sdcard-backup.err</string>
</dict>
</plist>
The sleep 2 gives the volume time to fully mount before the script runs.
Load the agent:
launchctl load ~/Library/LaunchAgents/com.sdcard-backup.plist
Step 4: Mount Your NAS (macOS)
You can mount a NAS share via Finder (Cmd+K, then smb://192.168.1.69/photos) or add it to Login Items for automatic mounting.
For a more reliable mount, add to /etc/fstab:
sudo nano /etc/fstab
//user:password@192.168.1.69/photos /Volumes/photos smbfs 0 0
Or use automount - create /etc/auto_smb:
photos -fstype=smbfs ://user:password@192.168.1.69/photos
macOS Testing
Insert an SD card and check the log:
tail -f ~/Library/Logs/sdcard-backup.log
To test manually:
~/bin/sdcard-backup.sh
macOS Troubleshooting
| Problem | Solution |
|---|---|
| Script not triggering | Check LaunchAgent is loaded: `launchctl list |
| Permission errors | Grant Full Disk Access to Terminal in System Preferences |
| NAS not mounting | Check credentials and network connectivity |
| rsync not found | Install via Homebrew: brew install rsync |
Conclusion
Total setup time was about an afternoon, mostly spent getting the udev rules (Linux) or LaunchAgent (macOS) right. Now I just pop the card in when I get home and forget about it. The notification arrives a few seconds later confirming everything’s backed up.
I use a Raspberry Pi because it draws barely any power and was already running other things, but any Linux box or Mac would work. An old laptop with a broken screen, a Mac Mini running as a home server, or even directly on your NAS if it runs Linux. The card reader cost less than a coffee. Simple solutions are usually the best ones.
💬 Leave a comment
Got thoughts? I'd love to hear from you.
📬 Stay in the loop
New posts straight to your inbox. No spam.
