---
title: 'Obsidian to VPS Pipeline: Sync, Pull, and Redeploy'
description: A complete one-command publishing pipeline from Obsidian on macOS to a live Astro site on a VPS.
pubDate: '2026-03-11T18:15:00+01:00'
category: en/development
tags:
  - workflow
  - deployment
  - obsidian
---

I write posts in Obsidian, but shipping each one meant opening VS Code, committing Markdown, pushing, and then SSHing into the server to rebuild the container. Four steps, three places, too many opportunities to forget one. I wanted a single command on my laptop to do all of it.

This post documents the pipeline: one local shell script that updates the repo on the VPS, rsyncs the Markdown out of my Obsidian vault, and rebuilds the Astro container in one shot.

## The setup

- **Local**: macOS, Obsidian vault synced via iCloud Drive.
- **VPS**: Debian with `git`, `podman`, and `podman-compose` installed.
- **Site**: Astro running in a container managed by `podman-compose`, source at `/opt/websites/www.adrian-altner.de`.

## The goal

Three operations in order, triggered by one command locally:

1. Update the repository on the VPS (`git pull --ff-only`).
2. Sync the Markdown posts from macOS to the VPS (`rsync`).
3. Rebuild and restart the Astro container (`podman-compose`).

## Source and target paths

Local — the Obsidian vault lives inside iCloud's synced container, which means the path has spaces and tildes and needs quoting every time it appears:

```bash
/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/Web/adrian-altner-com/posts/
```

Remote — inside the Astro project:

```bash
/opt/websites/www.adrian-altner.de/src/content/posts
```

## Prerequisites

### SSH alias

An alias in `~/.ssh/config` keeps the script readable — `ssh hetzner` everywhere instead of a repeated IP plus flags:

```text
Host hetzner
  HostName <your-vps-ip-or-host>
  User root
  IdentityFile ~/.ssh/<your-key>
```

### Tools

- macOS: `rsync`, `ssh`.
- VPS: `git`, `podman`, `podman-compose`.

## Keeping one seed post in Git

Most posts are written in Obsidian and rsync'd, not committed. But I want at least one seed file (`hello-world.md`) tracked in Git so a fresh clone of the repo still renders something. The `.gitignore` split handles that:

```text
src/content/posts/*
!src/content/posts/hello-world.md
```

Commit the seed once and forget about it:

```bash
git add src/content/posts/hello-world.md
git commit -m "add hello-world seed post"
git push
```

## The publish script

Saved as `~/bin/publish-posts.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail

SRC='/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/Web/adrian-altner-com/posts/'
VPS="${1:-hetzner}"
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
REMOTE_POSTS="${REMOTE_BASE}/src/content/posts"

# 1) Update code on VPS
ssh "$VPS" "cd '$REMOTE_BASE' && git pull --ff-only"

# 2) Sync posts from Obsidian to VPS
# The explicit exclude keeps a Git-tracked seed file safe from --delete.
ssh "$VPS" "mkdir -p '$REMOTE_POSTS'"
rsync -az --delete \
  --exclude='hello-world.md' \
  --include='*.md' --exclude='*' \
  "$SRC" "$VPS:$REMOTE_POSTS/"

# 3) Rebuild and replace running container
ssh "$VPS" "cd '$REMOTE_BASE' && podman-compose -f compose.yml up --build -d --force-recreate"

echo "Repo pulled + posts synced + redeploy done via $VPS."
```

Two details are load-bearing. The `--exclude='hello-world.md'` on rsync prevents the seed post from being deleted by `--delete` whenever it isn't in my Obsidian folder. The `--include='*.md' --exclude='*'` pair restricts the sync to Markdown — Obsidian drops `.trash` directories and `.obsidian` config files alongside notes, and none of that belongs on the server.

Make it executable and run:

```bash
chmod +x ~/bin/publish-posts.sh
```

```bash
~/bin/publish-posts.sh
```

## Verification

After a run, two SSH checks tell me the container is up and not screaming:

```bash
ssh hetzner "podman ps --filter name=www.adrian-altner.de"
ssh hetzner "podman logs --tail 100 www.adrian-altner.de"
```

Then the new post URL in the browser — route resolves, title and date render, no startup errors in the logs.

## Troubleshooting

**Problem: container name conflict.** `podman-compose` sometimes fails with `name is already in use` when a previous container is stale. The `--force-recreate` flag in the script usually handles it, but if the error persists:

```bash
ssh hetzner "cd /opt/websites/www.adrian-altner.de && podman-compose -f compose.yml up --build -d --force-recreate"
```

or remove the container explicitly:

```bash
ssh hetzner "podman rm -f www.adrian-altner.de"
```

**Problem: `git pull` blocked by local changes on VPS.** If someone edited files directly on the server, fast-forward fails. Check the state:

```bash
ssh hetzner "cd /opt/websites/www.adrian-altner.de && git status"
```

Discipline, not tooling, is the fix here — keep the VPS working tree clean for predictable deploys.

## What to take away

- **One command beats four.** The pipeline collapses write → commit → push → deploy into a single shell invocation.
- **`--ff-only` is non-negotiable on a server.** A merge commit on the VPS is a deploy-time surprise you never want.
- **Whitelist what rsync sends.** `--include='*.md' --exclude='*'` stops Obsidian's hidden files from leaking into the site.
- **Keep one tracked seed post.** It prevents `--delete` from stripping the content directory bare and makes a fresh clone build without extra setup.
- **Boring on purpose.** Every step of the script is replaceable by cron, launchd, or CI later — the interface stays the same.
