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 & content
    • hostinger → 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 hugo and git
  • 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.md file
    • The blog's base URL for Hugo

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 ![example.png](/images/example.png)
hljs python
            for image in images:
                markdown_image = f"![Image](/images/{image.replace(' ', '%20')})"
  • 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.md from 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 hugo command with my specified baseURL

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 master branch

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 hostinger branch on GitHub (used for site deployment)
  • Deletes the temporary hostinger-deploy branch
    • 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 yaml
      assets:
        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 old public/ 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