GitHub: per2jensen/scrubexif
Docker Hub: per2jensen/scrubexif
GPL-3.0-or-later
Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See section 15 and section 16 in the supplied "LICENSE" file.
Removes common privacy-sensitive JPEG metadata—including GPS coordinates, serial numbers, and maker notes—using a byte-level APP wipe followed by an allowlisted metadata rebuild.
Verify the integrity and origin of every published release and refresh image using Sigstore signatures, and review the accompanying vulnerability scan.
Output safety behavior: scrubexif creates output files from the completed scrubbing pipeline; it does not copy a failed input directly into the output directory. If the JPEG scrubbing pipeline fails, the command reports an error and does not publish that JPEG to its intended output path. In output-directory modes, existing destination entries are never overwritten.
This is a failure-handling safeguard, not a guarantee that an image contains no privacy-sensitive information. Default mode preserves a few selected technical tags and the ICC profile. --paranoia removes JPEG APP metadata more aggressively, but scrubexif does not inspect visible image content, filenames unless --rename is used, sidecar files, or unsupported future formats. Always check the command’s exit status before publishing output.
High-stakes use: Independently verify the resulting files before publishing or distributing them.
Full documentation moved → DETAILS.md
This README is intentionally short for Docker Hub visibility.
Scrub all JPEGs in the current directory ($PWD) and write cleaned copies to $PWD/output/:
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
-v "$PWD:/photos" \
per2jensen/scrubexif:0.7.26Verify the results: Verify that the sensitive metadata you expect to remove is absent and that only the intended technical tags remain.
Worth noting:
- scans your current directory (
$PWD) for*.jpg/*.jpeg(also in capital letters) - writes scrubbed copies to $PWD/output/ (or a custom
--outputdir) - leaves the originals untouched in $PWD/
- refuses to run if the
$PWD/outputdirectory already exists - prints host paths by default (use
--show-container-pathsto include/photos/...paths)
The demo script shows how to non-destructively scrub JPEGs in a directory and output the scrubbed JPEGs to another directory. Features:
- Run as the user calling the script, root is not allowed
- write a log in /tmp/scrubexif.log (keep it under 100k)
- sanity checks
- send notifications to desktop
Use -o to control where scrubbed files are written.
Output to a subdirectory of $PWD — scrubexif creates it if it does not exist:
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
-v "$PWD:/photos" \
per2jensen/scrubexif:0.7.26 \
-o scrubbedScrubbed files are written to $PWD/scrubbed/. The run is refused if scrubbed/ already exists
(safety guard — use -o with a bind-mount instead if you need to reuse a directory).
Output to an arbitrary host directory — mount it independently and pass the container path to -o:
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
-v "$PWD:/photos" \
-v "/tmp/scrub-test:/scrubbed" \
per2jensen/scrubexif:0.7.26 \
-o /scrubbed-v "/tmp/scrub-test:/scrubbed" maps /tmp/scrub-test on the host to /scrubbed inside the
container. -o /scrubbed tells scrubexif to write there. Because -o was explicitly supplied,
scrubexif accepts the directory even though it already exists (created by Docker as the mount point).
Scrubbed photos end up in /tmp/scrub-test/ on the host.
Note: mount the output directory at a top-level container path (e.g.
/scrubbed) rather than nested under/photos(e.g./photos/scrubbed). Nesting requires$PWDto be writable so Docker can create the mount point there.
Scrubexif is designed to not place an unscrubbed JPEG into an output directory.
If a scrub fails, no output file should be created for that JPEG; processing continues for the remaining files, and the final exit status is nonzero.
What happens on failed scrubs depends on the mode scrubexif is run in:
- Default safe mode (the one-liner): failed files stay in the original directory, and no file is written to the output directory for those failures.
- Auto mode (
--from-input): Failed files are archived in processed/ for inspection when possible. If archival also fails, the original remains in place and the run reports an error. - Manual (destructive) in-place (
--clean-inline): a failure leaves the original unchanged; there is no output directory involved.
Same idea, but with container hardening and in-line (destructive) overwrite:
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run -it --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
--read-only --security-opt no-new-privileges \
--tmpfs /tmp \
-v "$PWD:/photos" \
per2jensen/scrubexif:0.7.26 --clean-inlineExample of Gnome File Manager (Nautilus) integration can be seen in my file manager scripts
Filenames can leak as much as EXIF. A name like 2026-04-07_11-13-45.jpeg
reveals the exact capture time, and prefixes like D80_ identify the camera
body. --rename replaces the output filename with a format string of your
choice so the original identifying filename does not survive.
# Randomized filename — 8-character random hex name
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run -it --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
--read-only --security-opt no-new-privileges \
--tmpfs /tmp \
-v "$PWD:/photos" \
per2jensen/scrubexif:0.7.26 --clean-inline --rename "%r8" --recursive
# Keep your camera prefix, remove the timestamp
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run -it --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
--read-only --security-opt no-new-privileges \
--tmpfs /tmp \
-v "$PWD:/photos" \
per2jensen/scrubexif:0.7.26 --clean-inline --rename "D80_%r6" --recursive--paranoia implies --rename "%r8" when no --rename is given.
Common tokens: %r (random hex), %u (UUID), %n (sequential counter), %Y (year from EXIF), %m (month from EXIF).
Before modifying any file, scrubexif builds a complete collision-checked rename
plan in a bounded temporary SQLite database. It prints progress for long scans,
re-rolls random/UUID collisions up to three times, and aborts the whole batch if
a collision cannot be resolved. Final files are published with an atomic
no-overwrite operation. If a destination appears concurrently, scrubexif logs
the active-filesystem event, reserves another random name, and continues without
repeating the scrub; the concurrent file is never replaced.
Planning defaults to at most 250,000 files, 30 minutes, and 512 MiB of temporary
storage; the corresponding --rename-plan-* options can tune those safeguards.
Full specification → doc/rename-spec.md
Use auto mode with explicit input/output/processed directories:
mkdir input scrubbed processed errors
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run -it --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
--read-only --security-opt no-new-privileges \
--tmpfs /tmp \
-v "$PWD/input:/photos/input" \
-v "$PWD/scrubbed:/photos/output" \
-v "$PWD/processed:/photos/processed" \
-v "$PWD/errors:/photos/errors" \
per2jensen/scrubexif:0.7.26 --from-inputThese are the physical directories used on your file system:
Uploads → $PWD/input/
Scrubbed → $PWD/scrubbed/
Originals → $PWD/processed/ (or deleted with --delete-original)
Duplicates → deleted by default; use --on-duplicate move to move them into $PWD/errors/
Failed scrubs (e.g., corrupted files) → logged as failures; originals are moved to $PWD/processed/ for inspection
errors/ is a misnomer today; it is only used for duplicates when --on-duplicate move is set. Will be fixed in a later version.
Archive entries are never overwritten. If the usual filename is already occupied
in processed/ or errors/, scrubexif preserves it and publishes the incoming
original under a fresh random-suffixed name. The source is removed only after the
archive copy has been safely published; if archival fails, the source stays in
place and the run reports an error. This also works when intake and archive are
on different filesystems or mounts.
This flow diagram describes what happens only in auto mode (--from-input),
where four directories (input/, output/, processed/, errors/) are used.
Please observe these directories are named like this inside the container. Your physical directories in your file system are mapped when you run the docker run ... command. See the -v .... options in the above example.
[input/] --> <scrubexif> runs --> [output/]
|
+--> [processed/] (original JPEGs moved here after successful scrub,
unless --delete-original is used)
|
+--> [errors/] (duplicates only — only used when
--on-duplicate move)Meaning:
-
input/New JPEGs arrive here (e.g. from uploads, for example PhotoSync). -
output/Scrubbed JPEGs with allowlisted technical metadata. -
processed/Original JPEGs moved here after scrub (or deleted when requested). -
errors/Only created/used when--on-duplicate moveis enabled.
# build the image from the Dockerfile in this repo
docker build -t scrubexif:local .
# show CLI usage (ENTRYPOINT runs python -m scrubexif.scrub)
docker run --rm scrubexif:local --help
# scrub the current directory with hardened defaults
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
echo "Running as root is not allowed"
exit 1
fi
docker run -it --rm \
--user "$RUN_AS_UID:$RUN_AS_GID" \
--read-only --security-opt no-new-privileges \
--tmpfs /tmp \
-v "$PWD:/photos" \
scrubexif:localAny arguments appended to docker run … scrubexif:* are forwarded to the underlying
python3 -m scrubexif.scrub entrypoint.
- Scrubexif is JPEG‑only by design. This avoids format‑specific edge cases and ensures predictable behavior.
- Allowlist-based scrubbing: jpegtran strips all JPEG APP segments at the byte level (including unknown/proprietary vendor segments), then ExifTool writes back a small allowlist of technical tags (exposure, ISO, focal length, orientation)
- Removes common privacy-sensitive metadata, including GPS coordinates, serial numbers, and maker notes
- Preserves color profiles (ICC) by default; normal mode re-embeds the ICC profile after the
jpegtranstrip - Auto mode with duplicate handling (
--on-duplicate delete|move) - Optional stability gate for hot upload directories (e.g., PhotoSync, rclone, FTP uploads) (
--stable-seconds,--state-file) - Metadata inspection and dry-run support (
--show-tags,--preview,--dry-run) - Optional stamping of copyright and comment into EXIF/XMP (
--copyright,--comment) - Hardened container defaults in examples (read-only + no-new-privileges)
- ExifTool by Phil Harvey — used for metadata extraction and selective tag write-back (GPL-1.0-or-later / Artistic License)
- jpegtran from libjpeg-turbo — used for lossless byte-level JPEG transformation (IJG / BSD licence)
- sigstore/cosign used to sign/upload artifacts
- Syft used to generate a Software Bill Of Materials
- Grype used for image vulnerability scanning
- Ubuntu for the base image Scrubexif is based on
Every release image is cryptographically signed using cosign keyless signing via the Sigstore public infrastructure. The signature is tied directly to the specific GitHub Actions run that built the image, ensuring there are no long-lived signing keys that could be compromised. Anyone can verify that a pulled image genuinely came from this repository and was not tampered with in transit or on Docker Hub.
Verify any release in one command (requires cosign):
cosign verify per2jensen/scrubexif:0.7.26 \
--certificate-identity-regexp="https://github.com/per2jensen/scrubexif" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"Replace the tag with the version you wish to verify.
A successful verification prints the signing certificate, which includes the exact workflow URL, the Git commit SHA, and the GitHub Actions run URL — proving provenance down to the individual CI run.
Full details on installation, verification, and what the certificate fields mean → doc/DETAILS.md#image-signing-and-supply-chain-verification
The :latest image is rebuilt every Saturday from the exact source of the
latest stable release. A successful refresh is tested, scanned, signed, and
published with an immutable numeric suffix such as :0.7.26. Weekly
refreshes do not change the stable release version shown in this README; their
complete audit trail is recorded in doc/build-history.json, with compressed
SBOM and SARIF files stored under doc/.
Additional supply chain artefacts per release and scheduled refresh:
- SPDX SBOM (
sbom-<version>.spdx.json) — attached to each GitHub Release and as a signed in-toto attestation on the image itself - Grype vulnerability scan (
grype-results-<version>.sarif) — attached to the release, uploaded to the GitHub Security tab; releases are blocked on any high or critical CVE doc/build-history.json— tracks every release and refresh with Git commit, image digest, Grype counts, cosign Rekor log entry, and CI run URL
--from-input auto mode
--clean-inline in-place scrub (destructive)
--rename FORMAT rename output files using a format string (see doc/rename-spec.md)
--rename-plan-max-files N planning file-count circuit breaker (default: 250000)
--rename-plan-timeout-seconds S planning time circuit breaker (default: 1800)
--rename-plan-max-mib MIB planning storage circuit breaker (default: 512)
--show-container-paths include container paths in output
-q, --quiet no output on success
--preview no write, view only
--paranoia byte-level wipe via jpegtran only — removes JPEG APP metadata, including EXIF and ICC profiles
--comment stamp comment into EXIF/XMP
--copyright stamp copyright into EXIF/XMP
--on-duplicate delete | move
--stable-seconds N intake stability window
--state-file PATH override queue DB
-o, --output DIR write scrubbed files to DIR (default safe mode)
Full CLI reference → in DETAILS.md
The process exits 0 only when the run completes without scrub or
post-processing errors. A failed file, failed preview, unresolved collision, or
unsafe archive/delete operation returns 1; handled skips and duplicates do
not. With --quiet, successful runs remain silent, while failure diagnostics
and the summary are replayed to standard error.
This is an example of my workflow to quickly upload JPEG files to PhotoPrism. One use case is to quickly show dog owners photos at exhibitions.
| Host filesystem path | Container path | Purpose |
|---|---|---|
/some/directory/ |
/photos/input/ |
Location for new JPEG uploads on the server |
/photoprism/sooc/ |
/photos/output/ |
Destination for scrubbed JPEG versions, for PhotoPrism import |
/photoprism/processed/ |
/photos/processed/ |
Holding area for already-imported files. |
/etc/systemd/system/scrubexif.service:
[Service]
ExecStart=/usr/bin/docker run --rm \
--read-only --security-opt no-new-privileges \
--tmpfs /tmp \
-v /some/directory:/photos/input \
-v /photoprism/sooc:/photos/output \
-v /photoprism/processed:/photos/processed \
per2jensen/scrubexif:0.7.26 --from-input --stable-seconds 10/etc/systemd/system/scrubexif.timer:
[Unit]
Description=Run scrubexif every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.targetI use scrubexif to clean my jpegs on dog exhibitions. I upload the files to a server using rclone and a systemd timer runs the script below every 5 minutes.
You can see my (anonymized) script in the Github scrubexif repo
make dev-clean # remove dev image
make test # make dev image and run full test suite
pytest -m soak # optional 10 min run or try scripts/soak.sh
GitHub: per2jensen/scrubexif
Docker Hub: per2jensen/scrubexif
Full docs → DETAILS.md