Generating Photo Sidecars Locally Before a VPS Deploy

Category: Development

Tags: workflow, photography, deployment, obsidian


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.ts in the repo, exiftool and an OpenAI key present.
  • VPS: Hetzner Debian box serving the built site — intentionally kept free of API keys and image tooling.
  • Transport: rsync from 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:

  1. run vision.ts
  2. sync photo albums to the VPS
  3. 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_KEY
  • exiftool
  • local Node tooling just for metadata generation

The revised flow:

  1. run Vision locally against the Obsidian photo source
  2. sync posts to the VPS
  3. sync photo albums, including *.json, to the VPS
  4. 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
  • rsync transfers them together with the album images
  • the VPS no longer needs a .env file for OpenAI
  • the VPS no longer needs exiftool for 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.
  • rsync include/exclude rules are the right seam for “sync derived files alongside sources” — one directory, one transfer, no parallel state.