Lightweight, Dockerized EXIF cleaner designed for fast publishing of JPEG photos.
3.8K
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 and writes one canonical JFIF APP0 header, 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.
Detailed behavior and CLI reference → DETAILS.md
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.8.0
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:
The demo requires the schema-2 summary contract from scrubexif 0.8.0 or newer.
It runs per2jensen/scrubexif:0.8.0 by default; set SCRUBEXIF_IMAGE to use a
different schema-2-compatible image.
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.8.0 \
-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.8.0 \
-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.8.0 --clean-inline
I also maintain a scrubexif wrapper for GNOME Files, which I use myself almost every day. It lets you select one or more JPEGs and run scrubexif from the file manager's right-click Scripts menu.
The wrapper validates the selection, processes all selected files in one Docker run, handles FUSE/NFS/CIFS locations through a temporary local directory, and reports the result with a desktop notification and log. It uses destructive --clean-inline mode, so the selected files are replaced by their scrubbed versions.
See the file-manager-scripts repository for prerequisites and installation instructions. The script belongs in ~/.local/share/nautilus/scripts/ and must be executable.
--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.8.0 --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.8.0 --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.8.0 --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)/tmp tmpfs, and no-new-privilegesEvery release image is cryptographically signed using cosign keyless signing via the Sigstore public infrastructure. The signature binds an image digest to the identity of the GitHub Actions workflow that signed it, without a long-lived project signing key. Verification establishes image integrity and workflow provenance; it does not prove that the source, workflow, runner, or resulting image was free from compromise.
Verify any release in one command (requires cosign):
cosign verify per2jensen/scrubexif:0.8.0 \
--certificate-identity="https://github.com/per2jensen/scrubexif/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
Replace the tag with another immutable release version when needed. Refresh tags use the separate workflow identity documented in DETAILS.
A successful verification confirms that the image digest was signed under the expected release-workflow identity. Cosign's displayed certificate and bundle details vary by version; inspect the workflow identity, source revision, and run information when shown.
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.8.0. 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:
sbom-<version>.spdx.json) — a GitHub Release asset for releases, a compressed repository audit file for refreshes, and a signed in-toto image attestation for bothgrype-results-<version>.sarif) — a release asset or compressed refresh audit file and an upload to GitHub Security; publication is blocked on any High or Critical findingdoc/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)
--max-files N limit JPEG operation attempts; skipped/unsupported files do not count
--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 scrub/audit all in-scope JPEGs temporarily; unavailable in auto mode
--paranoia discard input APP metadata without restoring EXIF/XMP/IPTC/ICC; write one canonical JFIF header
--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 → DETAILS.md
The process exits 0 when the run completes without scrub, audit, or
post-processing errors. A controlled operational failure returns 1; command-line
syntax errors return 2; and a handled keyboard interruption returns 130.
Handled skips and successfully handled verified duplicates are not errors. Help,
version, and argument-parser exits do not emit a summary. With --quiet,
successful operational runs remain silent, while failure diagnostics and the
summary are replayed to standard error. The machine-readable line begins with schema=2; consumers
must require that version because 0.8.0 changes counter meanings as well as
adding fields. total remains a JPEG-only alias for jpeg_total, while
examined reconciles all terminal outcomes, including unsupported inputs.
The complete field contract and reconciliation rules are documented in
DETAILS.md.
The implementation decisions and acceptance criteria are recorded in the output safety plan.
This example runs an intake directory through scrubexif before PhotoPrism indexing, using a systemd timer for periodic processing.
| 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. |
/photoprism/errors/ | /photos/errors/ | Verified duplicates and same-name content collisions. |
/etc/systemd/system/scrubexif.service:
[Unit]
Description=Run scrubexif intake processing
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
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 \
-v /photoprism/errors:/photos/errors \
per2jensen/scrubexif:0.8.0 --from-input --stable-seconds 10
Create all four host directories first and ensure UID 1000, the image's default
runtime user, can write to them. Alternatively, add an explicit Docker --user
matching their host ownership.
/etc/systemd/system/scrubexif.timer:
[Unit]
Description=Run scrubexif every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true
Unit=scrubexif.service
[Install]
WantedBy=timers.target
The repository includes a configurable PhotoPrism integration script that validates schema-2 summaries and indexes only newly published outputs.
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:dd84dbd25…
Size
56.5 MB
Last updated
3 days ago
docker pull per2jensen/scrubexif