Skip to content

Architecture overview ​

BucketLean is a self-hosted Nuxt application that scans one or more configured Cloudflare R2 buckets and conditionally replaces qualifying JPEGs with smaller, validated versions. The app keeps job progress in SQLite and, by default, the original image plus a restore manifest in R2. Optimization runs one item at a time.

System map ​

ComponentResponsibilityMain code
Dashboard and APIAuthenticate, validate input, display scan and job results, enqueue workapp/pages/index.vue, server/api/, server/middleware/auth.ts
Thumbnail previewOn request, verify a listed JPEG, conditionally read it, and return a small JPEG without any R2 writeapp/components/ObjectPreview.vue, server/api/objects/preview.get.ts
Scan workerDiscover keys, identify JPEGs, inspect optimizer metadata, persist a resumable cursorserver/utils/scanner.ts
Optimization workerProcess one item at a time, save intent and verification steps, classify failures, reconcile uncertain writesserver/utils/jobs.ts
Image pipelineDecode and validate JPEGs, recompress, check dimensions and selected EXIF/ICC metadataserver/utils/image.ts
SQLiteStore bucket profiles, scans, current discovery rows, jobs, items, and worker leaseserver/utils/db.ts, server/utils/schema.ts
R2Store source objects, verified original backups, and independently readable manifestsserver/utils/r2.ts, server/utils/backup-manifest.ts
Recovery commandsSnapshot SQLite, backfill older manifests, test a restore without replacing the sourcescripts/snapshot-db.mjs, scripts/backfill-manifests.mjs, scripts/restore-drill.mjs

The Nitro plugin starts work on launch, polls for queued work every two seconds, and shuts down the workers and ExifTool cleanly. A SQLite lease selects one worker owner; deploy one app instance with SQLite WAL on a local persistent volume. Each profile ID is bound to one R2 endpoint and bucket. Profiles may share account credentials or use separate bucket-scoped credentials; credentials remain in the server environment. The global lease allows one active worker across all buckets.

Data model ​

objects is the current discovery view keyed by (bucket_id, key); a later scan can update a row and its scan_id. A job copies each candidate's key, ETag, and size into optimization_items at creation, in batches of 100 within one SQLite transaction. The worker then uses those saved values to detect a source changed since the scan. bucket_profiles stores stable profile IDs and endpoint/bucket pairs without credentials. worker_lease stores the current owner and expiry. Jobs carry their bucket ID, and item keys resolve through their job.

End-to-end flow ​

1. Scan without changing R2 ​

POST /api/scan enqueues a scan for the selected bucketId and supplied key prefix. The worker lists R2 objects in pages of up to 1,000 and calls HeadObject for JPEG keys to read image-optimizer-version. It saves discovered rows and the list continuation token in SQLite after each page. JPEGs with an unsuccessful metadata check have metadata_status = unknown and cannot enter an optimization job.

For example, a scan of photos/2026/ may find photos/2026/IMG_0123.JPG (eligible), photos/2026/IMG_0124.jpg (already marked optimized), and photos/2026/notes.txt (not a JPEG). Matching is case-insensitive for .jpg and .jpeg. The scanner excludes __optimizer/originals/ backups from JPEG discovery. A whole-bucket scan can still see JPEGs left under __optimizer/restore-drills/; remove drill copies when finished or filter them out with a narrower job prefix.

2. Create a job from a completed scan ​

The dashboard filters known, unoptimized JPEGs by literal key prefix and minimum size, then sends the user's selected keys and scan ID to POST /api/jobs. The server verifies every selected key against the latest completed scan and filters in one SQLite transaction before inserting job items in batches. API callers may omit the selection to queue every eligible key. Defaults are Balanced quality 82, minimum saving 15%, metadata preservation on, mandatory verified original backup before replacement, and backup retention on. Archival and Aggressive use qualities 90 and 72.

Example request (illustrative):

http
POST /api/jobs
Content-Type: application/json

{"bucketId":"photos","prefix":"photos/2026/","minBytes":1048576,"preset":"balanced","minimumSavingPercent":15,"preserveMetadata":true,"deleteBackupAfterOptimization":false}

The API returns a queued job and candidate count. The worker begins asynchronously; GET /api/jobs and GET /api/jobs/<id> show progress. Scan results are paginated in groups of 10. Job item lists are paginated in groups of 100.

3. Process one JPEG safely ​

The GET stream is capped even if R2 omits Content-Length. The worker hashes downloads while streaming to private temporary files and removes those files after use. Compression still loads the capped original into memory; Sharp can use additional native memory for decoded pixels.

For a 10 MiB original that compresses to 8 MiB, the saving is 20%, so a 15% threshold allows replacement. If it compresses to 9 MiB, the saving is 10%; the worker marks the item skipped and writes neither a backup nor a replacement.

4. Keep the original independently restorable ​

For source key photos/2026/IMG_0123.JPG and job 42, the SHA-256 of the key text is eae9ac1fd7359a317d865777c97061a12971b6efe8790645add935594c838d31. R2 contains these related objects after a successful item:

text
photos/2026/IMG_0123.JPG
__optimizer/originals/42/eae9ac1fd7359a317d865777c97061a12971b6efe8790645add935594c838d31.jpg
__optimizer/manifests/42/eae9ac1fd7359a317d865777c97061a12971b6efe8790645add935594c838d31.jpg.json

The JSON manifest records the bucket, job and item IDs, original key and ETag, backup key, original byte length and SHA-256, object headers, custom metadata, and creation time. Its key mapping is readable directly from R2 if SQLite is lost. The backup key hash is derived from the source key; the sha256 inside the manifest is the hash of the original bytes.

The manifest format and __optimizer/ keys retain their original identifiers after the BucketLean rename so existing backups remain readable. The same applies to image-optimizer-* object metadata and the default optimizer.sqlite database filename.

For example, an illustrative manifest body looks like this (the byte hash and ETag below are placeholders):

json
{
  "format": "r2-jpeg-optimizer-backup",
  "version": 1,
  "bucket": "photos-test",
  "jobId": 42,
  "itemId": 301,
  "originalKey": "photos/2026/IMG_0123.JPG",
  "backupKey": "__optimizer/originals/42/eae9ac1fd7359a317d865777c97061a12971b6efe8790645add935594c838d31.jpg",
  "originalETag": "\"example-etag\"",
  "sha256": "<sha256-of-original-file-bytes>",
  "size": 10485760,
  "headers": {
    "contentType": "image/jpeg",
    "cacheControl": null,
    "contentDisposition": null,
    "contentEncoding": null,
    "contentLanguage": null,
    "expires": null,
    "metadata": { "camera": "example" }
  },
  "createdAt": "2026-09-24T12:00:00.000Z"
}

The worker never overwrites an existing backup or manifest blindly. It reads them back and verifies content before replacing the source. The source replacement itself is conditional on the original ETag. Optimized objects receive image-optimizer-version and job/item metadata so later scans can recognize them.

Failure and recovery paths ​

SituationItem/job behaviorWhat happens next
Source ETag or size differs from the scan, including a conditional 412source_changed, or needs_attention if a write may already have happenedDo not replace a changed source; inspect uncertain remote state
Input is not a valid JPEGItem invalid_jpeg; job can finish completed_with_errorsOriginal remains unchanged
Network timeout, 429, or 5xxItem retry_wait with persisted attempt count and 2 s / 4 s backoffRetry up to three application attempts; reconcile any prior write first
Credentials or bucket-wide failureJob pausedCorrect access/configuration and resume the job
Backup, manifest, or replacement outcome cannot be establishedItem and eventually job needs_attentionInspect source and backup; use Recheck remote state / POST /api/jobs/<id>/reconcile

An item is marked completed only after the optimized source and original backup match the recorded hashes and the restore manifest is verified. For opt-in cleanup, completion also waits until the backup and manifest are deleted and verified absent; cleanup_pending is durable across restarts. If a replacement succeeded but its response or follow-up check failed, a later run can recognize the worker's metadata and complete the item without another replacement. A job with ordinary failed or invalid-JPEG items ends as completed_with_errors. Savings totals remain pending when a replacement or changed source makes final bytes uncertain.

Operations and trust boundaries ​

  • The dashboard and data APIs require APP_PASSWORD through HTTP Basic authentication; /api/health is public and checks local SQLite/configuration presence, not live R2 availability. Put HTTPS in front of a deployed app. R2 credentials stay server-side.
  • SQLite contains durable progress, retry deadlines, hashes, and the lease. Use npm run db:snapshot -- --output <new-path> for a consistent snapshot and keep a verified copy outside the app host.
  • R2 backups and manifests are separate prefixes. npm run manifest:backfill verifies older backups and creates missing manifests. npm run restore:drill can verify a backup without SQLite; with --apply, it writes a separate object under __optimizer/restore-drills/ and checks its hash and headers without changing the source.
  • The application does not automatically restore originals. Backups are retained by default; opt-in cleanup deletes them after successful source verification. Configure a backup expiration rule only after snapshots, manifest verification, and a restore drill. See Operations and recovery for commands and deployment notes.

BucketLean is self-hosted software. The documentation site is static.