Shore Up
A conveyor belt where large heavy picture frames enter one end, pass through a press that squeezes them smaller and lighter, and neat trimmed copies come out the other end while the originals are set aside in a labelled box.
Linux

Batch-Convert and Optimise Images with a Watch Script

Ketan Aagja8 min read
No ratings yet

Before you run this

This sets up a background service that watches a directory and, whenever a new JPEG or PNG lands there, produces a resized, stripped, and re-compressed copy in a separate output directory. Its purpose is to keep uploaded or generated images small and consistent without anyone running a command by hand.

A few things to be clear about before you deploy it:

  • Privileges. Installing the packages needs sudo. The watch script itself does not need root — run it as a normal service user that owns the directories. Do not run it as root; a stray file path under root's control is how accidents happen.
  • It is lossy, and that part is irreversible. JPEG re-compression at a lower quality and metadata stripping (EXIF, GPS, orientation, copyright) cannot be undone on the output file. For that reason this design never modifies your source file: it writes optimised copies to a separate directory and moves the originals into an archive folder. Keep that archive until you are sure the outputs are good.
  • Test first. Read the script, then run it against a throwaway directory with a handful of copied test images before you point it at anything real. Confirm the output dimensions and quality are what you want before you let it process a live upload folder.
  • Watch the disk. A watcher that never deletes will fill a disk. Decide up front how long you keep the originals archive and clean it on a schedule.

There is no firewall or network exposure here, so no out-of-band caveats — but the same discipline applies: prove it on test data first.

What I'm assuming

  • Debian 12 or Ubuntu 22.04, bash, and systemd.
  • ImageMagick 6.9 (what Debian 12 ships). I use the convert command, which exists in both ImageMagick 6 and 7. On an ImageMagick 7 box the modern name is magick, but convert still works as the legacy alias.
  • A dedicated system user (I'll call it imgproc) that owns the working directories.

If you are on RHEL/Alma, the package names differ (ImageMagick, inotify-tools) and you'd install optipng/jpegoptim from EPEL — otherwise the script is identical.

Install the tools

sudo apt update
sudo apt install imagemagick inotify-tools jpegoptim optipng
  • ImageMagick does the resize and format work.
  • inotify-tools gives us inotifywait to watch the directory.
  • jpegoptim and optipng squeeze the last bytes out of JPEG and PNG respectively.

One ImageMagick note: recent packages ship a security policy at /etc/ImageMagick-6/policy.xml that can block certain operations or large images. If a conversion fails with a "not authorized" error, that file is why — see the ImageMagick "Security Policy" documentation before loosening it.

Create the user and directories:

sudo useradd -r -s /usr/sbin/nologin imgproc
sudo mkdir -p /srv/images/{incoming,optimised,originals}
sudo chown -R imgproc:imgproc /srv/images

The script

Save this as /usr/local/bin/image-optimise.sh. Change the paths and the two tuning values at the top to suit you — /srv/images/... are placeholders.

#!/usr/bin/env bash
set -euo pipefail

WATCH_DIR="/srv/images/incoming"      # drop new images here
OUT_DIR="/srv/images/optimised"       # optimised copies land here
ARCHIVE_DIR="/srv/images/originals"   # untouched originals moved here
MAX_DIM=2000                          # longest edge, in pixels
JPEG_QUALITY=85                       # 1-100; lower = smaller and lossier

mkdir -p "$OUT_DIR" "$ARCHIVE_DIR"

process() {
    local src="$1"
    local name ext
    name="$(basename "$src")"
    ext="${name##*.}"

    shopt -s nocasematch
    case "$ext" in
        jpg|jpeg)
            # -resize with a trailing '>' only shrinks images larger than MAX_DIM
            convert "$src" -auto-orient -resize "${MAX_DIM}x${MAX_DIM}>" \
                    -strip -quality "$JPEG_QUALITY" "$OUT_DIR/$name"
            jpegoptim --max="$JPEG_QUALITY" --strip-all --quiet "$OUT_DIR/$name"
            ;;
        png)
            convert "$src" -auto-orient -resize "${MAX_DIM}x${MAX_DIM}>" \
                    -strip "$OUT_DIR/$name"
            optipng -quiet -o2 "$OUT_DIR/$name"
            ;;
        *)
            echo "Skipping unsupported file: $name"
            shopt -u nocasematch
            return 0
            ;;
    esac
    shopt -u nocasematch

    mv "$src" "$ARCHIVE_DIR/$name"    # keep the untouched original
    echo "Processed $name"
}

# close_write catches local copies/scp; moved_to catches rsync-style temp+rename
inotifywait -m -q -e close_write -e moved_to --format '%w%f' "$WATCH_DIR" |
while read -r file; do
    process "$file" || echo "Failed: $file"   # one bad file must not kill the loop
done

Make it executable:

sudo chmod +x /usr/local/bin/image-optimise.sh

A few things worth understanding rather than trusting blind:

  • -auto-orient applies the EXIF orientation flag to the actual pixels before -strip throws that flag away — otherwise stripped phone photos can come out sideways.
  • -resize "2000x2000>" — the trailing > means "only shrink images bigger than this, never enlarge". Both dimensions are the box; aspect ratio is preserved.
  • I match only jpg, jpeg, and png and skip everything else. If you want WebP output, cwebp (from the webp package) is the standard tool — check its man page for the exact -q syntax rather than assuming.

Run it as a systemd service

Watching a directory is a long-running job, so let systemd keep it alive and restart it on failure. Create /etc/systemd/system/image-optimise.service:

[Unit]
Description=Watch and optimise incoming images
After=local-fs.target

[Service]
Type=simple
User=imgproc
Group=imgproc
ExecStart=/usr/local/bin/image-optimise.sh
Restart=on-failure

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now image-optimise.service

Check it came up cleanly:

systemctl status image-optimise.service

(A systemd path unit can also trigger on directory changes instead of a long-running inotifywait; it's a valid alternative but I'm not walking through it here.)

Verify it worked

Drop a test image in and watch it flow through:

cp ~/some-big-photo.jpg /srv/images/incoming/

Within a second or two:

ls -lh /srv/images/optimised/     # the smaller copy
ls -lh /srv/images/originals/     # the untouched original, moved here

Confirm the dimensions and that it's a valid image with ImageMagick's identify:

identify /srv/images/optimised/some-big-photo.jpg

You should see the longest edge at or below your MAX_DIM, and a smaller file size than the original. Follow the service log live while you test:

journalctl -u image-optimise.service -f

You'll see a Processed ... line per file, or a Failed: ... line to investigate.

Undo and roll back

Because the script never touches source files in place, recovery is straightforward:

  • Restore an original — the pristine file is in /srv/images/originals/. Copy it back wherever you need it.

  • Stop the watcher:

    sudo systemctl disable --now image-optimise.service
    
  • Remove it entirely — delete the unit file and reload:

    sudo rm /etc/systemd/system/image-optimise.service
    sudo systemctl daemon-reload
    

The only irreversible part is the quality and metadata lost in the optimised copies — which is exactly why the originals archive exists. Keep it until you've confirmed the outputs are good, then prune it on whatever schedule your disk can afford.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.