The Setup
Create a new folder labeled posts. This is where you will add your blog posts, that’s it!

Syncing Obsidian to Hugo
Add some Frontmatter
When you are writing new posts, it is important to add the Frontmatter to the top of each of your Obsidian notes. This contains all the metadata that accompanies the post so it up across the top of your new post.
---
title: blogtitle
date: 2024-11-06
draft: false
tags:
- tag1
- tag2
---
Example of where that Frontmatter is displayed:

Python Script to Sync to Hugo
One-time setup
touch /path/to/git/repo/scripts/sync.config.json
Then, within your git repo, edit scripts/sync.config.json with your vault’s paths:
{
"posts_dir": "/path/to/obsidian/vault/posts",
"attachments_dir": "/path/to/obsidian/vault/attachments"
}
sync.config.json should be gitignored since the vault path is machine-specific. content/posts and static/images are derived automatically from your git repo’s location and don’t need to be set — only add content_posts_dir / static_images_dir keys if you’re ever running the script against a different checkout.
sync_obsidian.py
Reads Markdown from the vault’s posts folder and writes a transformed copy into content/posts: Obsidian’s ![[image.png]] embeds become Hugo-friendly  links, and referenced images are copied into static/images. The vault is read-only source material here — nothing under posts_dir/attachments_dir is ever modified.
It also auto-discovers a per-post cover image: if an attachment’s filename slugifies to match a post’s filename (e.g. my-post.png for my-post.md), it’s copied into static/images under that slug — matching the theme’s own auto-discovery in themes/mana/layouts/partials/post-image-resolve.html.
python3 scripts/sync_obsidian.py # dry-run: lists stale posts/images, deletes nothing
python3 scripts/sync_obsidian.py --delete # also deletes stale posts/images
python3 scripts/sync_obsidian.py --config path/to/other.json
“Stale” means a file in content/posts or static/images with no corresponding file left in the vault.
#!/usr/bin/env python3
"""
Sync Obsidian vault posts + attachments into this Hugo site.
The vault is treated as read-only source material; nothing under posts_dir
or attachments_dir is ever modified. Everything Hugo needs is generated into
this repo:
- Reads Markdown from an Obsidian "posts" folder and writes a transformed
copy into content/posts, converting Obsidian's  embeds into
Hugo-friendly  links. Front matter
and everything else in the post is passed through unchanged.
- Copies referenced attachment images into static/images.
- Auto-discovers a per-post cover image in the attachments folder whose name
slugifies to the post's filename (matches the theme's own auto-discovery
in themes/mana/layouts/partials/post-image-resolve.html, which keys off
the post's filename, not its front matter title).
- Removes files in content/posts and images in static/images that no longer
correspond to anything in the vault (dry-run unless --delete is passed).
Configuration lives in a small local JSON file (not committed) so the
machine-specific Obsidian vault path never ends up in git history. See
scripts/sync.config.example.json for the shape. Only the standard library
is used, no third-party dependencies.
Usage:
python3 scripts/sync_obsidian.py # dry-run on deletions
python3 scripts/sync_obsidian.py --delete # also remove stale posts/images
python3 scripts/sync_obsidian.py --config path/to/other.json
"""
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
from urllib.parse import unquote
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".bmp")
# Matches the theme's own auto-discovery: only these extensions are ever
# looked up for a cover image (themes/mana/layouts/partials/post-image-resolve.html).
COVER_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
WIKILINK_EMBED_RE = re.compile(
r"!\[\[([^|\]]+\.(?:" + "|".join(ext.lstrip(".") for ext in IMAGE_EXTENSIONS) + r"))(?:\|[^\]]*)?\]\]",
re.IGNORECASE,
)
PROCESSED_IMAGE_RE = re.compile(r"!\[[^\]]*\]\(/images/([^)]+)\)")
def slugify(text: str) -> str:
"""Approximate Hugo's `urlize` for matching attachment names to a post slug."""
text = text.lower()
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"[-\s]+", "-", text)
return text.strip("-")
# scripts/sync_obsidian.py -> repo root -> static/images, content/posts
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_STATIC_IMAGES_DIR = REPO_ROOT / "static" / "images"
DEFAULT_CONTENT_POSTS_DIR = REPO_ROOT / "content" / "posts"
def load_config(config_path: Path) -> dict:
if not config_path.exists():
example = config_path.with_name(config_path.name.replace(".json", ".example.json"))
hint = f" Copy {example.name} to {config_path.name} and fill in your paths." if example.exists() else ""
sys.exit(f"Config file not found: {config_path}.{hint}")
with config_path.open("r", encoding="utf-8") as f:
config = json.load(f)
required = ["posts_dir", "attachments_dir"]
missing = [key for key in required if not config.get(key)]
if missing:
sys.exit(f"Config file {config_path} is missing required keys: {', '.join(missing)}")
result = {key: Path(config[key]).expanduser() for key in required}
# These two always live in this repo; only let the config override them
# if someone genuinely needs to (e.g. running against a different checkout).
result["static_images_dir"] = (
Path(config["static_images_dir"]).expanduser() if config.get("static_images_dir") else DEFAULT_STATIC_IMAGES_DIR
)
result["content_posts_dir"] = (
Path(config["content_posts_dir"]).expanduser() if config.get("content_posts_dir") else DEFAULT_CONTENT_POSTS_DIR
)
return result
def find_cover_image(post_slug: str, attachments_dir: Path) -> Path | None:
"""Find an attachment whose name slugifies to post_slug."""
if not attachments_dir.is_dir():
return None
for ext in COVER_IMAGE_EXTENSIONS:
candidate = attachments_dir / f"{post_slug}{ext}"
if candidate.is_file():
return candidate
for candidate in attachments_dir.iterdir():
if candidate.is_file() and candidate.suffix.lower() in COVER_IMAGE_EXTENSIONS:
if slugify(candidate.stem) == post_slug:
return candidate
return None
def process_post(source_path: Path, dest_path: Path, attachments_dir: Path, static_images_dir: Path, referenced_images: set) -> bool:
"""Read one vault post, transform its image links, and write the result to dest_path.
The vault source file is never modified. Returns True if dest_path was created/changed."""
source_content = source_path.read_text(encoding="utf-8")
post_slug = slugify(source_path.stem)
cover_source = find_cover_image(post_slug, attachments_dir)
if cover_source:
dest_name = f"{post_slug}{cover_source.suffix.lower()}"
shutil.copy(cover_source, static_images_dir / dest_name)
referenced_images.add(dest_name)
print(f" cover image: {cover_source.name} -> {dest_name}")
for match in WIKILINK_EMBED_RE.finditer(source_content):
referenced_images.add(match.group(1).strip())
for processed_name in PROCESSED_IMAGE_RE.findall(source_content):
referenced_images.add(unquote(processed_name))
def replace_embed(match: re.Match) -> str:
image_name = match.group(1).strip()
url_name = image_name.replace(" ", "%20")
image_source = attachments_dir / image_name
if image_source.is_file():
shutil.copy(image_source, static_images_dir / image_name)
else:
print(f" WARNING: attachment not found for embed: {image_name}")
return f""
new_content = WIKILINK_EMBED_RE.sub(replace_embed, source_content)
existing_content = dest_path.read_text(encoding="utf-8") if dest_path.exists() else None
if new_content != existing_content:
dest_path.parent.mkdir(parents=True, exist_ok=True)
dest_path.write_text(new_content, encoding="utf-8")
return True
return False
def sync(
posts_dir: Path,
attachments_dir: Path,
static_images_dir: Path,
content_posts_dir: Path,
delete_unused: bool,
) -> None:
static_images_dir.mkdir(parents=True, exist_ok=True)
content_posts_dir.mkdir(parents=True, exist_ok=True)
if not posts_dir.is_dir():
sys.exit(f"posts_dir does not exist: {posts_dir}")
referenced_images: set[str] = set()
expected_dest_files: set[Path] = set()
changed_count = 0
for source_path in sorted(posts_dir.rglob("*.md")):
relative_path = source_path.relative_to(posts_dir)
dest_path = content_posts_dir / relative_path
expected_dest_files.add(dest_path)
print(f"{relative_path}")
if process_post(source_path, dest_path, attachments_dir, static_images_dir, referenced_images):
changed_count += 1
existing_dest_files = {p for p in content_posts_dir.rglob("*.md")}
stale_posts = existing_dest_files - expected_dest_files
if stale_posts:
action = "Removing stale post" if delete_unused else "Would remove stale post (dry-run, pass --delete to actually remove)"
for stale_path in sorted(stale_posts):
print(f"{action}: {stale_path.relative_to(content_posts_dir)}")
if delete_unused:
try:
stale_path.unlink()
except OSError as e:
print(f" ERROR removing {stale_path}: {e}")
static_images = {p.name for p in static_images_dir.iterdir() if p.is_file()}
unused_images = static_images - referenced_images
if unused_images:
action = "Removing unused image" if delete_unused else "Would remove unused image (dry-run, pass --delete to actually remove)"
for name in sorted(unused_images):
print(f"{action}: {name}")
if delete_unused:
try:
(static_images_dir / name).unlink()
except OSError as e:
print(f" ERROR removing {name}: {e}")
print(
f"\nDone. {changed_count} post(s) written, {len(stale_posts)} stale post(s) "
f"{'removed' if delete_unused else 'flagged'}, {len(unused_images)} unused image(s) "
f"{'removed' if delete_unused else 'flagged'}."
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--config",
type=Path,
default=Path(__file__).with_name("sync.config.json"),
help="Path to config JSON (default: sync.config.json next to this script)",
)
parser.add_argument(
"--delete",
action="store_true",
help="Actually remove stale posts/images (default: dry-run, just lists them)",
)
args = parser.parse_args()
config = load_config(args.config)
sync(
config["posts_dir"],
config["attachments_dir"],
config["static_images_dir"],
config["content_posts_dir"],
args.delete,
)
if __name__ == "__main__":
main()
publish.py
Wraps sync_obsidian.py and owns the git side: stages only content/posts and static/images (never git add ., so unrelated in-progress changes elsewhere in the working tree are left alone), commits with a message built from the changed post names, and pushes to origin main.
python3 scripts/publish.py # sync (dry-run cleanup) + commit + push
python3 scripts/publish.py --delete # also remove stale posts/images
python3 scripts/publish.py --no-push # sync + commit only, skip the push
There’s no local Hugo build or separate deploy branch — pushing to main is the deploy step; the self-hosted webhook (see Deployment) rebuilds the live site from there once it’s set up.
Typical workflow: write a post in Obsidian, flip draft: false when it’s ready, run python3 scripts/publish.py.
#!/usr/bin/env python3
"""
Sync Obsidian posts/images into this repo, then commit and push to main.
Thin wrapper around sync_obsidian.py: this script owns the git side (stage,
commit, push), sync_obsidian.py owns turning vault content into content/posts
and static/images. No Hugo build and no separate deploy branch here — this
repo's deploy plan (see CLAUDE.md) is a self-hosted webhook that rebuilds the
site server-side on push to main, so a push to main is the entire deploy step.
Only stages content/posts and static/images, never `git add .`, so unrelated
in-progress changes elsewhere in the working tree are left alone.
Usage:
python3 scripts/publish.py # sync (dry-run cleanup), commit, push
python3 scripts/publish.py --delete # also remove stale posts/images
python3 scripts/publish.py --no-push # sync + commit only, skip push
python3 scripts/publish.py --config path/to/other.json
"""
import argparse
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sync_obsidian import load_config, sync # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
def run_git(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True)
def build_commit_message(content_posts_dir: Path) -> str:
staged = run_git("diff", "--cached", "--name-only").stdout.splitlines()
posts_prefix = content_posts_dir.relative_to(REPO_ROOT).as_posix()
post_stems = sorted({Path(f).stem for f in staged if f.startswith(posts_prefix) and f.endswith(".md")})
if post_stems and len(post_stems) <= 3:
return "Sync posts: " + ", ".join(post_stems)
if post_stems:
return f"Sync {len(post_stems)} posts"
return f"Sync images ({datetime.now():%Y-%m-%d %H:%M:%S})"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--config",
type=Path,
default=Path(__file__).with_name("sync.config.json"),
help="Path to config JSON (default: sync.config.json next to this script)",
)
parser.add_argument(
"--delete",
action="store_true",
help="Actually remove stale posts/images during sync (default: dry-run, just lists them)",
)
parser.add_argument(
"--no-push",
action="store_true",
help="Sync and commit but skip 'git push origin main'",
)
args = parser.parse_args()
if shutil.which("git") is None:
sys.exit("git is not installed or not on PATH.")
config = load_config(args.config)
print("=== Syncing from Obsidian vault ===")
sync(
config["posts_dir"],
config["attachments_dir"],
config["static_images_dir"],
config["content_posts_dir"],
args.delete,
)
print("\n=== Staging changes ===")
posts_rel = config["content_posts_dir"].relative_to(REPO_ROOT).as_posix()
images_rel = config["static_images_dir"].relative_to(REPO_ROOT).as_posix()
run_git("add", posts_rel, images_rel)
staged = run_git("diff", "--cached", "--name-only").stdout.strip()
if not staged:
print("No changes to publish.")
return
print(staged)
message = build_commit_message(config["content_posts_dir"])
print(f"\n=== Committing: {message} ===")
run_git("commit", "-m", message)
if args.no_push:
print("\n--no-push passed, skipping push. Run 'git push origin main' when ready.")
return
print("\n=== Pushing to origin main ===")
push = subprocess.run(["git", "push", "origin", "main"], cwd=REPO_ROOT)
if push.returncode != 0:
sys.exit("git push failed, see output above.")
print("\nDone. Pushed to main; the webhook (once configured) will rebuild the live site.")
if __name__ == "__main__":
main()
Docker
In my docker environment, I am using Traefik v3 for my reverse proxy which also handles all my SSL certificate renewals automatically via Lets Encrypt. So in my environment I only need a docker container that participates in my Traefik network and runs on http (port 80) since Traefik handles all the https (port 443) for me. Due to the nature of this Obsidian -> HUGO -> Git repo environment, my Docker container can be entirely ephemeral and rebuilt every time without worry of any data loss.
Dockerfile
This is my dockerfile used to help build my Docker container, this is specifically configured to pull in the Livour/mana Hugo theme repo so it can be built on the fly each time. This is because Dockhand does not sync all sub-repos during the webhook re-deploy events.
FROM ghcr.io/gohugoio/hugo:v0.164.0 AS build
# Pinned to the commit currently recorded for the themes/mana submodule
# (`git submodule status`). Dockhand's sync fetches repo content without
# .git metadata, so the build can't read the submodule's pinned commit
# from git itself and instead clones the theme directly at this SHA.
# Bump this whenever themes/mana is upgraded (see README "Upgrading > Theme").
ARG MANA_THEME_COMMIT=9cfc6549d8429789853197fdac264a5df232f7c3
WORKDIR /src
COPY --chown=hugo:hugo . .
RUN rm -rf themes/mana && \
git clone https://github.com/Livour/hugo-mana-theme.git themes/mana && \
git -C themes/mana checkout ${MANA_THEME_COMMIT}
RUN hugo --minify
FROM nginx:1.27-alpine AS final
COPY --from=build /src/public /usr/share/nginx/html
EXPOSE 80
Docker Compose
This is the the compose file that I ended up using for my environment. I use Docker labels to manage my Traefik, so you’ll see those listed below:
services:
website:
build:
context: .
dockerfile: Dockerfile
image: my-blog-site:latest
container_name: my-blog-site
restart: unless-stopped
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.my-blog.rule=Host(`blog.example.com`)"
- "traefik.http.routers.my-blog.entrypoints=websecure"
- "traefik.http.routers.my-blog.tls.certresolver=letsencrypt"
- "traefik.http.services.my-blog.loadbalancer.server.port=80"
networks:
traefik:
external: true
name: traefik
Deployment
For me, I chose to host my site via Docker container on my server at home. I like to use Dockhand to manage my Docker environment. One of the reasons I like Dockhand is that it supports syncing Docker Compose stacks from Git repos and it also has webhook support. This means that I can push a new commit to my blog repo and Github will fire a webhook event off to my Dockhand instance which is authenticated with a security token and then it will re-build and re-deploy my blog automatically. Effectively with the single action of running the publish.py script above, my blog is immediately updated without any further interaction!
Setup
Adding your Git Repo
To set this up, I first logged into my Dockhand panel and then went to Settings > Git and then added my blog repo:

If your git repo is private, you will need to add a Credential under Settings > Git > Credentials before adding your repo. I setup a Personal Access Token (PAT) for mine and selected it from the Credential drop-down as shown above.
Setup your Compose stack
Now you will need to go to Stacks and then click on From Git across the top menu. Then after doing that you will need to do the following steps:
- Select your newly added Repository
- Enter the name of your compose.yaml file within your repo (including path if it is nested)
- Enable Webhook setting
- Generate secret and copy it so you can paste it into your Git repo’s webhook configuration
- Enable Build images on deploy so your docker image gets rebuilt (This may vary depending on how you setup your docker environment)

Now you can click on Create and then once it’s added you will need to click on the Pencil icon to the right of the Stack to edit it. Now you should be able to see your Webhook settings: URL, Secret. You will need to copy both of these down for the next step.

Setup your Git Webhook events
Now you will need to go into your Git repo’s Settings > Webhooks and add a new webhook. Then you will need to paste in the following items from the previous step:
- Webhook URL -> Payload URL
- Webhook secret -> Secret

Click on Add webhook to save and send a test event.
You should now be good to go and your website should be auto-updating with every run of the publish.py script!
