---
title: Generating Photo Sidecars Locally Before a VPS Deploy
description: How to run Vision metadata generation against an Obsidian photo library locally and sync the generated JSON sidecars to a VPS.
pubDate: '2026-03-14T21:12:00+01:00'
category: en/development
tags:
  - workflow
  - photography
  - deployment
  - obsidian
seriesParent: obsidian-to-vps-pipeline-with-sync-pull-and-redeploy
seriesOrder: 1
---

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:

```bash
/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:

```bash
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:

```bash
#!/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.
