Obsidian to Blog
my Obsidian vault is my second brain - it has everything from work notes to random ideas to blog posts and I always wanted the blog posts to just… be the blog not copy-paste into some CMS, not export to Hugo, not maintain two copies of the same thing just write in Obsidian, push to git, and it’s live
so that’s exactly what I built
the idea
the repo is both an Obsidian vault and an Astro project at the same time
the root of the repo is the vault - .obsidian/ folder and all
and inside there’s an astro/ directory that knows how to build the vault into a static site
the key insight is in the content config:
const content = defineCollection({
loader: glob({
pattern: [
"**/*.md",
"!node_modules/**",
"!astro/**",
"!dist/**",
"!**/.*/**",
],
base: "..",
}),
});
Astro’s glob loader just scans the parent directory (the vault root) for all markdown files
it ignores node_modules, the astro folder itself, dist, and dotfiles
everything else is fair game
obsidian flavored markdown
the one tricky part is that Obsidian has its own markdown flavor two things needed fixing - image embeds and comments
Obsidian does images like ![[photo.png]] instead of standard 
and it has comment blocks with %% hidden stuff %%
so I wrote two tiny remark plugins that handle both:
// Converts ![[file.png]] to standard markdown images
export const remarkObsidianImages: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, "paragraph", (node) => {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (child.type !== "text") continue;
const match = child.value.match(/!\[\[([^\]]+)\]\]/);
if (!match) continue;
node.children[i] = { type: "image", url: `/${match[1]}`, alt: match[1] };
}
});
};
};
// Strips %% comment %% blocks
export const remarkObsidianComments: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, "text", (node) => {
node.value = node.value.replace(/%%[^]*?%%/g, "");
});
};
};
images that live in the vault root get copied to public/ during the build so Astro can serve them
public vs private
not everything in my vault should be public obviously in CI only specific sections get built:
const ci = !!process.env.CI;
const PUBLIC_SECTIONS = ["blog", "talks"];
const all = (await getCollection("content"))
.filter((entry) => {
if (!ci) return true;
const section = entry.id.split("/")[0]?.toLowerCase();
return PUBLIC_SECTIONS.includes(section);
});
locally I see everything - all my notes, projects, thoughts
but when it builds on GitHub Actions only Blog/ and talks/ make it to production
so I can keep private notes in the same vault without worrying about leaking them
the deploy
push to master and GitHub Actions does the rest:
- bun install
- bun run build (copies images, runs Astro build)
- deploys to Cloudflare Pages via wrangler
the whole build is like 10 seconds
why this works
the beauty of this setup is there’s almost nothing to maintain no static site generators with their own content directories and config files no syncing between an editor and a build system the vault IS the source of truth
I write a new markdown file in Obsidian, add a frontmatter with title and date, commit and push that’s it, it’s a blog post I don’t even need to leave Obsidian - the obsidian-git plugin lets me commit and push right from the editor
and because it’s just markdown files in a git repo, Claude Code can write and edit posts too which is exactly how this blog post was written