My Self-Hosted Notes Workflow (Obsidian + Cloudflare Zero Trust)

self hosted obsidian cloudflare zero trust couchdb

Working across Windows, macOS, iOS, and a handful of Linux distros, I’ve always struggled to find a decent notes app that just works everywhere.
I’m privacy-focused, prefer local storage, and don’t love the idea of scattering my data across a dozen cloud providers. That narrows the field pretty quickly.

Take Apple Notes, for example. It’s fine inside Apple’s walled garden, but the moment you step outside, things fall apart. The web version is stripped down, and you’ve got to log in with your full Apple account on every platform, which means giving access to everything else tied to that account.
For me, that’s a hard no.

Discovering Obsidian

After a fair bit of testing and frustration, I stumbled upon Obsidian and it instantly clicked.

Obsidian uses Markdown as its foundation, which for a developer is perfect. It’s lightweight, portable, and readable in any editor. Even without third-party plugins, the base app is surprisingly powerful, giving you a solid personal knowledge base out of the box.

They do offer a sync service for around $5 USD a month, and while it’s advertised as end-to-end encrypted, the fact that the client is not open source makes it tricky to verify that claim.
And honestly, for someone like me who already runs a home server, paying another monthly bill for syncing notes just doesn’t make much sense.

The First Hurdle: No Obsidian Web Version

The biggest drawback? Obsidian doesn’t have a native web interface.
You’ve got to install the app on each device you want to use it on. That’s fine for daily machines, but if you’re like me and constantly tinkering with different distros or working in a sandboxed malware analysis environment, it’s a pain.

There are times when inspiration strikes mid-test, and by the time you’ve installed and configured Obsidian, the idea’s already gone.
Plus, in isolated or high-risk environments, storing plaintext Markdown files isn’t ideal from a security perspective.

So, a web version was non-negotiable.

Running Obsidian in Docker

Thankfully, the developers at LinuxServer.io came to the rescue with a fantastic Docker image for Obsidian.

In just a few commands, I had it running in a container, accessible via a browser. That solved the accessibility issue, but introduced another one: security.
The image doesn’t come with built-in authentication, meaning if you expose it directly to the web, you’re basically publishing all your notes to anyone who stumbles across the port. Not great!

Securing Obsidian Access with Cloudflare Zero Trust

To lock it down properly, I used Cloudflare Zero Trust Tunnels to publish the Obsidian container securely.
This setup means the Docker service never actually opens a public port. Cloudflare handles the tunnel, and only authenticated users (me) can get through.

I set up Zero Trust access policies requiring proper login via Cloudflare before I can reach the instance.
No public exposure, no dodgy logins, no worries.

I also recommend adding a local authentication layer in front of Obsidian as a fallback, even though Cloudflare Zero Trust already protects the tunnel.
This is typically done by running a lightweight reverse proxy (such as Caddy, Traefik, or Nginx) inside the Docker network and configuring it to require auth headers or token-based authentication before requests reach the Obsidian container.

This way, even if Cloudflare misconfiguration ever exposed the service, the local reverse proxy would still enforce access control.

Syncing Obsidian Notes Without Paying for It

The next challenge was syncing notes across my devices.
Since I skipped Obsidian’s official sync, I went with the community-built Live Sync plugin, which uses CouchDB as a backend.

The plugin’s docs suggest hosting CouchDB on Fly.io, but again, that’s more cost, more exposure, and more risk.
So instead, I spun up CouchDB on my Raspberry Pi 5, hosted at home, and routed it securely through Cloudflare Zero Trust again.

To make the Live Sync plugin behave, I had to bypass some of Cloudflare’s default protections and use basic auth. Not ideal, but I mitigated the risk by:

  • Using a randomly generated hostname
  • Planning to lock it to static VPN IPs in the next iteration

It might sound a bit paranoid, but when you spend your days analysing WAF logs and seeing constant brute-force attacks on random services, you learn to take this stuff seriously.

Obsidian Backups You Can Actually Trust

I don’t trust plugin-based backups for something as critical as my notes, so I wrote my own.

A simple Bash script, run via cron, handles it all:

  • Checks for changes with git diff
  • Automatically commits and pushes updates to my private repo
  • Stops CouchDB briefly, encrypts the data, and ships an archive off to AWS S3

It’s basic, but it’s bulletproof. It gives me full version history, offsite storage, and disaster recovery, all under my control.

#!/bin/bash
set -e
OBSIDIAN_ROOT="/path/to/obsidian"
BACKUP_DIR="$OBSIDIAN_ROOT/backups"
VAULT_DIR="$OBSIDIAN_ROOT/obsidian_config/Obsidian Vault"
COUCHDB_DIR="$OBSIDIAN_ROOT/path/to/couchdb/volume/path"
REPO="$OBSIDIAN_ROOT/.git"
S3_BUCKET="s3://path-to-s3-backups-bucket"
DOCKER_COMPOSE_FILE="$OBSIDIAN_ROOT/docker-compose.yml"
ENV_FILE="$OBSIDIAN_ROOT/.env"

cd $OBSIDIAN_ROOT
# Load environment variables (expects BACKUP_PASSPHRASE_ENV)
if [ -f "$ENV_FILE" ]; then
    export $(grep -v '^#' "$ENV_FILE" | xargs)
else
    echo "⚠️  .env file not found!"
    exit 1
fi

if [ -z "$BACKUP_PASSPHRASE_ENV" ]; then
    echo "❌ BACKUP_PASSPHRASE_ENV not set in .env file!"
    exit 1
fi

mkdir -p "$BACKUP_DIR"

# Check for vault changes
if git diff --quiet && git diff --cached --quiet; then
    echo "No vault changes detected; proceeding with CouchDB snapshot only."
    exit 0
else
    echo "Vault changes detected; will back up CouchDB and Git."
fi

# --- Detect current CouchDB state ---
echo "Checking CouchDB state..."
if docker compose -f "$DOCKER_COMPOSE_FILE" ps --services --filter "status=running" | grep -q "^couchdb$"; then
    COUCHDB_WAS_RUNNING=true
    echo "CouchDB is currently running. It will be stopped temporarily for snapshot."
else
    COUCHDB_WAS_RUNNING=false
    echo "CouchDB is not running. Snapshot will be created without starting it later."
fi

# --- Stop CouchDB if running ---
if [ "$COUCHDB_WAS_RUNNING" = true ]; then
    echo "Stopping CouchDB..."
    docker compose -f "$DOCKER_COMPOSE_FILE" stop couchdb
fi

# --- Create timestamped snapshot ---
DATE=$(date +%F)
DATETIME=$(date +%F_%H-%M-%S)
SNAPSHOT_FILE="couchdb_${DATETIME}.tar.gz"
SNAPSHOT_PATH="$BACKUP_DIR/$SNAPSHOT_FILE"

echo "Creating CouchDB snapshot..."
tar -czf "$SNAPSHOT_PATH" -C "$COUCHDB_DIR" .

# --- Restore CouchDB state ---
if [ "$COUCHDB_WAS_RUNNING" = true ]; then
    echo "Restarting CouchDB..."
    docker compose -f "$DOCKER_COMPOSE_FILE" up -d couchdb
else
    echo "CouchDB was originally stopped; keeping it stopped."
fi

# --- Commit and push vault changes ---
git add .
git commit -m "Auto backup ${DATETIME}" || echo "Nothing to commit."
git push origin backups

# --- Encrypt the snapshot ---
echo "Encrypting snapshot..."
ENCRYPTED_FILE="${SNAPSHOT_PATH}.enc"
openssl enc -aes-256-cbc -pbkdf2 -salt \
    -in "$SNAPSHOT_PATH" -out "$ENCRYPTED_FILE" \
    -pass env:BACKUP_PASSPHRASE_ENV

rm "$SNAPSHOT_PATH"  # remove unencrypted version

# --- Upload to S3 under structured path ---
S3_PATH="${S3_BUCKET}/obsidian/${DATE}/"
echo "Uploading encrypted backup to S3: $S3_PATH"
aws s3 cp "$ENCRYPTED_FILE" "$S3_PATH" --storage-class STANDARD_IA

echo "✅ Backup complete and encrypted successfully."

# Decrypting example (for reference):
# openssl enc -d -aes-256-cbc -pbkdf2 -salt \
#   -in couchdb_2025-11-02_14-30-00.tar.gz.enc \
#   -out couchdb_2025-11-02_14-30-00.tar.gz \
#   -pass env:BACKUP_PASSPHRASE_ENV
# tar -xzf couchdb_2025-11-02_14-30-00.tar.gz -C /path/to/restore/
# rm couchdb_2025-11-02_14-30-00.tar.gz

Optimising self-hosted Obsidian for Performance

Running a full desktop Electron app (Obsidian) inside Docker on a Raspberry Pi 5 chews through a surprising amount of memory and CPU.
Since I don’t need it online 24/7, I wanted a way to start and stop it quickly.

That’s where Telegram comes in.

I’ve got a custom Python Telegram bot that manages a bunch of my home-hosted apps. It’s basically a control panel in chat form.
With a few buttons, I can:

  • Spin up or shut down containers (docker-compose up/down)
  • Check service status
  • Even send text or screenshots directly to the bot and have them saved as notes

For critical actions, I’ve added OTP verification, and the whole thing runs through Cloudflare WebSSH with Zero Trust on top.
The result? A private, secure, on-demand note-taking environment that’s accessible from anywhere in seconds.

The End Result

So what I’ve ended up with is a self-hosted, cross-platform Obsidian setup that’s:

  • Secure (thanks to Cloudflare Zero Trust and local hosting)
  • Private (no third-party sync)
  • Cheap (no monthly fees)
  • Reliable (with Git-based and s3 backups)

It works seamlessly across my devices, fits into my existing self-hosting stack, and doesn’t rely on anyone else’s infrastructure.

It’s been a fun little project to piece together.
here is the minimum docker-compose.yml file

services:
  obsidian:
    image: lscr.io/linuxserver/obsidian:latest
    container_name: obsidian
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Timezone/Your_City
    volumes:
      - ./obsidian_config_folder:/config
    ports:
      - 8000:3000
      - 8001:3001
    shm_size: "1gb"
    restart: unless-stopped
    depends_on:
      - couchdb

  couchdb:
    image: couchdb:3.5.0
    container_name: couchdb
    volumes:
      - ./couchdb_data_folder:/opt/couchdb/data
      - ./couchdb_etc_folder:/opt/couchdb/etc/local.d
    ports:
      - 12345:5984
    environment:
      - COUCHDB_USER=${COUCHDB_USER_NAME}
      - COUCHDB_PASSWORD=${COUCHDB_PASSWORD_VALUE}
      - COUCHDB_SECRET=${COUCHDB_SECRET_VALUE}
    restart: unless-stopped

  cloudflared_obsidian:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared_obsidian
    command: tunnel run
    restart: unless-stopped
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN_OBSIDIAN_VALUE}
    volumes:
      - ./cloudflare_folder/obsidian_logs_folder:/var/log/cloudflared

  cloudflared_couchdb:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared_couchdb
    command: tunnel run
    restart: unless-stopped
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN_COUCHDB_VALUE}
    volumes:
      - ./cloudflare_folder/couchdb_logs_folder:/var/log/cloudflared

    

Leave a Comment