← Home

Generating Photo Sidecars Locally Before a VPS Deploy

When I added photo albums to this site, I initially treated metadata generation as a VPS concern.

That turned out to be the wrong place for it.

The sidecar JSON files were generated by scripts/vision.ts, but the actual photo source was not 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.

That mismatch caused the failure: 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 problem

The original mental model looked like this:

  1. run vision.ts
  2. sync photo albums to the VPS
  3. build the site

But 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, then the generated JSON files are written to the wrong place. rsync cannot upload files that do not exist in the synced source.

Better model

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 working flow

The local publish script now does this:

  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 are going to be synced later.

Publish script

This is 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 workaround is better

It is not just a workaround. It is the cleaner architecture.

The machine that already has:

  • the original photos
  • the API key
  • the required metadata tools

should also be the machine that generates the derived files.

The VPS should only receive the final content it needs to serve the site.

Practical result

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

Rule to keep

If deployment syncs from an external content source, any preprocessing step must run against that same source.

Do not mix repository paths and publish-source paths unless they are actually the same directory.

← Home