Generating Photo Sidecars Locally Before a VPS Deploy
How to run Vision metadata generation against an Obsidian photo library locally and sync the generated JSON sidecars to a VPS.
Category: Development
When I first wired up photo albums on this site, I wired Vision metadata generation into the VPS as well — the server would grab the JPGs, call the API, and write sidecars next to them. That turned out to be wrong.
The sidecar JSON files were generated by scripts/vision.ts, but the actual photo source wasn’t the repository. The real source lived in Obsidian on macOS:
/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/Web/adrian-altner-com/photos/
The VPS only received a synced copy of that folder.
The setup
- Local: macOS with the photo library under an Obsidian iCloud vault,
scripts/vision.tsin the repo,exiftooland an OpenAI key present. - VPS: Hetzner Debian box serving the built site — intentionally kept free of API keys and image tooling.
- Transport:
rsyncfrom the Obsidian folder to the VPS content directory, followed by a container rebuild.
The problem
Problem: The deploy succeeded, but the VPS never got any .json sidecars because they had not been generated in the real source directory before rsync.
The original mental model looked like this:
- run
vision.ts - sync photo albums to the VPS
- build the site
Step 1 was running against the wrong folder. If the Vision script reads from src/content/photos in the repository while deployment syncs from an external Obsidian folder, the generated JSON files land in the wrong place. rsync cannot upload files that do not exist in the synced source.
Better model
Implementation: Treat the VPS as a deployment target only. Do the expensive and environment-sensitive work locally:
- OpenAI API calls
- EXIF extraction with
exiftool - JSON sidecar generation
Then sync the already prepared album folders to the server. That keeps the VPS free from:
OPENAI_API_KEYexiftool- local Node tooling just for metadata generation
The revised flow:
- run Vision locally against the Obsidian photo source
- sync posts to the VPS
- sync photo albums, including
*.json, to the VPS - rebuild the container on the VPS
Local Vision command
scripts/vision.ts now accepts an optional photo source directory:
pnpm run vision -- "/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/Web/adrian-altner-com/photos/"
That makes the script generate sidecars next to the actual JPG files that will be synced a moment later.
Publish script
The relevant shape of the deploy script:
#!/usr/bin/env bash
set -euo pipefail
SRC='/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/Web/adrian-altner-com/posts/'
PHOTO_SRC='/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/Web/adrian-altner-com/photos/'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
REMOTE_POSTS="${REMOTE_BASE}/src/content/posts"
REMOTE_PHOTOS="${REMOTE_BASE}/src/content/photos"
pnpm run vision -- "$PHOTO_SRC"
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_POSTS'
mkdir -p '$REMOTE_PHOTOS'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--exclude='*' \
"$SRC" "$VPS:$REMOTE_POSTS/"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.JPG' \
--include='*.JPEG' \
--include='*.json' \
--exclude='.DS_Store' \
--exclude='*' \
"$PHOTO_SRC" "$VPS:$REMOTE_PHOTOS/"
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
"
Why this is the cleaner architecture
Solution: It’s not just a workaround. The machine that already has the original photos, the API key, and the required metadata tools should also be the machine that generates the derived files. The VPS only needs the final content it serves.
After this change:
- the JSON sidecars are created directly inside the Obsidian album folders
rsynctransfers them together with the album images- the VPS no longer needs a
.envfile for OpenAI - the VPS no longer needs
exiftoolfor this step - the site build can treat the sidecars as plain synced content
What to take away
- If deployment syncs from an external content source, any preprocessing step must run against that same source — don’t mix repository paths and publish-source paths unless they’re the same directory.
- Push expensive, environment-sensitive work — API calls, binary tools, secrets — to the machine that already has the originals, not the server that only receives a copy.
- Keep the VPS narrow: no API keys, no image tooling, no Node-for-metadata. It only needs the built site and the static assets.
rsyncinclude/exclude rules are the right seam for “sync derived files alongside sources” — one directory, one transfer, no parallel state.