Complete Guide to Building a Hugo Blog Pipeline

Brad | Jan 13, 2025 min read

Image

This is a notes page for how this blog was created.

Prepare Obsidian

  1. Download and Install Obsidian
  2. Create a ‘posts’ folder in Obsidian root
  3. Create an ‘images’ directory in Obsidian root
  4. Open Obsidian Settings to Tell Obsidian to use the new ‘images’ directory to store and reference images.

Add Some Frontmatter to a Page

This allows the template to display meta data about your blog post. It should be placed at the top of your Obsidian note. To add this ‘Frontmatter’ meta data, put the Obsidian note page into ‘Source mode’ by using the 3 dots menu at the top right of the page.

---
title: blogtitle
date: 2024-11-06
draft: false
summary: "Short summary of the post"
tags:
  - tag1
  - tag2
---

Install and Configure Hugo and GitHub Repo

Install Hugo

sudo apt install hugo

Verify Hugo works

hugo version

Create a new Hugo site

hugo new site websitename
cd websitename

Install and Configure a GitHub Repo

Create a new repository for your project

git init

Add all files in website directory to git

git add .

Commit the files

git commit -m "Initial commit"

Clone the Profile Theme as a Git Submodule

git submodule add -f [email protected]:protomota/hugo-profile.git themes/hugo-profile

Add a ‘config.yaml’ file at the root of the hugo site

baseURL: "https://yoururl.com"
languageCode: "en-us"
title: "My Profile"
theme: hugo-profile

outputs:
  home:
    - "HTML"
    - "RSS"
    - "JSON"
  page:
    - "HTML"
    - "RSS"

pagination:
  pagerSize: 3
enableRobotsTXT: true
# disqusShortname: your-disqus-shortname
# googleAnalytics: G-MEASUREMENT_ID

markup:
  goldmark:
    renderer:
      unsafe: true

Menus:
  main:
    - identifier: blog
      name: Blog
      title: Blog posts
      url: /blogs
      weight: 1
    # - identifier: gallery
    #   name: Gallery
    #   title: Blog posts
    #   url: /gallery
    #   weight: 2
    #Dropdown menu
    # - identifier: dropdown
    #   title: Example dropdown menu
    #   name: Dropdown
    #   weight: 3
    # - identifier: dropdown1
    #   title: example dropdown 1
    #   name: example 1
    #   url: /#
    #   parent: dropdown
    #   weight: 1
    # - identifier: dropdown2
    #   title: example dropdown 2
    #   name: example 2
    #   url: /#
    #   parent: dropdown
    #   weight: 2

params:
  title: "My Profile"
  description: Text about my cool site
  # staticPath: ""  # The path to serve the static files from
  favicon: "/fav.png"

  # Whether to serve bootstrap css and js files from CDN or not. Can be set to true, "css" or "js" to choose between
  # serving both, only the css, or only the js files through the CDN. Any other value will make so that CDN is not used.
  # Note the lack of "" in true, it should be of boolean type.
  useBootstrapCDN: false

  # If you want to load dynamically responsive images from Cloudinary
  # This requires your images to be uploaded + hosted on Cloudinary
  # Uncomment and change YOUR_CLOUD_NAME to the Cloud Name in your Cloudinary console
  # cloudinary_cloud_name: "YOUR_CLOUD_NAME"

  # Whether to add mathjax support on all pages. Alternatively, you can opt-in per page by adding `mathjax: true` in the frontmatter.
  mathjax: false

  # Whether the fade animations on the home page will be enabled
  animate: false

  theme:
    # disableThemeToggle: true
    # defaultTheme: "light" # dark

  font:
    fontSize: 1rem # default: 1rem
    fontWeight: 400 # default: 400
    lineHeight: 1.5 # default: 1.5
    textAlign: left # default: left

  # color preference
  # When using hex codes for colors, quotations must be used along with the # sign
  # color:
  #   textColor: "#343a40"
  #   secondaryTextColor: "#6c757d"
  #   textLinkColor: "#007bff"
  #   backgroundColor: "#eaedf0"
  #   secondaryBackgroundColor: "#64ffda1a"
  #   primaryColor: "#007bff"
  #   secondaryColor: "#f8f9fa"

  #   darkmode:
  #     textColor: "#e4e6eb"
  #     secondaryTextColor: "#b0b3b8"
  #     textLinkColor: "#ffffff"
  #     backgroundColor: "#18191a"
  #     secondaryBackgroundColor: "#212529"
  #     primaryColor: "#ffffff"
  #     secondaryColor: "#212529"

  # If you want to customize the menu, you can change it here
  navbar:
    align: ms-auto # Left: ms-auto | center: mx-auto | right: me-auto | Default: ms-auto
    # brandLogo: "/logo.png" # Logo for the brand | default is the favicon variable
    # showBrandLogo: false # Show brand logo in nav bar | default is true
    brandName: "Brad Dunlap" # Brand name for the brand | default is the title variable
    disableSearch: false
    # searchPlaceholder: "Search"
    stickyNavBar:

      enable : true
      showOnScrollUp : true
    enableSeparator: false
    menus:
      disableAbout: false
      disableExperience: false
      disableEducation: false
      disableCertifications: false
      disableProjects: false
      disableAchievements: false
      disableContact: false

  # Hero
  hero:
    enable: true
    intro: ""
    title: ""
    subtitle: "Innovator. Leader. Engineer. Designer. Entrepreneur."
    content: "Driven by a desire to learn, create, and lead, I thrive on turning ideas into reality and constantly seeking new ways to solve problems. This passion extends into my free time, where I enjoy tinkering and developing fun side projects. Through both individual contribution and effective leadership, I adapt daily and work towards delivering innovative solutions that make a tangible, positive difference."
    image: /images/me.jpeg
    bottomImage:
      enable: false

    # roundImage: true # Make hero image circular | default false
    button:
      enable: true
      name: "Resume"
      url: "#"
      download: true
      newPage: false
    socialLinks:
      fontAwesomeIcons:
        - icon: fab fa-github
          url: https://github.com/protomota
        - icon: fab fa-linkedin
          url: https://www.linkedin.com/in/bradley-dunlap/
      customIcons:
        - icon: /fav.png
          url: https://protomota.com


  # About
  about:
    enable: true
    title: "About"
    # image: "/images/me.png"
    content: |-
      A dedicated artisan with over two decades of experience in the niche field of underwater basket weaving, blending tradition with innovation in one of the world’s most unusual crafts. She has mastered every phase of the weaving process, from aquatic material sourcing to submerged design execution. Known for her ability to lead collaborative installations, she has brought intricate, water-formed creations to life for museums, resorts, cultural festivals, and avant-garde art showcases across the globe.
    skills:
      enable: true
      title: "Summary of Skills:"
      items:
        - "Skill 1"
        - "Skill 2"
        - "Skill 3"

  # Career Highlights
  career_highlights:
    enable: true
    title: "Career Highlights"
    items:
      - "Career Highlights 1"
      - "Career Highlights 2"
      - "Career Highlights 3"
  # Experience
  experience:
    enable: true
    # title: "Custom Name"
    items:
      - company: "Company 1"
        companyUrl: "https://companyurl.com/"
        positions:
          - job: "Position 1"
            date: "Jan 2023 – Present"
            content: |
              - Accomplishment 1
              - Accomplishment 2
              - Accomplishment 3

      - company: "Company 2"
        companyUrl: "https://companyurl.com/"
        positions:
          - job: "Position 1"
            date: "November 2021 - November 2023"
            content: |
              - Accomplishment 1
              - Accomplishment 2
              - Accomplishment 3

          - job: "Position 2"
            date: "November 2021 - November 2023"
            content: |
              - Accomplishment 1
              - Accomplishment 2
              - Accomplishment 3

  # Education
  education:
    enable: true
    # title: "Custom Name"
    index: false
    items:
      - title: "Bachelor of Underwater Basket Weaving"
        school:
          name: "Musing University"
          # url: "https://www.school.edu/"
        date: "1993 - 1998"
        content: |-
          - Accomplishment 1
		  - Accomplishment 2
		  - Accomplishment 3

  # Certifications
  certifications:
    enable: true
    # title: "Custom Name"
    items:
      - title: "Cert Name"
        content: I completed the cert.
        url: https://www.certurl.com
        image: /images/certimage.png

  # Achievements
  achievements:
    enable: true
    # title: "Custom Name"
    items:
      - title: "Achievement 1"
        content: I did an achievement
        url: https://achievmenturl.com
        image: /images/achievment-image.png
      - title: "Achievement 2"
        content: I did an achievement
        url: https://achievmenturl.com
        image: /images/achievment-image.png

  # projects
  # projects:
  #   enable: true
  #   # title: "Custom Name"
  #   items:
  #     - title: Project 1
  #       content: Description of a project
  #       image: /images/project-image.png
  #       featured:
  #         name: Demo
  #         link: https://url.com
  #       badges:
  #         - "Python"
  #         - "Hugo"
  #         - "Javascript"
  #       links:
  #         - icon: fa fa-envelope
  #           url: mailto:[email protected]?subject=Hugo%20Profile%20Template&body=Check%20it%20out:%20https%3a%2f%2fhugo-profile.netlify.app%2fblog%2fmarkdown-syntax%2f
  #         - icon: fab fa-github
  #           url: https://github.com/project

  #     - title: Project 2
  #       content: Description of a project
  #       image: /images/project-image.png
  #       featured:
  #         name: Demo
  #         link: https://url.com
  #       badges:
  #         - "Python"
  #         - "Hugo"
  #         - "Javascript"
  #       links:
  #         - icon: fa fa-envelope
  #           url: mailto:[email protected]?subject=Hugo%20Profile%20Template&body=Check%20it%20out:%20https%3a%2f%2fhugo-profile.netlify.app%2fblog%2fmarkdown-syntax%2f
  #         - icon: fab fa-github
  #           url: https://github.com/project

  #Contact
  contact:
    enable: true
    # title: "Custom Name"
    content: Please feel free to reach out at any time—whether you have a question or simply wish to connect. Responses will be provided as promptly as possible.
    btnName: Email
    btnLink: mailto:[email protected]
    # formspree:
    #   enable: true # `contact.email` value will be ignored
    #   formId: abcdefgh # Take it from your form's endpoint, like 'https://formspree.io/f/abcdefgh'
    #   emailCaption: "Enter your email address"
    #   messageCaption: "Enter your message here"
    #   messageRows: 5

  footer:
    recentPosts:
      path: "blogs"
      count: 3
      title: Recent Posts
      enable: true
      disableFeaturedImage: true
    socialNetworks:
      github: https://github.com/
      linkedin: https://www.linkedin.com/

  # List pages like blogs and posts
  listPages:
    disableFeaturedImage: true

  # Single pages like blog and post
  singlePages:
    socialShare: true
    readTime:
      enable: true
      content: "min read"
    scrollprogress:
      enable: true
    tags:
      openInNewTab: true

  # For translations
  terms:
    read: "Read"
    toc: "Table Of Contents"
    copyright: "All rights reserved"
    pageNotFound: "Page not found"
    emailText: "Check out this site"

  datesFormat:
    article: "Jan 2, 2006"
    articleList: "Jan 2, 2006"
    articleRecent: "Jan 2, 2006"

  #customScripts: -| # You can add custom scripts which will be added before </body> tag
  #  <script type="text/javascript"><!-- any script here --></script>

Blog Deploy Script

Create a python script called ‘deploy.py’ and place this code: (Be sure to update OBSIDIAN_NOTES_PATH)

#!/usr/bin/env python3
import os
import sys
import re
import subprocess
import logging
import datetime
import shutil
from pathlib import Path
from typing import Optional, Tuple

# Add the parent directory of the project root to Python path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.append(str(PROJECT_ROOT.parent))

# Replace the blogi import with direct configuration
BLOG_SITE_STATIC_IMAGES_PATH = PROJECT_ROOT / "static" / "images"
BLOG_SITE_POSTS_PATH = PROJECT_ROOT / "content" / "blogs"
OBSIDIAN_NOTES_PATH = Path("/Path/To/My/ObsidianNotes")
OBSIDIAN_IMAGES_PATH = OBSIDIAN_NOTES_PATH / "images"
OBSIDIAN_POSTS_PATH = OBSIDIAN_NOTES_PATH / "posts"

# Debug mode flag - set to True to enable DEBUG logging
DEBUG_MODE = False
BASE_LOG_LEVEL = 'DEBUG' if DEBUG_MODE else 'INFO'

def setup_logging():
    """Configure logging."""
    logging.basicConfig(
        level=BASE_LOG_LEVEL,
        format='%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
        handlers=[logging.StreamHandler(sys.stdout)]
    )
    return logging.getLogger(__name__)

logger = setup_logging()

class DeploymentManager:
    def __init__(self):
        self.logger = logger
        self.changes_made = False

        self.dest_path = BLOG_SITE_POSTS_PATH
        self.origin_path = OBSIDIAN_POSTS_PATH
        self.images_source = OBSIDIAN_IMAGES_PATH
        self.images_dest = BLOG_SITE_STATIC_IMAGES_PATH

    def run_command(self, command: list[str], cwd: str = None) -> Tuple[bool, str]:
        """Run a shell command and return success status and output."""
        try:
            process = subprocess.run(
                command,
                cwd=cwd,
                check=True,
                capture_output=True,
                text=True
            )
            return True, process.stdout
        except subprocess.CalledProcessError as e:
            return False, e.stderr

    def sync_images(self) -> bool:
        """Verify and sync all images from Obsidian to website folder, including AI images."""
        try:
            self.logger.info("Verifying and syncing images:")
            self.logger.info(f"  Source: {self.images_source}")
            self.logger.info(f"  Destination: {self.images_dest}")

            # Validate directories
            for directory in [self.dest_path, self.images_source, self.images_dest]:
                if not directory.exists():
                    self.logger.error(f"  Directory not found: {directory}")
                    raise FileNotFoundError(f"Directory not found: {directory}")
                self.logger.debug(f"  ✓ Validated: {directory}")

            # Create destination directory if it doesn't exist
            self.images_dest.mkdir(parents=True, exist_ok=True)

            # Verify markdown files for standard images
            md_files = list(self.dest_path.glob('*.md'))
            self.logger.info(f"Verifying {len(md_files)} markdown files:")

            for filepath in md_files:
                self.logger.info(f"  File: {filepath.name}")
                with open(filepath, "r") as file:
                    content = file.read()
                
                # Check for unconverted links
                obsidian_links = re.findall(r'\[\[([^]]*\.png)\]\]', content)
                if obsidian_links:
                    self.logger.warning(f"    Found {len(obsidian_links)} unconverted image links!")
                
                # Verify and copy markdown images
                markdown_links = re.findall(r'\[.*?\]\(/images/([^)]+)\)', content)
                if markdown_links:
                    self.logger.info(f"    Found {len(markdown_links)} image references:")
                    for image in markdown_links:
                        source_path = self.images_source / image
                        dest_path = self.images_dest / image
                        
                        # Check if source image exists
                        if source_path.exists():
                            # Copy image if it doesn't exist in destination or if source is newer
                            if not dest_path.exists() or (source_path.stat().st_mtime > dest_path.stat().st_mtime):
                                self.logger.info(f"      Copying: {image}")
                                shutil.copy2(source_path, dest_path)
                                self.changes_made = True
                            self.logger.info(f"      ✓ {dest_path}")
                        else:
                            self.logger.warning(f"      ✗ Source image missing: {source_path}")
                else:
                    self.logger.info("    No images found")

            return True
            
        except Exception as e:
            self.logger.error(f"Image verification failed: {e}")
            self.logger.exception("Detailed error trace:")
            return False

    def sync_content(self) -> bool:
        """Sync content from Obsidian to Hugo."""
        try:
            self.dest_path.mkdir(parents=True, exist_ok=True)
            
            # Get lists of files in both directories
            source_files = set(f.name for f in self.origin_path.glob('*.md'))
            dest_files = set(f.name for f in self.dest_path.glob('*.md'))
            
            # Find files to remove (in dest but not in source)
            files_to_remove = dest_files - source_files
            for filename in files_to_remove:
                file_to_remove = self.dest_path / filename
                self.logger.info(f"Removing file: {filename}")
                file_to_remove.unlink()
                self.changes_made = True
            
            # Process source files
            files_processed = 0
            for source_file in self.origin_path.glob('*.md'):
                self.logger.info(f"Checking file: {source_file.name}")
                
                dest_file = self.dest_path / source_file.name

                with open(source_file, "r") as file:
                    content = file.read()
                
                content = self._process_image_paths_in_content(content, source_file)
                
                with open(source_file, "w") as file:
                    file.write(content)
                with open(dest_file, "w") as file:
                    file.write(content)
                
                files_processed += 1
                
            if files_processed > 0 or files_to_remove:
                self.changes_made = True
                self.logger.info(f"Content sync completed successfully ({files_processed} files updated, {len(files_to_remove)} files removed)")
            else:
                self.logger.info("No files needed updating")
            return True
            
        except Exception as e:
            self.logger.error(f"Sync failed: {e}")
            self.logger.exception("Detailed error trace:")
            return False

    def _process_image_paths_in_content(self, content: str, source_file: Path) -> str:
        """Process images in content and return updated content."""
        images = re.findall(r'\[\[([^]]*\.png)\]\]', content)
        
        for image in images:
            self.logger.info(f"    Processing image: {image}")
            new_image_name = image.replace(' ', '_')
            
            obsidian_image = self.images_source / image
            new_obsidian_image = self.images_source / new_image_name
            if obsidian_image.exists() and obsidian_image != new_obsidian_image:
                obsidian_image.rename(new_obsidian_image)
                self.logger.info(f"    ✓ Renamed Obsidian image: {image} -> {new_image_name}")
                self.changes_made = True
            
            markdown_image = f"![Image](/images/{new_image_name})"
            content = content.replace(f"[[{image}]]", markdown_image)
            
        return content

    def build_hugo(self, site_path: Path) -> bool:
        """Build Hugo site."""
        success, output = self.run_command(['hugo'], cwd=site_path)
        if not success:
            self.logger.error(f"Hugo build failed: {output}")
        return success

    def git_operations(self, site_path: Path) -> bool:
        """Handle all git operations."""
        try:
            self.run_command(['git', 'add', '.'], cwd=site_path)
            
            result = subprocess.run(['git', 'diff', '--cached', '--quiet'], 
                                 cwd=site_path, 
                                 capture_output=True)
            
            if result.returncode == 1:  # Changes exist
                commit_message = f"New Blog Post on {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
                self.run_command(['git', 'commit', '-m', commit_message], cwd=site_path)
                self.run_command(['git', 'push', 'origin', 'main'], cwd=site_path)
                self.handle_branch_deployment(site_path)
                
            return True
        except Exception as e:
            self.logger.error(f"Git operations failed: {e}")
            return False

    def handle_branch_deployment(self, site_path: Path) -> bool:
        """Handle branch deployment."""
        try:
            subprocess.run(['git', 'branch', '-D', 'deploy'], 
                         cwd=site_path,
                         stderr=subprocess.DEVNULL)
            
            self.run_command(['git', 'subtree', 'split', '--prefix', 'public', '-b', 'deploy'],
                           cwd=site_path)
            self.run_command(['git', 'push', 'origin', 'deploy:deploy', '--force'],
                           cwd=site_path)
            self.run_command(['git', 'branch', '-D', 'deploy'],
                           cwd=site_path)
            
            return True
        except Exception as e:
            self.logger.error(f"Branch deployment failed: {e}")
            return False

    def show_success_notification(self, no_changes=False):
        """Show success notification on macOS."""
        try:
            message = "No changes detected!" if no_changes else "Deployment completed successfully!"
            subprocess.run(['osascript', '-e', f'display dialog "{message}"'])
        except Exception as e:
            self.logger.error(f"Failed to show notification: {e}")

def main():
    """Main entry point for deployment process."""
    deploy_manager = DeploymentManager()
    
    if not all([
        deploy_manager.sync_content(),
        deploy_manager.sync_images()
    ]):
        return False

    if deploy_manager.changes_made:
        if all([
            deploy_manager.build_hugo(PROJECT_ROOT),
            deploy_manager.git_operations(PROJECT_ROOT)
        ]):
            deploy_manager.show_success_notification()
            return True
    else:
        deploy_manager.logger.info("No changes detected - skipping build and git operations")
        deploy_manager.show_success_notification(no_changes=True)
        return True
    
    return False

if __name__ == "__main__":
    success = main()
    sys.exit(0 if success else 1) 

Make the Script executable

chmod +x deploy.py

Execute Deployment Script

./deploy.py

Build Hugo Project

hugo build

Start Hugo Local Server

hugo server

Navigate to test locally or go to your site to see the changes live in production: http://localhost:1313/

Cheers! You’re done and your new blog should be deployed! 🥂

Image

Fixes if you get GIT merge errors or something weird happens with the data pipeline.

Delete Git Branch

git push -d <remote_name> <branchname>   # Delete remote
git branch -d <branchname>               # Delete local

Navigate to:Hostinger

Go to: Advanced > GIT > Under Manage Repositories > Delete the repository reference

Navigate to the public_html of your site and delete all the files that get generated. Be sure to delete hidden .git folders.

Run ./deploy.py to save and sync everything and setup a new clean ‘deploy’ branch.

Go back to the Hostinger GIT settings and add the reference to the new ‘deploy’ branch.

Run ./deploy.sh again and it should sync all of your changes to your new blog.

comments powered by Disqus