Building My Publishing Pipeline: Obsidian → Hugo → GitHub → Hostinger
2025-04-205 min read
Obsidian → Hugo → GitHub → Hostinger Automation Workflow
Writing blog posts is one thing — getting them published consistently is another. In this post, I walk through the automated publishing pipeline I built to take Markdown posts from Obsidian, render them with Hugo, and deploy them to Hostinger via GitHub webhook, all triggered by a single Python script.
Tools Used
- Obsidian – Markdown note-taking and blog writing
- Hugo – Static site generator using the PaperMod theme
- GitHub – Repository with two branches:
master→ Hugo project & contenthostinger→ Static site output
- Hostinger – Web hosting platform with webhook integration
- Python – Automation script for content syncing and site deployment My Automation Script
Folder Structure
hljs plaintext
Local_Obsidian_Vault/
└── my-home-lab-journey/
├── post-1/
└── index.md
├── post-2/
└── index.md
├── post-3/
└── index.md
└── ...
tillynetblog/
├── hugo.yaml
├── content/
│ └── my-home-lab-journey/
├── public/
├── themes/
│ └── PaperMod/
└── static/
└── images
└── css/custom.css
What the Automation Script Does
Imports
hljs python
import os
import shutil
import subprocess
import re
- os: Filesystem operations like path handling
- shutil: Copying and removing files and directories
- subprocess: Running shell commands like
hugoandgit - re: Finding image links in Markdown via regular expressions
Configuration
hljs python
obsidian_dir = r"C:\Users\micha\Documents\Local_Obsidian_Vault\my-home-lab-journey"
hugo_root_dir = r"C:\Users\micha\Documents\tillynetblog"
hugo_content_dir = os.path.join(hugo_root_dir, "content", "my-home-lab-journey")
attachments_dir = r"C:\Users\micha\Documents\Local_Obsidian_Vault\assets\images"
static_images_dir = os.path.join(hugo_root_dir, "static", "images")
about_src = r"C:\Users\micha\Documents\Local_Obsidian_Vault\pages\about.md"
about_dst_dir = os.path.join(hugo_root_dir, "content", "about")
about_dst = os.path.join(about_dst_dir, "index.md")
base_url = "https://blog.tillynet.com"
- Sets paths to:
- Obsidian content (
obsidian_dir) - Hugo blog folder
- Markdown post destination
- Obsidian image source and Hugo image destination
- Source and destination for the
about.mdfile - The blog's base URL for Hugo
- Obsidian content (
STEP 1: Copy Markdown Posts
hljs python
if os.path.exists(hugo_content_dir):
shutil.rmtree(hugo_content_dir)
shutil.copytree(obsidian_dir, hugo_content_dir)
print("✔ Copied markdown posts from Obsidian.")
- Deletes existing Markdown content in Hugo
- Copies fresh Markdown files from Obsidian
- Confirms the copy
STEP 2: Convert Image Embeds and Copy Images
hljs python
for subdir, _, files in os.walk(hugo_content_dir):
for filename in files:
if filename.endswith(".md"):
...
- Iterates through every Markdown file in the blog content
hljs python
images = re.findall(r'\[\[([^]]*\.png)\]\]', content)
- Finds Obsidian-style image links like

hljs python
for image in images:
markdown_image = f"})"
- Replaces them with standard Markdown image syntax
hljs python
src_image = os.path.join(attachments_dir, image)
if os.path.exists(src_image):
shutil.copy(src_image, static_images_dir)
- Copies matching images from Obsidian to Hugo's static image folder
hljs python
with open(md_path, "w", encoding="utf-8") as file:
file.write(content)
- Overwrites the file with the updated content
hljs python
print("✔ Processed images and updated markdown links.")
- Confirms processing is complete
STEP 3: Copy About Page
hljs python
if os.path.exists(about_src):
os.makedirs(about_dst_dir, exist_ok=True)
shutil.copyfile(about_src, about_dst)
print("✔ Updated About page as /about/index.md.")
else:
print("⚠ About page not found in Obsidian vault; skipping.")
- Copies
about.mdfrom Obsidian to Hugo content - Skips and warns if the file is missing
STEP 4: Clean Existing public/ Folder
hljs python
public_dir = os.path.join(hugo_root_dir, "public")
if os.path.exists(public_dir):
shutil.rmtree(public_dir)
print("✔ Cleaned existing public/ folder.")
- Removes the old
public/folder - Forces Hugo to rebuild the entire static site from scratch, picking up all new pages and changes
STEP 5: Build the Hugo Site
hljs python
subprocess.run(["hugo", "--buildDrafts", "--buildFuture", "-b", base_url], cwd=hugo_root_dir, check=True)
print("✔ Hugo site built with baseURL.")
- Builds the site using the
hugocommand with my specifiedbaseURL
STEP 6: Push Source Files to GitHub master
hljs python
subprocess.run(["git", "checkout", "master"], cwd=hugo_root_dir, check=True)
subprocess.run(["git", "add", "."], cwd=hugo_root_dir, check=True)
subprocess.run(["git", "commit", "-m", "Update blog content"], cwd=hugo_root_dir, check=False)
subprocess.run(["git", "push", "origin", "master"], cwd=hugo_root_dir, check=True)
print("✔ Pushed changes to GitHub master.")
- Commits and pushes all changes to the
masterbranch
STEP 7: Deploy Public Folder to hostinger Branch
hljs python
subprocess.run(["git", "subtree", "split", "--prefix", "public", "-b", "hostinger-deploy"], cwd=hugo_root_dir, check=True)
subprocess.run(["git", "push", "origin", "hostinger-deploy:hostinger", "--force"], cwd=hugo_root_dir, check=True)
subprocess.run(["git", "branch", "-D", "hostinger-deploy"], cwd=hugo_root_dir, check=True)
print("✔ Deployed public/ folder to GitHub hostinger branch.")
- Splits
public/into a temporary branch - Force-pushes it to the
hostingerbranch on GitHub (used for site deployment) - Deletes the temporary
hostinger-deploybranch- This is necessary for the webhook to recognize the changes
Issues I Encountered
CSS Not Applying on Deployed Site
-
Root Cause: Hostinger was caching fingerprinted CSS files.
-
Fix:
-
Disabled Hugo asset fingerprinting in
hugo.yaml:hljs yamlassets: disableFingerprinting: true -
Verified correct path:
assets/css/custom.css
-
Lessons Learned
- Control your asset paths. Static site deployment requires full control over content paths and assets — one wrong reference breaks everything.
- Watch out for fingerprinting. Hugo's asset fingerprinting can break styling when your host caches aggressively. Disable it if needed.
- Convert Obsidian embeds. Obsidian's
[[embed]]syntax doesn't work in Hugo — the script handles this conversion automatically. - Use git subtree for clean deploys. Pushing only the
public/folder to the deployment branch keeps things isolated and predictable. - Always nuke
public/before building. Deleting the oldpublic/folder forces Hugo to rebuild from scratch, ensuring new pages and removed content are reflected correctly.
Final Outcome
- End-to-end deployment is fully automated
- Blog posts are written in Obsidian subfolders
- A single Python script deploys everything to GitHub and Hostinger
- Duplicate post issues and CSS bugs resolved
- Dark/light theme and custom styles work across devices