Build Zenfolio
KINDAreplaces $9/mosaves $108/yrback to the verdict
A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the catalogue, the preview queue and the gallery server.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree
Why Every preview and every rendered export goes through it, and its default limit and failure behaviour are what phase two is built around.
Get it Install it for your runtime and read its constructor options before writing the queue.
Verify
A deliberately oversize image is refused with the limit named, and the queue records it as a failure - have readyfree, plus disk for derivatives
Why Every hard part here is a scale problem, and a folder of twenty test images will tell you nothing about the preview queue or the integrity scan.
Get it Point the catalogue at a copy of a real shoot, including a panorama and at least one file you know is corrupt.
Verify
A full import and preview build completes and reports its failures - accounta percentage plus a fixed fee per sale; the fee is not returned on a refund
Why Selling downloads means settlement events, refunds and fees, and all three shape the delivery design.
Get it Create the account, complete verification, and take the keys plus the webhook signing secret.
Verify
A test payment settles and your endpoint receives a signed event - decidefree to decide; possibly not free to comply with
Why You are the merchant of record, and deciding this after the first international sale means deciding it retrospectively.
Get it Check the registration thresholds for the places your buyers are, write your refund policy, and put both in the checkout.
Verify
The checkout shows tax where you charge it and the refund policy text is stored with each order
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- assets(id, path, sha256, bytes, width, height, taken_at, orientation, camera, missing_since) · a path and a hash, and nothing that could be used to write to the file - previews(asset_id, width, format, path, built_at, failed_reason) · a failure is a recorded row, not a stopped import - edits(asset_id, version, params_json, created_at) · crop, exposure, contrast, white balance and saturation as numbers - galleries(id, name, client_name, token_hash, expires_at, download_policy, watermark, bytes_served) - gallery_assets(gallery_id, asset_id, position) and selections(id, gallery_id, asset_id, chosen_by, chosen_at, note) - orders(id, gallery_id, buyer_email, amount_minor, tax_minor, fee_minor, state, settled_at, delivered_at, licence_text) - downloads(order_id, asset_id, token_hash, expires_at, used_at, bytes) · one use, one record, so a forwarded link is visible - Nothing in this schema writes to an original. The only file paths stored for originals are read paths, and the test suite hashes them before and after every run.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
CATALOGUE_PATH | required | ./data/catalogue.db | Assets, edits, galleries, selections and orders. Not the photographs. |
LIBRARY_ROOTS | required | /mnt/photos/2026,/mnt/photos/2025 | Folders indexed in place. Opened read-only, never written. |
PREVIEW_CACHE | required | ./data/previews | Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. |
MAX_INPUT_PIXELS | required | 268402689 | The decoder's input limit, set deliberately. Larger files become recorded failures. |
GALLERY_LINK_DAYS | required | 90 | Default expiry set when a gallery link is created. |
MONTHLY_BYTES_CEILING | required | 100000000000 | Public gallery serving stops here. The local catalogue keeps working. |
PROCESSOR_SECRET_KEYsecret | required | sk_live_...redacted | Server side only, for checkout sessions and refunds. |
PROCESSOR_WEBHOOK_SECRETsecret | required | whsec_...redacted | Verifies settlement and refund events before anything is delivered. |
The build, in order
A catalogue that cannot touch your photographs
Importing indexes files in place, records hashes, and provably leaves every original byte identical.
The hash is both the identity and the proof.
Files
src/catalogue/import.tstests/originals-unchanged.test.tsOtherwise reorganising a folder loses a year of edits.
Files
src/catalogue/repair.tsAn unplugged drive is not a deletion.
Files
src/catalogue/scan.ts
done when · tick each as it passeswatch out- A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised.
Previews for twelve thousand files
Derivatives build through a queue that survives large files, corrupt files and interruption.
The default will refuse a panorama and the maximum will exhaust memory.
Files
src/previews/decode.tsdocs/limits.mdTwelve thousand files will contain a few bad ones.
Files
src/previews/queue.tstests/queue-failure.test.tsPreview builds take hours and machines restart.
Files
src/previews/queue.ts
done when · tick each as it passeswatch out- Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash.
Galleries and proofing that remember who chose
A client gallery is a selection served behind a token, and every choice is attributed and kept.
The link will be forwarded; plan for that from the start.
Files
src/galleries/tokens.tstests/token.test.tsThe argument about what was chosen is the one you must win.
Files
src/galleries/selections.tsDelivery is the cost that surprises people.
Files
src/galleries/usage.ts
done when · tick each as it passeswatch out- Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery.
Selling a download honestly
A digital sale delivers only after settlement, records its licence, and accounts for what a refund costs.
The return page is a redirect, not a payment.
Files
src/orders/fulfil.tstests/settlement.test.tsA download link is a bearer credential.
Files
src/orders/downloads.tsSo your accounting shows what a refund really cost.
Files
src/orders/fees.ts
done when · tick each as it passeswatch out- Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund.
Backups, ceilings and leavingproduct builder
The catalogue can be restored, the bandwidth has a ceiling, and everything exports in a documented form.
A restore that cannot find the photographs is not a restore.
Files
src/backup.tstests/restore.test.tsterminalnpm run backup npm run restore -- --verify
Drives fail quietly and so do synchronisation tools.
Files
src/integrity/scan.tsDelivery bandwidth is the bill that arrives without warning.
Files
src/galleries/limits.ts
done when · tick each as it passeswatch out- Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare.
That is the whole plan for Zenfolio. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Print sales and laboratory fulfilment.
- A raw conversion engine of your own.
- Hosted synchronisation between devices.
- Automatic culling and retouching models.
- hosting, templates, proofing, commerce, print labs, and photographer support
- proprietary raw-processing quality
- cloud sync and sharing
- large AI models
- camera and print ecosystem
- A watermarked proofing size generated once per gallery rather than per request
- Delivery through storage you already pay for, once the bandwidth ceiling starts being reached
- A per-shoot report of selections and downloads, which is the only client-facing number worth showing
Need the files? The project pack on the verdict page hands your agent the whole brief · more photo editing.