Configuring Ghost for Technical Blogging: SEO, Syntax Highlighting, and Comments

Setting up Ghost for technical content: Prism.js syntax highlighting with autoloader, SEO optimization, Google Search Console, Giscus comments, and GDPR compliance. Complete code injection configuration included.


Introduction

Yesterday, I got Ghost up and running on my Red Hat server. Today, I'm making it functional for technical content: adding syntax highlighting for code blocks, setting up SEO so people can actually find my blog, and enabling comments so readers can engage with the content.

This post documents every configuration step, the decisions I made, and the issues I encountered along the way.


πŸ“ This is part of the Blog Infrastructure series - documenting how I built this platform to share my homelab journey.

Other posts in this series:


Diagram showing Ghost code injection structure: the head section loads Prism CSS and SEO meta tags, the body contains the blog post content, and the end of body footer loads Prism JS, Giscus comments, and the cookie notice. Scripts load last for performance, CSS loads first for styling.
Ghost code injection in three layers β€” CSS and meta tags in the head, scripts and comments at the end of body where they won't block page rendering.

Why These Configurations Matter

A basic Ghost installation works, but it's not optimized for technical blogging:

  • Code blocks need syntax highlighting to be readable
  • SEO configuration helps Google discover and index your content
  • Comments enable discussion and community building
  • GDPR compliance (I'm in the Netherlands) requires proper cookie notices

Let's make Ghost production-ready.


This post assumes you have Ghost already installed. If you're starting from scratch, check out the installation guide first.

Living in the EU means I need to be transparent about cookies. Even though Ghost uses minimal tracking, it's good practice to inform visitors.

Why This Matters

Under GDPR (enforced by Autoriteit Persoonsgegevens in the Netherlands):

  • Essential cookies don't need consent
  • Users should know what data is collected
  • Transparency builds trust

Since I'm not using analytics or tracking cookies yet (planning self-hosted Umami later), I only need a simple notice about essential cookies.

Implementation

Ghost's Code Injection feature lets you add custom HTML, CSS, and JavaScript without editing theme files.

Location: Settings β†’ Code Injection β†’ Site Footer

<!-- ==================== Cookie Notice (GDPR) ==================== -->
<style>
.cookie-notice {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    text-align: center;
    padding: 10px;
    font-size: 0.9em;
    color: #666;
    background: #f9f9f9;
    border-top: 1px solid #eee;
    z-index: 1000;
}
</style>

<div class="cookie-notice">
    This site uses essential cookies for functionality. No tracking cookies are used.
</div>
<!-- ==================== End Cookie Notice ==================== -->

Result: A fixed bar at the bottom of every page informing visitors about cookie usage.

The "Site Footer" in Code Injection is confusing - it refers to where the code is injected in the HTML (bottom of the <body> tag), not where content visually appears. You need CSS positioning (position: fixed; bottom: 0;) to actually place it at the visual bottom of the page.

I added clear comment blocks (<!-- ==================== -->) to organize code sections, which made troubleshooting much easier later.


Part 2: Syntax Highlighting with Prism.js

Technical blogs live and die by code readability. Ghost's default code blocks are plain text - no colors, no structure. Not good enough.

Choosing Prism.js

I evaluated a few options:

  • Prism.js - Lightweight, many themes, CDN-hosted
  • Highlight.js - Popular alternative, heavier
  • Custom CSS - Too much work to maintain

Prism.js won: easy to implement via CDN, good theme selection, lightweight and highly extensible. By using the Autoloader plugin, the blog now automatically detects and highlights any language I useβ€”from Bash and YAML to Pythonβ€”without me ever having to touch the configuration again.

I chose the Tomorrow Night theme - it's easy on the eyes and looks professional.

Implementation: Two Parts Required

This is where many guides get confusing. Prism.js needs both CSS and JavaScript, and they go in different places in Ghost Code Injection.

Site Header (CSS Stylesheet)

Location: Settings β†’ Code Injection β†’ Site Header (top box)

<!-- ==================== Prism.js Syntax Highlighting - CSS ==================== -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.css">
<!-- ==================== End Prism.js Syntax Highlighting - CSS ==================== -->

Location: Settings β†’ Code Injection β†’ Site Footer (bottom box)

<!-- ==================== Prism.js Syntax Highlighting ==================== -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/toolbar/prism-toolbar.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>
<!-- ==================== End Prism.js Syntax Highlighting ==================== -->

Language autoloader plugin added: the blog automatically detects and highlights any language β€” from Bash and YAML to Python. No need to add more later if needed.

To enable line numbers on a specific block, ensure you use the line-numbers class in your Markdown (e.g., ```yaml line-numbers).

Critical Mistake I Made

Initially, I put everything in the Site Header box. The CSS loaded, but the JavaScript didn't work properly because scripts should load at the end of the page (in Site Footer).

The fix: CSS goes in Site Header, JavaScript goes in Site Footer. The clear comment demarcation I added made it easy to identify and move the blocks to the right places.

Testing

After saving:

  1. Visit a blog post with code blocks
  2. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R) - Ghost caches aggressively
  3. Code blocks should now have color syntax highlighting

It works! Now code is actually readable.


Part 3: SEO Configuration

A great blog is useless if no one can find it. Time to make Google happy.

General Settings

Location: Settings β†’ General

Timezone: Set to Europe/Amsterdam (important for post timestamps)

Site Description: This appears in Google search results and social media shares.

I went with:

"Building a production homelab with Kubernetes on TuringPi. 25+ years of telecom infrastructure experience applied to self-hosted services, Talos Linux, and ARM64 clusters. Real commands, real troubleshooting."

This tells search engines (and humans) exactly what the blog is about: technical, experienced, hands-on.

Post-Level SEO

Each post should have its own meta description. In Ghost:

  1. Open a post for editing
  2. Click the settings icon (looks like a toggle/slider)
  3. Find the "Excerpt" field
  4. Add a description under 160 characters

For my first post, I used:

"Complete guide to installing Ghost CMS on Red Hat Enterprise Linux 9.7 with remote MariaDB, Apache reverse proxy, and SELinux. Includes all commands, troubleshooting, and lessons learned."

Ghost auto-saves as you type - no need to manually save.

Sitemap Verification

Ghost automatically generates XML sitemaps at:
https://vluwte.nl/sitemap.xml

I verified it loads and shows my posts and pages. Ghost creates multiple sitemaps automatically:

  • sitemap.xml (main index)
  • sitemap-posts.xml
  • sitemap-pages.xml
  • sitemap-authors.xml
  • sitemap-tags.xml

All working perfectly.


Part 4: Google Search Console Setup

Having a sitemap is great, but Google needs to know it exists.

Verification Method Decision

Google offers three verification methods:

1. DNS TXT Record

  • Pros: Most permanent, domain-level verification, clean (no code on site), professional approach
  • Cons: Requires DNS access, takes time to propagate

2. HTML Meta Tag

  • Pros: Quick, instant verification, easy via Code Injection
  • Cons: Could be accidentally removed, tied to platform, adds site code

3. HTML File Upload

  • Pros: Straightforward concept
  • Cons: Complicated with Ghost, requires Apache config, can get lost

My choice: DNS TXT Record. I manage my own DNS, and this is the cleanest approach. It won't accidentally break if I edit Code Injection or change themes.

Implementation

  1. Went to https://search.google.com/search-console
  2. Selected "Add property" β†’ "Domain" property
  3. Entered: vluwte.nl
  4. Google provided a TXT record: google-site-verification=abc123...
  5. Added TXT record to my DNS zone as @ record
  6. Waited a few minutes for DNS propagation
  7. Clicked "Verify" in Search Console

βœ… Ownership verified!

Submitting the Sitemap

In Google Search Console:

  1. Clicked "Sitemaps" in left sidebar
  2. Entered: sitemap.xml
  3. Clicked "Submit"

Initial status: "Couldn't fetch" - This is misleading!

Troubleshooting: When Google Says "Couldn't Fetch"

Search Console showed "couldn't fetch" status, but I knew something was off. Time to check the logs.

tail -f /opt/websites/vluwte.nl/logs/access.log | grep -i google

Result:

66.249.75.161 - - [15/Feb/2026:09:45:13 +0100] "GET /sitemap.xml HTTP/1.1" 200 617 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
66.249.75.160 - - [15/Feb/2026:09:45:53 +0100] "GET /sitemap-authors.xml HTTP/1.1" 200 329 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"

200 status codes = Success! Google is fetching the sitemap successfully.

The "couldn't fetch" status in Search Console is a timing/sync issue between Google's crawler and the Search Console UI. This is common with newly submitted sitemaps.

Timeline expectations:

  • Googlebot may fetch within minutes to hours of submission
  • Search Console status update: Wait 12-24 hours
  • If logs show 200 responses, the sitemap is working correctly

I'll check back in 2-3 days to verify:

  • Sitemap status changed to "Success"
  • Coverage report shows indexed pages
  • Performance data starts appearing

Google Analytics & Tag Manager?

Google prompted me to set up Analytics and Tag Manager. I declined both:

Google Analytics: Not privacy-friendly, requires cookie consent, overkill for now. I'll use Ghost's built-in analytics or self-host Umami on my TuringPi cluster later.

Tag Manager: Massive overkill for a technical blog. Only needed for complex marketing campaigns with multiple tracking scripts.


Part 5: A Smart Comment System with Giscus

The last piece: letting readers engage with the content.

Choosing a Comment System

Options considered:

Disqus

  • Pros: Popular, easy setup
  • Cons: Tracking/ads, privacy concerns, not technical-audience-friendly

Commento

  • Pros: Privacy-focused, self-hostable
  • Cons: Requires setup and maintenance

Utterances

  • Pros: Uses GitHub Issues, simple
  • Cons: Issues aren't ideal for discussions

Giscus (Chosen)

  • Pros: Uses GitHub Discussions (better than Issues), privacy-friendly, free, clean interface
  • Cons: Requires GitHub account to comment

My reasoning: My audience is technical professionals interested in Kubernetes, Talos Linux, and self-hosting. If you're reading about building a homelab cluster, you probably already have a GitHub account. If not... well, this might be a good reason to create one! πŸ˜‰

The trade-off: high-quality technical discussion over casual anonymous comments.

Setting Up GitHub Repository

Giscus requires a public GitHub repository with Discussions enabled:

  1. Created repository: vluwte/blog-discussions
  2. Description: "Discussions for vluwte.nl blog posts"
  3. Set to Public (required)
  4. Enabled Discussions: Settings β†’ Features β†’ βœ… Discussions
  5. Disabled unnecessary features (Wikis, Issues, Projects) to keep it focused

Installing the Giscus App

  1. Went to https://github.com/apps/giscus
  2. Clicked "Install"
  3. Selected "Only select repositories"
  4. Chose blog-discussions
  5. Permissions granted:
    • Read access to metadata
    • Read and write access to discussions

Configuring Giscus

Went to https://giscus.app/ and configured:

Repository: vluwte/blog-discussions
Mapping: Discussion title contains page pathname (creates unique discussion per post)
Category: Announcements (author-controlled threads)
Features:

  • βœ… Enable reactions
  • βœ… Emit discussion metadata
  • βœ… Place comment box above comments

Theme: Preferred color scheme (adapts to reader's light/dark mode)

Giscus generated a <script> tag with all the configuration embedded.

Adding to Ghost

Location: Settings β†’ Code Injection β†’ Site Footer (after cookie notice)

<!-- ==================== Giscus Comments System ==================== -->
<script>
  function loadGiscus() {
    // Check if the current page is a post by looking for the .post-template class
    if (document.body.classList.contains('post-template')) {
      const giscusScript = document.createElement('script');
      giscusScript.src = "https://giscus.app/client.js";
      giscusScript.setAttribute("data-repo", "vluwte/blog-discussions");
      giscusScript.setAttribute("data-repo-id", "R_kgDORQuPVQ");
      giscusScript.setAttribute("data-category", "Announcements");
      giscusScript.setAttribute("data-category-id", "DIC_kwDORQuPVc4C2ejq");
      giscusScript.setAttribute("data-mapping", "pathname");
      giscusScript.setAttribute("data-strict", "0");
      giscusScript.setAttribute("data-reactions-enabled", "1");
      giscusScript.setAttribute("data-emit-metadata", "1");
      giscusScript.setAttribute("data-input-position", "top");
      giscusScript.setAttribute("data-theme", "preferred_color_scheme");
      giscusScript.setAttribute("data-lang", "en");
      giscusScript.crossOrigin = "anonymous";
      giscusScript.async = true;
      
      document.body.appendChild(giscusScript);
    }
  }
  
  if (document.readyState === 'complete') {
    loadGiscus();
  } else {
    window.addEventListener('DOMContentLoaded', loadGiscus);
  }
</script>
<!-- ==================== End Giscus Comments System ==================== -->

Saved and refreshed my blog post - comments section appeared at the bottom with "Sign in to comment" using GitHub authentication.

Limitation Discovered

Comments originally appeared on all pages (homepage, posts, about page) because Ghost's Code Injection applies globally.

By using a JavaScript conditional check (document.body.classList.contains('post-template')), I ensured comments only load on posts β€” avoiding "theme hacking" while keeping the homepage clean.


Complete Code Injection Structure

To avoid confusion about where code goes, here's the complete setup:

Site Header (CSS Only)

<!-- ==================== Prism.js Syntax Highlighting - CSS ==================== -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.css">
<!-- ==================== End Prism.js Syntax Highlighting - CSS ==================== -->
<!-- ==================== Prism.js Syntax Highlighting ==================== -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/toolbar/prism-toolbar.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>
<!-- ==================== End Prism.js Syntax Highlighting ==================== -->

<!-- ==================== Cookie Notice (GDPR) ==================== -->
<style>
.cookie-notice {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    text-align: center;
    padding: 10px;
    font-size: 0.9em;
    color: #666;
    background: #f9f9f9;
    border-top: 1px solid #eee;
    z-index: 1000;
}
</style>

<div class="cookie-notice">
    This site uses essential cookies for functionality. No tracking cookies are used.
</div>
<!-- ==================== End Cookie Notice ==================== -->

<!-- ==================== Giscus Comments System ==================== -->
<script>
  function loadGiscus() {
    // Check if the current page is a post by looking for the .post-template class
    if (document.body.classList.contains('post-template')) {
      const giscusScript = document.createElement('script');
      giscusScript.src = "https://giscus.app/client.js";
      giscusScript.setAttribute("data-repo", "vluwte/blog-discussions");
      giscusScript.setAttribute("data-repo-id", "R_kgDORQuPVQ");
      giscusScript.setAttribute("data-category", "Announcements");
      giscusScript.setAttribute("data-category-id", "DIC_kwDORQuPVc4C2ejq");
      giscusScript.setAttribute("data-mapping", "pathname");
      giscusScript.setAttribute("data-strict", "0");
      giscusScript.setAttribute("data-reactions-enabled", "1");
      giscusScript.setAttribute("data-emit-metadata", "1");
      giscusScript.setAttribute("data-input-position", "top");
      giscusScript.setAttribute("data-theme", "preferred_color_scheme");
      giscusScript.setAttribute("data-lang", "en");
      giscusScript.crossOrigin = "anonymous";
      giscusScript.async = true;
      
      document.body.appendChild(giscusScript);
    }
  }
  
  if (document.readyState === 'complete') {
    loadGiscus();
  } else {
    window.addEventListener('DOMContentLoaded', loadGiscus);
  }
</script>
<!-- ==================== End Giscus Comments System ==================== -->

Critical: CSS goes in Site Header, JavaScript goes in Site Footer. I learned this the hard way by initially putting everything in Site Header, which broke syntax highlighting.

The clear comment demarcation (<!-- ==================== -->) makes it easy to identify, organize, and troubleshoot each section.


Lessons Learned

1. Code Injection Box Confusion

"Site Header" and "Site Footer" refer to where code is injected in HTML, not where content visually appears. CSS positioning is needed for visual placement.

CSS stylesheets go in Site Header (<head>), JavaScript goes in Site Footer (end of <body>). Getting this wrong breaks functionality.

3. Comment Block Organization

Using consistent, clear comment markers (<!-- ==================== Section Name ==================== -->) made troubleshooting and reorganization trivial. Highly recommend this for any Code Injection work.

4. Google Search Console Lag

"Couldn't fetch" status doesn't mean failure. Check your Apache logs for actual Googlebot requests with 200 status codes. The Search Console UI lags behind the actual crawler by hours or days.

5. Privacy-First Approach

Declining Google Analytics and Tag Manager was the right choice. They're overkill for a technical blog, create GDPR compliance issues, and I have better alternatives (Ghost's built-in analytics now, self-hosted Umami later).

6. GitHub Comments = Quality Filter

Requiring GitHub authentication for comments naturally filters for technical audience. This aligns perfectly with the blog's focus.

7. Document Limitations

Knowing comments appear on all pages (not ideal) and documenting it as a future improvement prevents frustration later. Not everything needs to be perfect immediately.

8. Dynamic Script Injection

You can use document.createElement('script') to load external tools only when specific conditions are met (like being on a post page). This keeps the site light and prevents "polluting" pages with unnecessary UI elements like comment boxes.


What's Working Now

βœ… Syntax highlighting - Code blocks are beautiful and readable
βœ… Cookie notice - GDPR-compliant transparency
βœ… SEO configured - Google knows the site exists
βœ… Sitemap submitted - Waiting for indexing
βœ… Comments enabled - Readers can engage via GitHub
βœ… Professional appearance - Blog looks legitimate
βœ… Conditional comment display - Only comments on posts


Next Steps

Immediate (2-3 days):

  • Check Google Search Console for sitemap status update
  • Verify pages are being indexed
  • Monitor for crawl errors

Future improvements:

  • Create Privacy Policy page
  • Self-hosted analytics (Umami on TuringPi cluster)
  • HTML sitemap for humans (after 10+ posts)

Content pipeline:

  • Start planning TuringPi cluster hardware
  • Research Talos Linux in depth
  • Design self-hosting strategy

Conclusion

Ghost is now configured for serious technical blogging. Code is readable, Google can find the content, readers can comment, and everything is GDPR-compliant.

The configurations took a few hours with some trial and error, but now it's solid. The clear code organization and documentation will make future changes much easier.

Total time: About 3-4 hours including troubleshooting.

Was it worth it? Absolutely. The blog is now professional, discoverable, and ready for consistent publishing.

If you're setting up your own Ghost blog for technical content, I hope this detailed walkthrough helps you avoid some of the issues I encountered!


← Previous: Setting Up Ghost on Red Hat
β†’ Next: Adding Analytics to My Blog: The Umami Journey


Questions or suggestions? Leave a comment below (yes, the comments system is now working!) or reach out at igor@vluwte.nl.