By Charity Shot 12 min read

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.

Share:

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

ComponentNotes
Any Linux machineRaspberry Pi, old laptop, mini PC, or NAS with USB
USB SD Card ReaderAny cheap one works - mine cost £8
Network StorageSMB/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:

  1. Install the ntfy app on your phone
  2. Add your server (or use ntfy.sh for the public one)
  3. 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:

EventPriorityWhen
Card detectedlowSD card inserted
Backup starteddefaultBeginning file sort
Backup completedefaultAll files copied
Mount failedhighCouldn’t read card
I/O errorshighCard 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:

BrandFolder Pattern
FujifilmDCIM/*_FUJI
CanonDCIM/100CANON
SonyDCIM/100MSDCF
NikonDCIM/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

ProblemSolution
Backup not triggeringsudo udevadm control --reload-rules then re-insert card
Mount failsCheck filesystem support: sudo apt install exfat-fuse ntfs-3g
NAS not accessibleVerify mount: `mount
Permission deniedCheck NAS credentials and mount options
Script runs but no files copiedCheck 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 &amp;&amp; $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

ProblemSolution
Script not triggeringCheck LaunchAgent is loaded: `launchctl list
Permission errorsGrant Full Disk Access to Terminal in System Preferences
NAS not mountingCheck credentials and network connectivity
rsync not foundInstall 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.

🖼️ Something for your wall?

Photos are better when you can actually hold them.

Browse prints →

Related Posts

Ed Leeman

About Ed Leeman

Street and urban photographer capturing Portsmouth and Hampshire with charity shop cameras. Software engineer who loves automation—building tools to streamline everything from photo workflows to print sales. Finding beauty in the overlooked.

Your Cart

Loading cart...