name: obsidian-vps-vault-deploy
description: Deploy an Obsidian vault on VPS as a browsable website with GitHub sync — custom Node.js server approach (NOT Quartz).
tags: [obsidian, vps, deploy, github, sync]
author: Rocha Sales / Hermes
date: 2026-06-13
Obsidian VPS Vault — Deploy and Sync
Deploy an Obsidian vault on a VPS as a browsable website, with GitHub sync.
Approach
Why NOT Quartz: Quartz v5 has build issues on VPS (symlink problems, Node22 requirement, complex dependency chain). It kept failing with enoent errors when content was symlinked.
The working approach: Custom Node.js server that serves .md files directly with directory listing and markdown rendering. Simple, reliable, full control.
Prerequisites
- - VPS with nginx, SSL (certbot), SSH access
- - Git installed on VPS
- - Node.js on VPS (v20 works fine)
- - GitHub personal access token with
reposcope - ⬆️..
- ${icon}${item.name}${item.isDir ? '/' : ''}
- $1 ')
- .*<\/li>)/s, '
- $1
.replace(/^\|(.+)\|$/gm, (match) => {
const cells = match.split('|').filter(c => c.trim());
return '
';' + cells.map(c => ).join('') + '${c.trim()} })
.replace(/(
.*<\/tr>)/s, ' $1
').replace(/^---$/gm, '
').replace(/\n\n/g, '
')
.replace(/^(?!<[huloT]|
$1');return html;
}
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
let reqPath = parsed.pathname;
if (reqPath === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderFolder(VAULT_PATH, '', VAULT_PATH));
return;
}
reqPath = decodeURIComponent(reqPath);
let fullPath = path.join(VAULT_PATH, reqPath);
if (fullPath.endsWith('/')) {
fullPath = fullPath.slice(0, -1);
}
// Block hidden files
const fileName = path.basename(fullPath);
if (HIDDEN.includes(fileName)) {
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('
403 - Proibido
');return;
}
try {
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderFolder(fullPath, reqPath.slice(1), VAULT_PATH));
return;
}
const ext = path.extname(fullPath);
const mime = MIME_TYPES[ext] || 'text/plain; charset=utf-8';
if (ext === '.md') {
const content = fs.readFileSync(fullPath, 'utf-8');
const htmlContent = markdownToHtml(content);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderMarkdown(htmlContent, fullPath));
} else {
res.writeHead(200, { 'Content-Type': mime });
res.end(fs.readFileSync(fullPath));
}
} catch (e) {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
404 - Não encontrado
O arquivo não existe no vault.
`);
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(
Obsidian site running on port ${PORT});});
`Step 5 — Create Systemd Service
`bashsshpass -p 'PASSWORD' ssh root@VPS_IP "cat > /etc/systemd/system/obsidian-site.service << 'EOF'
[Unit] Description=Obsidian Vault Web Server After=network.target
[Service] Type=simple ExecStart=/usr/bin/node /var/www/obsidian-site/index.js Restart=always User=root
[Install] WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable obsidian-site && systemctl start obsidian-site"
`Step 6 — Nginx Reverse Proxy
`bashsshpass -p 'PASSWORD' ssh root@VPS_IP "cat > /etc/nginx/sites-available/DOMAIN << 'NGINX'
server {
listen 80; server_name DOMAIN; return 301 https://\\$host\\$request_uri;
}
server {
listen 443 ssl; server_name DOMAIN;
ssl_certificate /etc/letsencrypt/live/DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/DOMAIN/privkey.pem;
location / {
proxy_pass http://127.0.0.1:PORT;
proxy_http_version 1.1;
proxy_set_header Host \\$host;
proxy_set_header X-Real-IP \\$remote_addr;
}
}
NGINX
ln -sf /etc/nginx/sites-available/DOMAIN /etc/nginx/sites-enabled/
nginx -t && nginx -s reload"
`Issue cert:
`bashcertbot --nginx -d DOMAIN
`Step 7 — GitHub Sync Script
`bashsshpass -p 'PASSWORD' ssh root@VPS_IP "cat > /usr/local/bin/obsidian-sync.sh << 'SCRIPT'
#!/bin/bash
cd /var/www/obsidian-vault
git add -A
git diff --cached --quiet || { git commit -m \"auto-sync \$(date +%Y-%m-%d\ %H:%M)\" && git push origin main; }
SCRIPT
chmod +x /usr/local/bin/obsidian-sync.sh"
`Schedule in cron:
`bashsshpass -p 'PASSWORD' ssh root@VPS_IP \
'(crontab -l 2>/dev/null | grep -v obsidian-sync; echo "/5 * /usr/local/bin/obsidian-sync.sh >> /var/log/obsidian-sync.log 2>&1") | crontab -'
`Common Issues
EADDRINUSE — port already in use (orphan process)
Symptom:
journalctl -u obsidian-siteshowsError: listen EADDRINUSE: address already in use 127.0.0.1:3099and service keeps restarting.Cause: A previous node process for the vault is already running on that port (not managed by systemd — orphan or from a previous manual run).
Fix:
`bashFind the orphan process
lsof -i :3099
Kill it (replace PID)
kill
Then restart the service
systemctl daemon-reload
systemctl restart obsidian-site
systemctl status obsidian-site --no-pager
`Recovery if service keeps restarting: Check
journalctl -u obsidian-site -f— if EADDRINUSE persists, the orphan process respawned or there are two processes fighting. Kill by name:`bashpkill -f "node /var/www/obsidian-site/index.js"
sleep 1
systemctl restart obsidian-site
`Vault accidentally deleted
If
quartz createor similar command wipes the vault, restore from GitHub:`bashcd /var/www/obsidian-vault
git init
git remote add origin https://TOKEN@github.com/USER/repo.git
git pull origin main
`Git credentials not persisting
Store in git remote URL:
https://TOKEN@github.com/USER/repo.gitGitHub Push Blocked — Secret Scanning (CRITICAL — error message is misleading)
Symptom:
git pushfails with:`! [remote rejected] main -> main (push declined due to repository rule violations)
`OR with explicit secret scanning message:
`remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: Push cannot contain secrets
remote: —— Discord Bot Token ——
remote: locations:
remote: - commit: HASH path: 02-AGENTS/HERMES-SETUP.md:31
`⚠️ KEY LESSON — Two completely different causes produce the SAME error message:
1. Branch Protection — API returns 404 if not configured. Symptoms: explicit "branch protection" in error.
2. GitHub Secret Scanning — API returns 404 if you query branch protection (because it's not set!). Symptoms: "repository rule violations", "secret-scanning/unblock-secret/HASH" in log. The error message does NOT say "secret scanning" — it says "repository rule violations" which confuses everyone.
How to diagnose correctly:
`bashCheck sync log — look for the secret-scanning URL
tail /var/log/obsidian-sync.log
If you see this URL, it's secret scanning (not branch protection):
https://github.com/OWNER/REPO/security/secret-scanning/unblock-secret/HASH
Branch protection returns 404 if not set (check via API):
curl -s -u "user:TOKEN" \
"https://api.github.com/repos/OWNER/REPO/branches/main/protection"
If it returns 404 → no branch protection configured → must be secret scanning
`Prevention — NEVER put real secrets in vault files:
Prevention — NEVER put real secrets in vault files:
- - Use
REDACTED,*, or placeholder values for any token/key/password- - Examples of dangerous content:
DISCORD_BOT_TOKEN=MTQ4NT...,SUPABASE_KEY=eyJ..., API keys, passwords- - The vault is a public website — anything committed is visible
Fix — Two options:
Option A — Fast (if you can access GitHub):
1. Go to:
https://github.com/OWNER/REPO/security/secret-scanning2. Find the blocked secret and click "Unblock" or "Allow"
3. Push again
Option B — Create a clean repo:
`bash1. Create new repo on GitHub (via API or web)
curl -s -X POST "https://api.github.com/user/repos" \
-H "Authorization: token GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"obsidian-vault-clean","description":"Clean vault","private":false}'
2. Change remote to new repo
cd /var/www/obsidian-vault
git remote set-url origin https://GITHUB_TOKEN@github.com/USER/new-repo.git
3. Push fresh (rewrite local history first if needed)
git filter-branch --force --tree-filter 'find . -name "*.md" -exec sed -i "s/REAL_TOKEN/REDACTED/g" {} \;' HEAD
git push --force origin main
`Why
filter-branchalone doesn't work: GitHub scans all commits in the push, including the pre-rewrite ones. The secret must be removed from the remote history entirely, which requires either unblocking on GitHub or force-pushing after a full history rewrite.Nginx site not responding
Check:
nginx -t && nginx -s reloadCheck cert:
certbot renew --dry-runServer not starting (generic debugging)
`bash1. Check systemd log
journalctl -u obsidian-site --no-pager -n 30
2. Check port
lsof -i :3099
3. Check if process is running
ps aux | grep obsidian
4. Manual start to see errors
/usr/bin/node /var/www/obsidian-site/index.js
`File Transfer Tip
When updating the server script (e.g. adding features), use this sequence to avoid downtime:
`bash1. Backup current
cp /var/www/obsidian-site/index.js /var/www/obsidian-site/index.js.bak
2. SCP new file to tmp
(write_file locally, then scp)
3. Move into place
mv /tmp/obsidian-index.js /var/www/obsidian-site/index.js
4. Restart (kills old process, starts new atomically)
systemctl restart obsidian-site
sleep 2
systemctl status obsidian-site --no-pager | head -10
`Nginx site not responding
Check:
nginx -t && nginx -s reloadCheck cert:
certbot renew --dry-runFile Transfer Tip
When writing configs to VPS, use
scpinstead of heredoc to avoid$escaping issues:`bashwrite_file content to /tmp/file.conf locally
scp /tmp/file.conf root@VPS:/tmp/file.conf
ssh root@VPS "mv /tmp/file.conf /destination/"
` - - Use
Step 1 — Create Vault Directory
`bash
sshpass -p 'PASSWORD' ssh -o StrictHostKeyChecking=no root@VPS_IP "mkdir -p /var/www/obsidian-vault"
`
Step 2 — Create GitHub Repo via API
`bash
curl -s -X POST "https://api.github.com/user/repos" \
-H "Authorization: token GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"obsidian-vault","description":"Obsidian Vault","private":false}'
`
Step 3 — Initialize Git and Push
`bash
sshpass -p 'PASSWORD' ssh -o StrictHostKeyChecking=no root@VPS_IP "
cd /var/www/obsidian-vault
git init
git config user.email 'vault@local'
git config user.name 'Vault Owner'
git remote add origin https://GITHUB_TOKEN@github.com/USER/repo.git
git add .
git commit -m 'initial commit'
git push -u origin main
"
`
Step 4 — Create the Web Server
> ⚠️ Use scp to transfer files to VPS — heredoc $ escaping causes silent failures. Write locally, scp to /tmp/, then mv into place.
Improved index.js features: hidden file filter (.git, .gitignore), breadcrumb navigation, proper markdown table/list/blockquote rendering.
`javascript
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const PORT = 3099;
const VAULT_PATH = '/var/www/obsidian-vault';
const HIDDEN = ['.git', '.gitignore', '.DS_Store', 'Thumbs.db'];
const MIME_TYPES = {
'.md': 'text/markdown; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
function readDir(dir, basePath) {
return fs.readdirSync(dir, { withFileTypes: true })
.filter(item => !HIDDEN.includes(item.name))
.map(item => ({
name: item.name,
path: path.relative(basePath, path.join(dir, item.name)),
isDir: item.isDirectory(),
ext: path.extname(item.name),
}))
.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
function renderFolder(dir, relPath, basePath) {
const items = readDir(dir, basePath);
const parentPath = path.dirname(relPath);
let html = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a2e;color:#eee;min-height:100vh}
header{background:#16213e;padding:1rem 2rem;border-bottom:1px solid #0f3460}
header h1{color:#e94560;font-size:1.2rem}
header a{color:#eee;text-decoration:none;margin-right:1rem}
.container{max-width:900px;margin:0 auto;padding:2rem}
.breadcrumb{color:#888;margin-bottom:1rem;font-size:0.9rem}
.breadcrumb a{color:#e94560;text-decoration:none}
.breadcrumb span{margin:0 0.3rem}
.file-list{list-style:none}
.file-list li{margin:0.3rem 0}
.file-list a{display:flex;align-items:center;padding:0.5rem 1rem;background:#16213e;border-radius:6px;text-decoration:none;color:#eee;transition:background 0.2s}
.file-list a:hover{background:#0f3460}
.file-list .icon{margin-right:0.5rem;color:#e94560}
📚 Obsidian Vault
- ;
if (relPath) {
html += ;
}
for (const item of items) {
const icon = item.isDir ? '📁' : '📄';
html += ;
}
html +=
return html;
}
function renderMarkdown(htmlContent, filePath) {
return `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a2e;color:#eee;line-height:1.7;min-height:100vh}
header{background:#16213e;padding:1rem 2rem;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
header h1{color:#e94560;font-size:1.2rem}
header a{color:#eee;text-decoration:none;margin-right:1rem}
.container{max-width:800px;margin:0 auto;padding:2rem}
pre{background:#0f3460;padding:1rem;border-radius:6px;overflow-x:auto}
code{font-family:'Fira Code','Consolas',monospace;font-size:0.9rem}
:not(pre) > code{background:#0f3460;padding:0.1rem 0.4rem;border-radius:4px;color:#e94560}
a{color:#e94560;text-decoration:none}
a:hover{text-decoration:underline}
h1,h2,h3,h4{color:#fff;margin:1.5rem 0 0.5rem}
h1{color:#e94560}
ul,ol{margin:0.5rem 0 0.5rem 1.5rem}
li{margin:0.3rem 0}
blockquote{border-left:3px solid #e94560;padding-left:1rem;margin:1rem 0;color:#aaa}
table{border-collapse:collapse;width:100%;margin:1rem 0}
th,td{border:1px solid #0f3460;padding:0.5rem;text-align:left}
th{background:#0f3460}
tr:nth-child(even){background:#16213e}
hr{border:none;border-top:1px solid #0f3460;margin:2rem 0}
img{max-width:100%;height:auto;border-radius:6px}
📄 ${path.basename(filePath)}
`;
}
function markdownToHtml(content) {
let html = content
.replace(/^### (.+)$/gm, '
$1
').replace(/^## (.+)$/gm, '
$1
').replace(/^# (.+)$/gm, '
$1
').replace(/\\(.+?)\\/g, '$1')
.replace(/\(.+?)\/g, '$1')
.replace(/(.+?)/g, '$1')
.replace(/\[(.+?)\]\((.+?)\)/g, '$1')
.replace(/^(- .+)$/gm, '
.replace(/(