Menu
Home Videos Blog Tech Projects Shop Digital Designs Physical Art Layered Maps Chinese Paper Cuttings About Contact Cart (0)

Quartz can turn your Obsidian notes into a website. You do not need GitHub Pages. You can build the site on your own server and serve it with Nginx.

Using your own server gives you more control than GitHub Pages. You can choose your own web server configuration, domain setup, privacy rules, access logs, deployment process, and storage location. It also avoids depending on GitHub for hosting and can bypass GitHub storage limitations, which is useful if you want full ownership of your notes and publishing workflow.

Traditional publishing of Obsidian notes, including the official guide, depends on GitHub Actions. This guide shows one way to publish a single Obsidian note with Templater directly to your own server.

How It Works

The flow is simple:

Obsidian note → Templater command → upload note to server → run Quartz build on server → Nginx serves the public site → commit and push to your own GitHub repo for backup

Quartz reads Markdown files from:

/www/quartz/content

Then it builds HTML files into:

/www/quartz/public

Nginx serves:

/www/quartz/public

Your notes stay on your server. GitHub is optional. In this setup, GitHub is only used as a backup repo.

Server Setup

On the server, put Quartz here:

/www/quartz

Install Node with nvm. For example, this setup uses:

/home/doer/.nvm/versions/node/v22.17.0/bin

Test the build from your Mac:

ssh -p 2201 jacobhere@10.24.200.201 'export PATH="/home/doer/.nvm/versions/node/v22.17.0/bin:$PATH"; cd /www/quartz && npx quartz build'

If this works, Quartz can build on the server.

Nginx Setup

Point Nginx to the Quartz public folder:

server { listen 80; server_name lab.doer.ee; root /www/quartz/public; index index.html; error_page 404 /404.html; location / { try_files $uri $uri.html $uri/ =404; } }

Then reload Nginx:

sudo nginx -t sudo systemctl reload nginx

Pick a Publish Root

Your local Obsidian vault may have a long folder path like this:

/Users/doer/Documents/Pasco/Doer/lab

You may not want Doer/lab in the public URL.

So this local file:

/Users/doer/Documents/Pasco/Doer/lab/tech/my-note.md

should publish as:

https://lab.doer.ee/tech/my-note

The script below strips the publish root path before it uploads the note.

Templater Script

Create one Templater template and paste this code into it:

<%*
const { execFile } = require("child_process")
const path = require("path")

const PUBLISH_ROOT = "YOUR_LOCAL_OBSIDIAN_PATH"
const LOCAL_RSYNC = "/usr/bin/rsync"

const SSH_USER_HOST = "doer@192.168.1.2"
const SSH_PORT = "22"
const SERVER_QUARTZ = "/www/quartz"
const REMOTE_NODE_BIN = "/home/doer/.nvm/versions/node/v22.17.0/bin"

function notice(message) {
  new Notice(message, 5000)
}

function shellQuote(value) {
  return `'${String(value).replace(/'/g, `'\\''`)}'`
}

function run(command, args, options = {}) {
  return new Promise((resolve, reject) => {
    execFile(command, args, options, (error, stdout, stderr) => {
      if (error) reject(new Error(stderr || error.message))
      else resolve(stdout)
    })
  })
}

async function remote(command) {
  return run("ssh", ["-p", SSH_PORT, SSH_USER_HOST, command])
}

async function ensureRemoteDir(remoteDir) {
  await remote(`mkdir -p ${shellQuote(remoteDir)}`)
}

async function uploadFile(localPath, remotePath) {
  const remoteDir = path.posix.dirname(remotePath)
  await ensureRemoteDir(remoteDir)

  await run(LOCAL_RSYNC, [
    "-avz",
    "-e",
    `ssh -p ${SSH_PORT}`,
    localPath,
    `${SSH_USER_HOST}:${shellQuote(remotePath)}`,
  ])
}

function getVaultBasePath() {
  const adapter = app.vault.adapter

  if (!adapter.basePath) {
    throw new Error("Could not find vault base path. This only works on Obsidian desktop.")
  }

  return adapter.basePath
}

function isInsidePublishRoot(localPath) {
  const relativePath = path.relative(PUBLISH_ROOT, localPath)
  return !(relativePath.startsWith("..") || path.isAbsolute(relativePath))
}

function toRemoteContentPath(localPath) {
  const relativePath = path.relative(PUBLISH_ROOT, localPath)

  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
    throw new Error(`File is not inside publish root: ${PUBLISH_ROOT}`)
  }

  return path.posix.join(
    SERVER_QUARTZ,
    "content",
    relativePath.split(path.sep).join(path.posix.sep)
  )
}

function getMarkdownAssetLinks(markdown) {
  const links = new Set()

  const embedRegex = /!\[\[([^|\]]+)(?:\|[^\]]*)?\]\]/g
  for (const match of markdown.matchAll(embedRegex)) {
    links.add(match[1].split("#")[0].replace(/^\//, ""))
  }

  const mdImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g
  for (const match of markdown.matchAll(mdImageRegex)) {
    const raw = decodeURIComponent(match[1].trim()).split("#")[0]

    if (
      !raw.startsWith("http://") &&
      !raw.startsWith("https://") &&
      !raw.startsWith("data:")
    ) {
      links.add(raw.replace(/^\//, ""))
    }
  }

  return [...links]
}

async function uploadReferencedAssets(file, vaultBasePath, markdown) {
  const assetLinks = getMarkdownAssetLinks(markdown)
  let uploadedCount = 0

  for (const link of assetLinks) {
    const assetFile = app.metadataCache.getFirstLinkpathDest(link, file.path)

    if (!assetFile) {
      console.warn(`Could not resolve asset: ${link}`)
      continue
    }

    const localAssetPath = path.join(vaultBasePath, assetFile.path)

    if (!isInsidePublishRoot(localAssetPath)) {
      console.warn(`Asset is outside publish root, skipping: ${assetFile.path}`)
      continue
    }

    const remoteAssetPath = toRemoteContentPath(localAssetPath)
    await uploadFile(localAssetPath, remoteAssetPath)
    uploadedCount++
  }

  return uploadedCount
}

const file = app.workspace.getActiveFile()

if (!file) {
  notice("No active note to publish")
  return
}

if (file.extension !== "md") {
  notice("Active file is not a Markdown note")
  return
}

try {
  notice(`Publishing ${file.basename}...`)

  const vaultBasePath = getVaultBasePath()
  const localNotePath = path.join(vaultBasePath, file.path)
  const remoteNotePath = toRemoteContentPath(localNotePath)

  const markdown = await app.vault.read(file)

  await uploadFile(localNotePath, remoteNotePath)
  const uploadedAssetCount = await uploadReferencedAssets(file, vaultBasePath, markdown)

  await remote(`
    export PATH="${REMOTE_NODE_BIN}:$PATH"
    cd ${shellQuote(SERVER_QUARTZ)}
    npx quartz build
    git add content
    if ! git diff --cached --quiet; then
      git commit -m ${shellQuote(`Publish ${file.basename}`)}
      git push origin HEAD
    fi
  `)

  const publishedPath = path
    .relative(PUBLISH_ROOT, localNotePath)
    .replace(/\.md$/i, "")
    .split(path.sep)
    .join("/")

  notice(`Published: /${publishedPath} (${uploadedAssetCount} assets)`)
} catch (error) {
  console.error(error)
  notice(`Publish failed: ${error.message}`)
}
%>

Use It

Open the note you want to publish in Obsidian.

Run the Templater template.

If the note is here:

/Users/jacobhere/Documents/Pasco/Doer/lab/tech/my-note.md

it will publish here:

https://lab.doer.ee/tech/my-note

What Happens When You Edit a Note

If you edit the same file and publish again, the script overwrites the old Markdown file on the server.

Quartz then rebuilds the same page.

So this file:

tech/my-note.md

keeps this URL:

https://lab.doer.ee/tech/my-note

What Happens When You Rename a Note

If you rename a file, Quartz sees it as a new page.

Old file:

tech/my-note.md

Old URL:

/tech/my-note

New file:

tech/better-title.md

New URL:

/tech/better-title

The old file may still exist on the server. Delete it if you do not want the old URL to work.

Git Setup

Your server Quartz repo can use your own GitHub repo as origin:

cd /www/quartz 
git remote set-url origin git@github-doer-ee:doer-ee/lab.doer.ee.git

Keep the official Quartz repo as upstream:

upstream https://github.com/jackyzha0/quartz.git

This gives you two roles:

origin = your site repo upstream = official Quartz repo

The Templater script commits and pushes content/ after each publish.

About Quartz Git Warnings

You may see a warning like this:

content/my-note.md isn't yet tracked by git, dates will be inaccurate

This means Quartz cannot get the page date from Git history.

It does not block publishing.

Once the script commits the note, future builds can use Git history for dates. You can also add dates in frontmatter:

--- date: 2026-05-04 ---

Summary

This setup keeps publishing simple.

You write in Obsidian. You run one Templater command. The note uploads to your server. Quartz builds the site. Nginx serves the page. GitHub stores a copy, but it does not host the site.

Your server remains the source of the public website.