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:
- Setting Up Ghost on Red Hat
- Configuring Ghost for Technical Blogging (you are here)
- Adding Analytics to My Blog

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.
Part 1: Cookie Notice for GDPR Compliance
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.
Lesson Learned: "Site Footer" Naming
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 ==================== -->
Site Footer (JavaScript)
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:
- Visit a blog post with code blocks
- Hard refresh (Ctrl+Shift+R or Cmd+Shift+R) - Ghost caches aggressively
- 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:
- Open a post for editing
- Click the settings icon (looks like a toggle/slider)
- Find the "Excerpt" field
- 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.xmlsitemap-pages.xmlsitemap-authors.xmlsitemap-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
- Went to https://search.google.com/search-console
- Selected "Add property" β "Domain" property
- Entered:
vluwte.nl - Google provided a TXT record:
google-site-verification=abc123... - Added TXT record to my DNS zone as
@record - Waited a few minutes for DNS propagation
- Clicked "Verify" in Search Console
β Ownership verified!
Submitting the Sitemap
In Google Search Console:
- Clicked "Sitemaps" in left sidebar
- Entered:
sitemap.xml - 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:
- Created repository:
vluwte/blog-discussions - Description: "Discussions for vluwte.nl blog posts"
- Set to Public (required)
- Enabled Discussions: Settings β Features β β Discussions
- Disabled unnecessary features (Wikis, Issues, Projects) to keep it focused
Installing the Giscus App
- Went to https://github.com/apps/giscus
- Clicked "Install"
- Selected "Only select repositories"
- Chose
blog-discussions - 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 ==================== -->
Site Footer (JavaScript and HTML)
<!-- ==================== 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.
2. Header vs Footer Placement Matters
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.