Automating JPEG optimization with jpegoptim in your projects
Run jpegoptim on copies of your images in a build directory. That gives you optimized JPEGs to publish while keeping your originals and Git’s staging area intact. This walkthrough builds a directory of losslessly optimized JPEGs on Linux and stops with an error if any image fails.
Install jpegoptim on Ubuntu
You need Bash, GNU coreutils and findutils, and an existing directory of JPEG assets. The script
below uses GNU realpath options; it is a Linux example, not a native macOS or Windows script.
It was tested with Ubuntu 24.04’s jpegoptim 1.4.7 package and jpegoptim 1.5.6 on Linux.
On Ubuntu 24.04, install the jpegoptim package:
sudo apt-get update &&
sudo apt-get install -y jpegoptim &&
jpegoptim --version
The package manager installs the runtime libraries. You do not need libjpeg-dev to run the
packaged program.
Preview one image
Replace photo.jpg with a file in your asset directory:
jpegoptim --noaction --strip-none --nofix -- assets/images/photo.jpg
--noaction reports a candidate size without writing the file. Without --max or --size,
jpegoptim optimizes the JPEG’s coding losslessly. Its
manual distinguishes this
from lowering the image’s quality. The compressed bytes can change while decoded pixels stay the
same. A skipped result can simply mean that the candidate was no smaller.
Build a separate directory of optimized JPEGs
Save this as optimize-jpegs.sh in your project root. It copies regular .jpg and .jpeg files,
including uppercase extensions and nested directories, then optimizes the copies. Other files and
symlink entries are excluded. Keep the source tree unchanged while the script runs.
#!/usr/bin/env bash
set -euo pipefail
if (( $# != 2 )); then
printf 'Usage: bash optimize-jpegs.sh SOURCE NEW_OUTPUT\n' >&2
exit 1
fi
command -v jpegoptim >/dev/null || {
printf 'Install jpegoptim first.\n' >&2
exit 1
}
IFS= read -r -d '' source_dir < <(realpath -e -z -- "$1")
[[ -d "$source_dir" ]] || { printf 'Source must be a directory.\n' >&2; exit 1; }
IFS= read -r -d '' output_dir < <(realpath -m -z -- "$2")
if [[ "$source_dir" == / || "$output_dir/" == "$source_dir/"* ]]; then
printf 'Output must be outside the source tree.\n' >&2
exit 1
fi
if [[ -e "$2" || -L "$2" ]]; then
printf 'Output already exists; choose a new directory.\n' >&2
exit 1
fi
mkdir -- "$output_dir"
find "$source_dir" -type f \( -iname '*.jpg' -o -iname '*.jpeg' \) -print0 |
while IFS= read -r -d '' file; do
relative=${file#"$source_dir"/}
target="$output_dir/$relative"
mkdir -p -- "${target%/*}"
cp -- "$file" "$target"
jpegoptim --strip-none --nofix -- "$target"
done
printf 'JPEG build ready: %s\n' "$output_dir"
Run it from the project root, with an output directory that does not yet exist. Its parent must already exist. This example writes beside your project:
bash optimize-jpegs.sh assets/images ../optimized-images
For example, assets/images/products/front.JPG becomes
../optimized-images/products/front.JPG. Copying first also retains files that cannot get any
smaller: jpegoptim’s --dest option alone can omit those files. The script never overwrites an
existing output directory, so a second run needs a new destination.
The null-delimited file list and quoted paths handle spaces, newlines, leading hyphens, and literal
% characters in filenames. Both discovery and copying use the same resolved source path, including
when the directory argument contains a symlink followed by ...
--nofix rejects images that produce decoding warnings instead of attempting repairs. A failed
copy or optimization stops the loop, and Bash’s pipefail also propagates a failed find.
A failure after output creation can leave an incomplete directory; inspect or remove that failed
build before retrying. Publish only after a zero exit status and the final success message. An empty
source directory succeeds with an empty output directory.
Keep the commit’s staged changes intact
Avoid a pre-commit hook that optimizes working files and then runs git add assets/images.
Git adds the current state of that whole directory, including
unrelated edits, new files, and deletions. It can also replace a deliberately staged version of an
image with the different version in your working tree.
Use the build script as a separate task. It runs no Git commands and does not rewrite source images, so staged, partially staged, unstaged, and untracked work stays as you left it. Locally it reads the working tree, including untracked JPEGs; its output is not a snapshot of your staged commit. Use a clean checkout in CI when the output must correspond to a commit.
Run the same script in CI
In an Ubuntu CI job, check out the project, run the installation command above, then invoke the
script from the project root. For a GitHub Actions shell step, use a fresh directory under
RUNNER_TEMP
as the second argument. Configure artifact upload or deployment to consume that
directory only after the script succeeds. The script produces JPEG assets, not a complete website;
your build still needs to supply its other files and reference the optimized assets.
Choose metadata and quality policies deliberately
The script uses --strip-none to retain metadata markers, including EXIF orientation, ICC color
profiles, and comments. JFIF and Adobe markers can still be regenerated by the JPEG library. This
is not a byte-for-byte archive or a privacy scrub: location and camera information can remain.
Do not substitute --strip-all without considering how your images use that metadata. Removing
orientation or color information can affect their display even when the decoded pixel samples
are unchanged. The metadata options
describe which markers each flag retains or removes.
For smaller files at the cost of image quality, --max=80 enables lossy optimization where
applicable. It is a quality ceiling, not an 80% size target. --size also enables lossy optimization
and aims for a size rather than guaranteeing it. Evaluate such changes on fresh copies of your
originals; the build script intentionally stays lossless.
Measure your own images
Compare the original and output byte counts for an image you built:
wc -c -- assets/images/photo.jpg ../optimized-images/photo.jpg
There is no fixed savings percentage. An already optimized image may stay the same size, and smaller assets alone do not establish a page-load improvement. Check your actual build output and measure the page after it serves those files. Keep the originals so you can revisit quality or metadata choices without starting from a previously recompressed image.
