Lightweight, Dockerized EXIF cleaner designed for fast publishing of JPEG photos.
3.3K
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 builds each JPEG in a private temporary
directory outside the output folder, then independently parses the completed
JPEG against the active metadata policy. Only those audited bytes are copied to
a hidden .part file in the output folder and atomically published. A failed or
non-compliant result is never published under its intended output filename. 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.27
Verify the results: Verify that the sensitive metadata you expect to remove is absent and that only the intended technical tags remain.
Worth noting:
$PWD) for *.jpg / *.jpeg (also in capital letters)--output dir)$PWD/output directory already exists--show-container-paths to 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:
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.27 \
-o scrubbed
Scrubbed 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.27 \
-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:
--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.--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.27 --clean-inline
Example of Gnome File Manager (Nautilus) integration can be seen in my file manager scripts
--rename)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.27 --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.27 --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.27 --from-input
These are the physical directories used on your file system:
Uploads → $PWD/input/
Scrubbed → $PWD/scrubbed/
Originals → $PWD/processed/ (or deleted with --delete-original)
Verified duplicates → moved to $PWD/errors/ by default; use explicit --on-duplicate delete to delete them
Same filename, different content → incoming original moved to $PWD/errors/; reported as a collision with a nonzero exit
Failed scrubs (e.g., corrupted files) → logged as failures; originals are moved to $PWD/processed/ for inspection
Existing output that fails its privacy audit → left untouched; incoming source stays in $PWD/input/; run exits nonzero
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.
--from-input)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/] (verified duplicates and same-name
content collisions)
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/
Receives verified duplicates under the default move policy and preserves
same-name/different-content collisions for inspection.
# 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:local
Any arguments appended to docker run … scrubexif:* are forwarded to the underlying
python3 -m scrubexif.scrub entrypoint.
jpegtran strip--on-duplicate move|fail|delete; default move)--stable-seconds, --state-file)--show-tags, --preview, --dry-run)--copyright, --comment)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.27 \
--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.27. 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:
sbom-<version>.spdx.json) — attached to each GitHub Release and as a signed in-toto attestation on the image itselfgrype-results-<version>.sarif) — attached to the release, uploaded to the GitHub Security tab; releases are blocked on any high or critical CVEdoc/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 move | fail | delete (default: 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, audit, or
post-processing errors. A failed file, failed preview, same-name content
collision, unsafe existing output, or unsafe archive/delete operation returns
1; handled skips and verified duplicates do not. With --quiet, successful
runs remain silent, while failure diagnostics and the summary are replayed to
standard error.
The implementation decisions and acceptance criteria are recorded in the output safety plan.
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.27 --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.target
I 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
Content type
Image
Digest
sha256:1ef20ebb5…
Size
58.8 MB
Last updated
2 days ago
docker pull per2jensen/scrubexif