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.