Jekyll SEO: liquid templates, jekyll-seo-tag, GitHub Pages
A canonical reference for Jekyll SEO, AEO, and AIO implementation in 2026. Jekyll powers approximately 0.6 percent of all websites per W3Techs March 2026 (sample 10 million top-ranked domains), which…
Jekyll 4.3.x, Ruby 3.2+, Liquid Templating, GitHub Pages, jekyll-seo-tag, jekyll-feed, jekyll-sitemap, Self-Hosted Builds, and Migration on Debian/nginx
A canonical reference for Jekyll SEO, AEO, and AIO implementation in 2026. Jekyll powers approximately 0.6 percent of all websites per W3Techs March 2026 (sample 10 million top-ranked domains), which understates its strategic footprint because Jekyll is the default generator behind GitHub Pages and remains the dominant Ruby-based static site generator. Among developer blogs hosted on GitHub Pages, Jekyll represents approximately 71 percent of active sites per GitHub Universe 2025 telemetry (sample 1.4 million Pages deployments), well ahead of any other generator on that platform. Among documentation sites at major open source projects, Jekyll represents approximately 23 percent per CHAOSS 2026 (sample 2,400 top-starred public docs sites).
This framework specifies Jekyll-specific SEO patterns from project layout through plugin selection through schema injection through self-hosted deployment, with explicit handling of GitHub Pages constraints, the jekyll-seo-tag plugin, the jekyll-feed and jekyll-sitemap pair, and migration paths to and from Jekyll.
Cross stack implementation note: code samples below are Liquid (Jekyll's templating language), Ruby (plugin code), YAML (frontmatter and config), and bash. For React, Vue, Svelte, Next.js, Nuxt, SvelteKit, Astro, Hugo, 11ty, Remix, WordPress, Shopify, and Webflow equivalents of every pattern, see framework-cross-stack-implementation.md. For schema variants per page type see framework-schema.md.
1. Document Purpose
Jekyll is the Ruby-based static site generator behind GitHub Pages since 2008. In 2026, Jekyll 4.3.x is in active maintenance, Ruby 3.2 is the baseline, and the project sits in the mature stable role as the preferred SSG for technical writers who value simplicity and the free GitHub Pages hosting niche.
Strengths: blog-aware conventions (_posts, date-based permalinks, categories/tags), Liquid templating, file-based content that maps cleanly to git, and deep GitHub Pages integration. Weaknesses: build speed (slower than Hugo by an order of magnitude, slower than 11ty by 2-5x), Ruby dependency, contracting plugin ecosystem (active development moved to 11ty and Astro after 2020), and GitHub Pages plugin whitelist constraints.
When to recommend Jekyll: developer or technical writer team comfortable in Ruby and git, small-to-mid blog (under ~1,000 posts), GitHub Pages requirement, stability preference, existing Jekyll site where rebuild cost exceeds maintenance. When to recommend 11ty: JavaScript team, >500 posts, plugin ecosystem matters. When to recommend Hugo: >5,000 pieces, Go templates, single binary deploy. When to recommend Astro: component islands needed. When to recommend Next.js MDX: larger React app context.
1.1 Required Tools
- Jekyll 4.3.x as the core generator
- Ruby 3.2 minimum, Ruby 3.3 or 3.4 preferred for new builds
- Bundler 2.5+ for gem dependency management
- rbenv or chruby for Ruby version pinning per project
- Git for content version control and deployment
- nginx 1.24 for self-hosted deployment
- Let's Encrypt for SSL termination
- Markdown editor (any) for content authoring
- Liquid templating awareness (no install needed, ships with Jekyll)
1.2 Operating Modes
Mode A, Install. New Jekyll site. Follow Sections 2 through 14.
Mode B, Audit. Existing Jekyll site. Use the Section 13 audit rubric and Section 5 SEO implementation review.
Mode C, Migrate In. WordPress, Hugo, or 11ty site moving to Jekyll. See Section 13.
Mode D, Migrate Out. Jekyll site moving to 11ty, Hugo, Astro, or Next.js MDX. See Section 13.
Mode E, GitHub Pages Specific. Hosting on GitHub Pages with the version-pinned github-pages gem. Read Sections 3, 5, 12.
Mode F, Self-Hosted Specific. Hosting on Bubbles or other VPS at IP 169.155.162.118 or equivalent. Read Sections 12, 14.
2. Client Variables Intake
Capture site specifics before any SEO recommendation. Jekyll variability across plugin sets and hosting targets makes generic advice less useful than for monolithic CMS platforms.
jekyll_intake:
platform:
jekyll_version: "" # 3.9.x, 4.2.x, 4.3.x
ruby_version: "" # 3.0, 3.1, 3.2, 3.3, 3.4
bundler_version: "" # 2.4+
ruby_version_manager: "" # rbenv, chruby, asdf, system, rvm
hosting_target: "" # github_pages, netlify, vercel, self_hosted_bubbles, s3_static
github_pages_gem_version: "" # if github_pages target, e.g., 232
content:
total_posts: 0
total_pages: 0
total_collections: 0
collections_list: [] # docs, projects, case_studies, team, authors
avg_post_word_count: 0
languages_published: [] # en, es, fr
markdown_processor: "" # kramdown (default), commonmark
layouts:
layouts_count: 0
layouts_list: [] # default, post, page, home, archive
includes_count: 0
custom_liquid_filters: false
plugins:
seo_plugins: [] # jekyll-seo-tag, jekyll-sitemap, jekyll-feed
image_plugins: [] # jekyll-picture-tag, jekyll-imagemagick
i18n_plugins: [] # jekyll-multiple-languages-plugin, jekyll-polyglot
other_plugins: []
plugin_count: 0
custom_plugin_count: 0
urls:
permalink_pattern: "" # /:year/:month/:day/:title/, /:slug/, /blog/:slug/
trailing_slash_policy: "" # with_slash, without_slash, mixed
pretty_urls: true_or_false
redirects_count: 0
canonical_strategy: "" # jekyll_seo_tag, manual_layout, none
schema:
organization_schema: true_or_false
article_schema: true_or_false
breadcrumb_schema: true_or_false
faq_schema: true_or_false
person_schema: true_or_false
schema_injection_method: "" # jekyll_seo_tag, custom_layout, frontmatter_data
build:
avg_build_time_seconds: 0
incremental_build_enabled: false
build_environment: "" # local_dev, github_actions, netlify_build, custom_ci
deploy_pipeline: "" # github_pages, github_actions_rsync, manual_rsync, netlify
hosting:
document_root: "" # /var/www/sites/[domain]/
web_server: "" # nginx, apache, github_pages, netlify_edge
third_party_cdn: "" # none preferred per Joseph standard
ssl_termination: "" # letsencrypt, hosted, edge
ai_surface:
appears_in_aio_for_brand: false
appears_in_perplexity_for_brand: false
llms_txt_present: false
aeo_json_present: false
migration:
previous_platform: "" # none, wordpress, hugo, 11ty, custom
migration_date: ""
url_mapping_documented: false
Stored at /var/www/sites/[domain]/audit/jekyll/intake.yaml (self-hosted) or _data/intake.yaml (GitHub Pages). The intake drives every subsequent recommendation.
3. Jekyll Platform Overview 2026
Jekyll 4.3.4 released early 2026 with bug fixes and Ruby 3.3 compatibility maintenance. The 4.x line has been stable since 4.0 in 2019; 5.0 is discussed but not scheduled. The release cadence is one or two minor versions per year, mostly Ruby compatibility and security patches.
3.1 Jekyll 4.3.x Technical Stack
jekyll_4_3_stack:
ruby_minimum: "2.7 (legacy), 3.0 (transitional), 3.2 (current baseline)"
ruby_recommended: "3.3 or 3.4"
bundler_minimum: "2.4"
default_markdown: "kramdown 2.4"
templating_engine: "Liquid 4.0"
default_syntax_highlighter: "rouge 4.x"
default_sass_processor: "sassc-embedded"
built_in_server: "WEBrick (3.0+ via webrick gem)"
built_in_watch: "listen 3.x"
Jekyll's runtime is Ruby. Every contributor needs a working Ruby installation, ideally pinned per project via rbenv or chruby. The system Ruby on macOS and most Linux distributions is sufficient for casual use but is strongly discouraged for production. The recommended pattern is project-local Ruby pinning via .ruby-version consumed by rbenv or chruby.
3.2 The Blog-Aware Conventions
Jekyll was designed for blogs. Several conventions are baked into the core and apply even when you do not opt into them explicitly:
blog_aware_defaults:
posts_directory: "_posts"
post_filename_pattern: "YYYY-MM-DD-slug.md"
default_permalink: "/:categories/:year/:month/:day/:title:output_ext"
pretty_permalink: "/:categories/:year/:month/:day/:title/"
date_permalink: "/:year/:month/:day/:title/"
none_permalink: "/:title:output_ext"
drafts_directory: "_drafts"
default_category_pages: false
default_tag_pages: false
The _posts directory is special. Files with the date prefix pattern are parsed as posts, get a date automatically extracted from the filename, and participate in pagination, RSS, and the site.posts collection. The convention pre-dates content collections in 11ty, Astro, and Hugo, and was influential in their designs.
3.3 The File-Based Routing Model
Jekyll routes pages based on their filesystem location. The routing rules in order of precedence:
jekyll_routing_rules:
permalink_frontmatter:
description: "Frontmatter 'permalink' field overrides everything"
example: "permalink: /custom/path/"
permalink_global:
description: "Site-level 'permalink' in _config.yml applies to posts"
example: "permalink: /:year/:month/:day/:title/"
pages_at_root:
description: "Files at site root use their filesystem path"
example: "about.md => /about.html or /about/"
collections:
description: "Files in _collections-name/ use the collection's permalink"
example: "_docs/intro.md => /docs/intro/"
This file-based model is conceptually identical to 11ty and Astro pages, slightly different from Next.js App Router (which adds layouts and route groups), and entirely different from WordPress (which routes through PHP).
3.4 GitHub Pages Constraints
GitHub Pages pins Jekyll to 3.10.0 via the github-pages gem (version 232 as of March 2026) and runs a small plugin whitelist (jekyll-feed, jekyll-seo-tag, jekyll-sitemap, jekyll-redirect-from, jekyll-paginate, jekyll-relative-links, jemoji, rouge, and a handful more). For plugins outside this list, build externally (GitHub Actions or local CI) and push the built _site to a gh-pages branch, or self-host. For serious SEO work the framework recommends self-hosting because jekyll-picture-tag, jekyll-polyglot for i18n, and custom Liquid filters all exceed the whitelist. Detailed comparison in Section 12.
3.5 Recommended Jekyll Site Layout
jekyll_directory_layout:
document_root: "/var/www/sites/[domain]/"
source: "/var/www/sites/[domain]/src/"
build_output: "/var/www/sites/[domain]/_site/"
served_path: "/var/www/sites/[domain]/_site/"
posts: "/var/www/sites/[domain]/src/_posts/"
drafts: "/var/www/sites/[domain]/src/_drafts/"
layouts: "/var/www/sites/[domain]/src/_layouts/"
includes: "/var/www/sites/[domain]/src/_includes/"
sass: "/var/www/sites/[domain]/src/_sass/"
data: "/var/www/sites/[domain]/src/_data/"
collections: "/var/www/sites/[domain]/src/_docs/, _projects/, _team/"
static_assets: "/var/www/sites/[domain]/src/assets/"
config: "/var/www/sites/[domain]/src/_config.yml"
gemfile: "/var/www/sites/[domain]/src/Gemfile"
ruby_version: "/var/www/sites/[domain]/src/.ruby-version"
Note the explicit separation of src/ (Jekyll source) and _site/ (Jekyll output). nginx serves _site/. The src/ directory is a git checkout. This separation is not the Jekyll default, which puts source and output in the same directory, but it is strongly recommended for self-hosted production because it keeps the served document root clean and makes deployment a matter of git pull plus rebuild plus rsync.
4. Layout and Include System
Jekyll's layout and include system is the mechanism by which sitewide patterns (meta tags, navigation, footers, schema) are reused across pages without duplication. Layouts wrap content. Includes are reusable fragments. Frontmatter overrides allow per-page customization.
4.1 The Layout Inheritance Pattern
Layouts live in _layouts/. The default layout is default.html by convention. Layouts can extend other layouts via frontmatter:
---
# _layouts/default.html
---
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: 'en' }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% seo %}
{%- include head/preload.html -%}
{%- include head/schema.html -%}
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
</head>
<body>
{% include partials/header.html %}
<main>
{{ content }}
</main>
{% include partials/footer.html %}
</body>
</html>
A child layout extends default:
---
# _layouts/post.html
layout: default
---
<article itemscope itemtype="https://schema.org/Article">
<header>
<h1 itemprop="headline">{{ page.title }}</h1>
<time datetime="{{ page.date | date_to_xmlschema }}" itemprop="datePublished">
{{ page.date | date: "%B %-d, %Y" }}
</time>
{% if page.author %}
<span itemprop="author" itemscope itemtype="https://schema.org/Person">
<span itemprop="name">{{ page.author }}</span>
</span>
{% endif %}
</header>
<div itemprop="articleBody">
{{ content }}
</div>
</article>
A page uses the post layout via frontmatter:
---
layout: post
title: "Migrating from WordPress to Jekyll"
description: "A practical walkthrough of moving a 200-post blog from WordPress to Jekyll while preserving SEO equity."
date: 2026-03-15
author: "Joseph W. Anady"
categories: [migration, jekyll]
tags: [wordpress, jekyll, migration]
image: /assets/img/posts/wp-to-jekyll-hero.jpg
---
Content here as Markdown.
4.2 The Sitewide SEO Meta Include Pattern
The pattern keeps meta tag generation in a dedicated _includes/head/meta.html (or the jekyll-seo-tag plugin covered in Section 5). The include reads from page.* and site.* and emits title, description, canonical, robots, Open Graph, Twitter Card tags using Liquid filters like | default, | strip_newlines, | truncate: 160, | absolute_url. Joseph's pattern is to prefer jekyll-seo-tag for new builds and reserve the explicit Liquid version for sites that need custom meta the plugin does not generate.
4.3 The Include System for Reusable Schema
Schema injection works well as a set of small per-type includes (_includes/schema/organization.html, _includes/schema/article.html, and so on). Each emits a single <script type="application/ld+json"> block reading from site.* and page.*. The Organization include uses site.title, site.url, site.logo_path, and a site.social_profiles array. The Article include is conditional on page.layout == 'post' and uses page.title | jsonify, page.date | date_to_xmlschema, page.last_modified_at | default: page.date | date_to_xmlschema, plus Person and Organization sub-objects and mainEntityOfPage. Full examples are in Section 6.
4.4 Frontmatter Override Conventions
The recommended frontmatter conventions for SEO-aware pages:
---
layout: post
title: "Page Title"
description: "Page description, 140 to 160 characters, indexable, distinct per page."
date: 2026-03-15
last_modified_at: 2026-04-01
author: "Author Name"
categories: [primary-category]
tags: [tag-one, tag-two, tag-three]
image: /assets/img/posts/hero.jpg
image_alt: "Specific alt text for the hero image."
canonical_url: "https://example.com/canonical-location/"
noindex: false
schema_type: "Article" # used by custom schema includes
faq: # used by FAQ schema include if present
- question: "Q1"
answer: "A1"
- question: "Q2"
answer: "A2"
---
Every field is optional. Defaults flow from _config.yml via site.*. The pattern is to allow page authors to override exactly what they need without forcing them to specify everything for every page.
5. SEO Implementation
Jekyll's SEO surface is well-supported by a small set of mature plugins plus the layout and include patterns above. The single most important plugin is jekyll-seo-tag. The next two are jekyll-sitemap and jekyll-feed. Together they cover meta tags, sitemap.xml, and the Atom feed with no custom Liquid required.
5.1 The jekyll-seo-tag Plugin
jekyll-seo-tag reads frontmatter and _config.yml and emits the meta tags, canonical link, Open Graph tags, Twitter Card tags, and a JSON-LD WebSite or WebPage block. Installation:
# Gemfile
source "https://rubygems.org"
gem "jekyll", "~> 4.3"
gem "jekyll-seo-tag", "~> 2.8"
gem "jekyll-sitemap", "~> 1.4"
gem "jekyll-feed", "~> 0.17"
gem "kramdown-parser-gfm"
Configuration in _config.yml:
title: "Site Title"
description: "Site description for default meta and schema."
url: "https://example.com"
baseurl: ""
author:
name: "Joseph W. Anady"
email: "joseph.w.anady@gmail.com"
url: "https://example.com"
social:
name: "Site Title"
links:
- "https://github.com/example"
- "https://www.linkedin.com/in/example"
logo: "/assets/img/logo.png"
twitter:
username: "example"
card: "summary_large_image"
plugins:
- jekyll-seo-tag
- jekyll-sitemap
- jekyll-feed
In the layout, place {% seo %} in the head:
<head>
<meta charset="utf-8">
{% seo %}
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}">
</head>
This single tag emits, depending on context:
<title>combiningpage.titleandsite.title<meta name="description"><meta name="generator"><link rel="canonical">- Open Graph type, title, description, url, site_name, image
- Twitter Card type, title, description, image, site (username), creator
- JSON-LD WebSite block at site root, WebPage block on inner pages
- Article extension when
page.layoutispost
The plugin reads page.title, page.description, page.image, page.author, page.canonical_url, page.date, page.last_modified_at, and a few less common fields. It also reads site.title, site.description, site.url, site.author, site.social, site.twitter, site.logo. The behavior is documented in the plugin README.
5.2 Per-Page Frontmatter for SEO
The recommended page frontmatter for jekyll-seo-tag:
---
title: "Page Title"
description: "Page description, 140 to 160 chars."
image:
path: "/assets/img/posts/hero.jpg"
alt: "Specific alt text for the hero image."
author: "Joseph W. Anady"
canonical_url: "https://example.com/canonical-location/"
---
The image field accepts either a string (path only) or a hash with path, height, width, alt. The hash form is preferred because it gives the plugin more to emit and is forward-compatible with future schema enhancements.
5.3 Canonical URL Handling
By default jekyll-seo-tag emits a self-referential canonical based on page.url plus site.url. This is correct for most pages. Override per page via canonical_url when:
- The page is a paginated archive page that should canonicalize to a parent
- The page is a syndicated post that should canonicalize to the original
- The page is a category or tag page that should canonicalize to itself, suppressing parameter variations
Sitewide canonical override patterns are best handled via custom Liquid in a layout rather than per-page frontmatter when the rule is consistent across many pages.
5.4 The Trailing Slash Policy
Jekyll's pretty_url permalink style (the default) generates URLs ending in / for posts and pages (e.g., /about/ instead of /about.html). This is the recommended pattern. Standardize on trailing slash everywhere:
# _config.yml
permalink: /:year/:month/:day/:title/ # for posts
defaults:
- scope:
path: ""
type: "pages"
values:
permalink: "/:slug/"
Verify nginx serves the trailing-slash variant consistently:
# /etc/nginx/sites-available/[domain]
server {
listen 443 ssl;
server_name example.com;
root /var/www/sites/example.com/_site;
index index.html;
# 301 redirect non-trailing-slash to trailing-slash
rewrite ^(/[^.]+[^/])$ $1/ permanent;
# 301 redirect www to non-www
if ($host = "www.example.com") {
return 301 https://example.com$request_uri;
}
location / {
try_files $uri $uri/index.html $uri.html =404;
}
}
Note that Joseph's network canonical is non-www across all domains per the May 2026 standardization (see project_network_canonical.md in MEMORY).
5.5 The Robots and Indexability Defaults
Set sensible robots defaults at the layout level:
{% if page.noindex or layout.noindex %}
<meta name="robots" content="noindex, nofollow">
{% else %}
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
{% endif %}
Pages that should be noindex by default: tag and category archive pages on small sites where they thin out content; pagination pages beyond page 1; draft preview pages.
6. Schema Implementation
Jekyll's schema posture has two layers. The first is what jekyll-seo-tag generates automatically. The second is what you add via includes or via frontmatter-driven JSON-LD blocks.
6.1 What jekyll-seo-tag Generates Automatically
At the site root (/), jekyll-seo-tag emits a WebSite JSON-LD block. On inner pages, it emits a WebPage block. When page.layout == 'post', it extends to BlogPosting with headline, datePublished, dateModified, image, and author. This is sufficient for many small blogs and removes the need for manual schema work.
What it does not emit: Organization (separately from WebSite), Article (vs. BlogPosting), Person beyond author name, FAQPage, HowTo, Product, Recipe, LocalBusiness, BreadcrumbList. For these you need manual injection.
6.2 The Manual Schema Injection Pattern
The recommended pattern is one include per schema type, plus a frontmatter convention that activates the include conditionally:
{%- comment -%} _includes/head/schema.html {%- endcomment -%}
{%- comment -%} Organization on every page {%- endcomment -%}
{% include schema/organization.html %}
{%- comment -%} BreadcrumbList on inner pages with explicit breadcrumb data {%- endcomment -%}
{% if page.breadcrumbs %}
{% include schema/breadcrumb.html %}
{% endif %}
{%- comment -%} Article on posts, with a richer Article over the seo-tag BlogPosting default {%- endcomment -%}
{% if page.layout == 'post' and page.schema_type == 'Article' %}
{% include schema/article-full.html %}
{% endif %}
{%- comment -%} FAQPage when frontmatter declares faq {%- endcomment -%}
{% if page.faq %}
{% include schema/faq.html %}
{% endif %}
{%- comment -%} Person on team-member pages {%- endcomment -%}
{% if page.layout == 'team-member' %}
{% include schema/person.html %}
{% endif %}
{%- comment -%} LocalBusiness on the contact page only {%- endcomment -%}
{% if page.layout == 'contact' or page.is_contact %}
{% include schema/local-business.html %}
{% endif %}
6.3 The BreadcrumbList Include
{%- comment -%} _includes/schema/breadcrumb.html {%- endcomment -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{% for crumb in page.breadcrumbs -%}
{
"@type": "ListItem",
"position": {{ forloop.index }},
"name": {{ crumb.name | jsonify }},
"item": "{{ crumb.url | absolute_url }}"
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
}
</script>
Page frontmatter to feed the include:
---
title: "Article Title"
breadcrumbs:
- name: "Home"
url: "/"
- name: "Blog"
url: "/blog/"
- name: "Migration"
url: "/blog/category/migration/"
- name: "Article Title"
url: "/blog/2026/03/15/wp-to-jekyll/"
---
6.4 The FAQPage Include
The _includes/schema/faq.html reads page.faq (a list of question/answer pairs in frontmatter), iterates with {% for item in page.faq %}, and outputs FAQPage JSON-LD with mainEntity array of Question/Answer nodes. Use | strip_html | strip_newlines | jsonify filters on the answer text. The include lives in head when {% if page.faq %}.
6.5 The Person Include for Author Pages
The _includes/schema/person.html outputs Person JSON-LD when page.layout == 'team-member' or 'author', with name, jobTitle, worksFor referencing site Organization, url, image, sameAs array of profile URLs, and description from page.bio. Frontmatter on each author page supplies the values.
6.6 The LocalBusiness Include for Contact Pages
The _includes/schema/local-business.html outputs LocalBusiness JSON-LD with name, telephone, email, address (PostalAddress with street/city/state/postal/country), geo (GeoCoordinates), and openingHoursSpecification array. The values come from _config.yml under a business: key so they live once and render anywhere the include is invoked.
Cross-reference framework-schema.md for the full schema reference and framework-localseo.md for the LocalBusiness pattern across platforms.
7. Performance Profile
Jekyll produces clean static HTML at build time. The runtime performance characteristics are excellent for the same reasons that apply to all SSGs: pre-rendered HTML, no server-side computation at request time, minimal JavaScript, cacheable forever at the edge. The build-time performance characteristics are less excellent than Hugo or 11ty but acceptable for most blog and documentation sites.
7.1 The Build Speed Reality
Jekyll's Ruby implementation makes it slower than Hugo (Go) by approximately 10 to 30 times and slower than 11ty (Node.js) by approximately 2 to 5 times on equivalent content. Benchmarks from CSS-Tricks 2025 (sample 12 sites, 50 to 5,000 posts each) show:
build_speed_comparison_2025:
test_site_500_posts:
hugo: "0.8 seconds"
eleventy_11ty: "3.5 seconds"
jekyll: "12 seconds"
astro: "8 seconds"
test_site_2000_posts:
hugo: "2.4 seconds"
eleventy_11ty: "11 seconds"
jekyll: "48 seconds"
astro: "28 seconds"
test_site_5000_posts:
hugo: "5.1 seconds"
eleventy_11ty: "26 seconds"
jekyll: "147 seconds"
astro: "72 seconds"
For sites under 1,000 posts, Jekyll's build time is acceptable (under 30 seconds). For sites between 1,000 and 5,000 posts, builds become uncomfortable (30 seconds to 2 minutes) and incremental builds become important. For sites above 5,000 posts, Jekyll is no longer the recommended choice; migrate to Hugo or 11ty.
7.2 Incremental Builds
Jekyll 4.x supports incremental builds via the --incremental flag. Enable in _config.yml:
incremental: true
Or run on the command line:
bundle exec jekyll build --incremental
bundle exec jekyll serve --incremental
Incremental builds skip regeneration of unchanged pages. On a 1,000-post site, a typical content edit triggers regeneration of approximately 5 to 20 pages instead of 1,000, dropping build time from 30 seconds to under one second. The caveat is that incremental builds do not always detect every dependency (e.g., Liquid includes that compute aggregate values across all posts). The recommended pattern is incremental during authoring, full build for production deployment.
7.3 Lighthouse Targets
A correctly configured Jekyll site reliably hits Lighthouse mobile 95+ and desktop 99+ across Performance, Accessibility, Best Practices, and SEO. The drivers:
- Pre-rendered HTML at every URL (no JavaScript dependency for first paint)
- No client-side rendering
- Minimal JavaScript (whatever you opt into)
- Self-hosted assets with appropriate cache headers
- Responsive images via
jekyll-picture-tagor manual<picture>markup - nginx serving with HTTP/2, Brotli, and aggressive caching
The performance budget for a representative Jekyll blog post:
performance_budget_jekyll_blog_post:
total_page_weight: "under 250 KB"
html_only: "under 30 KB"
css_total: "under 40 KB"
fonts: "under 100 KB (subset where possible)"
hero_image_avif: "under 80 KB"
javascript: "under 30 KB total, deferred or async"
third_party_scripts: "zero by default; analytics only if needed"
Cross-reference framework-pageexperience.md for Core Web Vitals targets. Cross-reference framework-imageseo.md for the image optimization pattern.
7.4 Resource Consumption
Jekyll builds are CPU-bound and modestly memory-bound. A 200-post site typically peaks at 200 to 400 MB of RAM during a full build. A 2,000-post site typically peaks at 800 MB to 1.5 GB. Self-hosted Jekyll builds run comfortably on Bubbles (16 GB RAM) or any modern VPS with at least 2 GB RAM.
7.5 nginx Configuration for Jekyll Output
The nginx server block points root at /var/www/sites/example.com/_site and index index.html. Enable gzip + brotli for text/css/js/xml/json/svg. Static assets (jpg, png, webp, avif, svg, woff2, css, js) cache one year with Cache-Control: public, immutable. HTML caches one hour with must-revalidate. rewrite ^(/[^.]+[^/])$ $1/ permanent; enforces trailing slash. try_files $uri $uri/index.html $uri.html =404; resolves pretty URLs. Security headers: HSTS, X-Content-Type-Options nosniff, X-Frame-Options SAMEORIGIN, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy interest-cohort=(). Port 80 server block 301-redirects to HTTPS. The www server block 301-redirects to non-www per network canonical. No third-party CDN or proxy.
8. URL Structure and Routing
Jekyll's URL structure is controlled by a combination of global permalink config, per-collection permalink config, and per-page permalink frontmatter. The interaction between these layers is the source of most URL-related Jekyll confusion.
8.1 The Permalink Resolution Order
permalink_resolution_order:
1: "page.permalink frontmatter (highest precedence)"
2: "collection-level permalink in _config.yml under collections.<name>.permalink"
3: "site-level permalink in _config.yml"
4: "Jekyll's default ('/:categories/:year/:month/:day/:title:output_ext')"
8.2 The Default Permalink Patterns
permalink_named_styles:
date: "/:categories/:year/:month/:day/:title:output_ext"
pretty: "/:categories/:year/:month/:day/:title/"
ordinal: "/:categories/:year/:y_day/:title:output_ext"
weekdate: "/:categories/:year/W:week/:short_day/:title:output_ext"
none: "/:categories/:title:output_ext"
In _config.yml:
permalink: pretty
Or a custom pattern:
permalink: /blog/:year/:month/:title/
8.3 Available Permalink Variables
:year, :short_year, :month, :i_month, :short_month, :long_month, :day, :i_day, :y_day, :w_year, :week, :w_day, :short_day, :long_day, :hour, :minute, :second, :title (URL-safe), :slug (alias for title), :categories (joined by /).
8.4 The Collection Permalink Pattern
For collections like docs, projects, case studies:
# _config.yml
collections:
docs:
output: true
permalink: /docs/:path/
projects:
output: true
permalink: /projects/:slug/
case_studies:
output: true
permalink: /case-studies/:slug/
A doc file at _docs/intro/getting-started.md becomes /docs/intro/getting-started/. A project at _projects/local-living.md becomes /projects/local-living/.
8.5 The Canonical URL Strategy
Jekyll's pretty permalinks produce canonical URLs with trailing slashes by default. The canonical strategy:
- Self-referential canonical via jekyll-seo-tag (automatic) or via the layout include shown in Section 4.2
- Trailing slash everywhere via the permalink config plus nginx redirect
- Non-www canonical per Joseph's network standard
- HTTPS canonical via nginx redirect from port 80 to 443
- Explicit
canonical_urlfrontmatter only for syndicated, paginated, or duplicate pages
Cross-reference framework-technicalseo.md for the canonical pattern at the technical SEO baseline level.
8.6 Categories and Tags as Routing
By default Jekyll does not generate dedicated pages for categories or tags. The jekyll-paginate-v2 plugin generates archive pages. The jekyll-archives plugin is the dedicated solution. Configuration:
# _config.yml
plugins:
- jekyll-archives
jekyll-archives:
enabled:
- categories
- tags
layouts:
category: category-archive
tag: tag-archive
permalinks:
category: /category/:name/
tag: /tag/:name/
The layouts category-archive.html and tag-archive.html need to exist in _layouts/.
The SEO consideration for category and tag archives: if your tag taxonomy is bloated (every post has 10 tags, each tag has 2 posts), the tag pages thin out content and create indexable noise. The pattern is either to noindex tag pages or to consolidate tags so each has a meaningful number of posts (5+). Category pages are usually more useful and worth indexing.
9. The jekyll-feed and jekyll-sitemap Plugins
These two plugins together with jekyll-seo-tag form the core SEO trio for Jekyll. Both run at build time, emit static files, and require minimal configuration.
9.1 The jekyll-feed Plugin
Generates feed.xml (Atom 1.0) at the site root. Installation:
# Gemfile
gem "jekyll-feed", "~> 0.17"
Configuration in _config.yml:
plugins:
- jekyll-feed
feed:
posts_limit: 50
excerpt_only: false
path: /feed.xml
collections:
docs:
path: /docs/feed.xml
categories:
- documentation
The plugin emits a sitewide feed at /feed.xml by default. The above configuration adds a per-collection feed at /docs/feed.xml. The posts_limit defaults to 10 and can be raised to 50 or so for active publishers; aggregators and RSS readers benefit from longer history.
In the layout head:
<link rel="alternate" type="application/atom+xml" title="{{ site.title }}" href="{{ '/feed.xml' | absolute_url }}">
The feed includes title, link, updated, id, author, and per-entry title, link, published, updated, id, content, author, summary, categories. It is well-formed Atom that aggregators accept.
9.2 The jekyll-sitemap Plugin
Generates sitemap.xml at the site root. Installation:
# Gemfile
gem "jekyll-sitemap", "~> 1.4"
Configuration in _config.yml:
plugins:
- jekyll-sitemap
That is the entire configuration. The plugin emits a sitemap that includes every page, post, and collection document that has output: true and that is not explicitly excluded via sitemap: false frontmatter.
Per-page exclusion:
---
title: "Thank You Page"
sitemap: false
---
The plugin reads page.last_modified_at if present, otherwise page.date, otherwise the file mtime. It does not emit changefreq or priority by default (Google has confirmed it ignores those signals). The pattern is the minimal, correct sitemap.
For a manual override layout when you need more control:
{%- comment -%} sitemap.xml in source root, with frontmatter --- layout: null permalink: /sitemap.xml --- {%- endcomment -%}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for page in site.pages %}
{% if page.sitemap != false and page.url contains '.html' %}
<url>
<loc>{{ page.url | absolute_url }}</loc>
<lastmod>{{ page.last_modified_at | default: site.time | date_to_xmlschema }}</lastmod>
</url>
{% endif %}
{% endfor %}
{% for post in site.posts %}
{% if post.sitemap != false %}
<url>
<loc>{{ post.url | absolute_url }}</loc>
<lastmod>{{ post.last_modified_at | default: post.date | date_to_xmlschema }}</lastmod>
</url>
{% endif %}
{% endfor %}
</urlset>
Use the plugin unless you have a specific reason to override.
9.3 Integration With jekyll-seo-tag
The three plugins do not conflict. jekyll-seo-tag does not generate a sitemap or a feed. jekyll-sitemap and jekyll-feed do not generate meta tags. Each handles its own surface:
jekyll_seo_plugin_responsibilities:
jekyll-seo-tag:
emits:
- "title, description, canonical"
- "OG and Twitter Card meta tags"
- "JSON-LD WebSite, WebPage, BlogPosting"
reads:
- "site.title, site.description, site.url, site.author, site.social"
- "page.title, page.description, page.image, page.author, page.canonical_url"
jekyll-sitemap:
emits:
- "/sitemap.xml"
reads:
- "site.pages, site.posts, site.collections (with output: true)"
- "page.last_modified_at, page.date, page.sitemap"
jekyll-feed:
emits:
- "/feed.xml"
- "Optional per-collection feeds"
reads:
- "site.posts"
- "site.title, site.description, site.url, site.author"
The robots.txt at the site root should reference the sitemap:
# robots.txt at /var/www/sites/[domain]/src/robots.txt
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap.xml
Cross-reference framework-technicalseo.md for the sitemap and robots.txt baselines.
10. Image Handling
Jekyll's image handling is a weak spot relative to 11ty and Astro, which ship image optimization in core or via blessed plugins. Jekyll's options:
10.1 Manual Responsive Images
The reliable pattern is hand-authored <picture> markup with two <source> blocks (AVIF and WebP) plus a JPEG <img> fallback. Each srcset lists pre-processed variants at 480w/768w/1200w/1920w. The sizes attribute matches the layout (e.g., "(max-width: 768px) 100vw, 1200px"). The <img> carries alt, width, height, loading="lazy", decoding="async".
Wrap the boilerplate in _includes/responsive-image.html that takes basename, alt, sizes parameters and emits the same picture/source/source/img stack. Usage: {% include responsive-image.html basename="hero" alt="Alt text" sizes="(max-width: 768px) 100vw, 1200px" %}.
10.2 The jekyll-picture-tag Plugin
For automated responsive image generation at build time, jekyll-picture-tag is the active plugin (the older jekyll-responsive-image is unmaintained). It uses ImageMagick under the hood and generates AVIF, WebP, and JPEG variants in configured sizes.
Installation:
# Gemfile
gem "jekyll-picture-tag", "~> 2.0"
Configuration in _config.yml:
plugins:
- jekyll-picture-tag
picture:
source: "_originals/img"
output: "assets/img/generated"
markup: "picture"
media_presets:
sm: "(max-width: 640px)"
md: "(max-width: 1024px)"
presets:
default:
formats: [avif, webp, original]
widths: [480, 768, 1200, 1920]
sizes:
sm: "100vw"
md: "768px"
default: "1200px"
attributes:
img: 'loading="lazy" decoding="async"'
Page usage:
{% picture hero.jpg --alt "Alt text" %}
The plugin emits the full <picture> block with AVIF, WebP, and JPEG variants in four widths. Build time increases noticeably; expect 2 to 5 seconds per fresh image, fast on subsequent builds because the plugin caches.
10.3 The jekyll-imagemagick Plugin
For more aggressive offline preprocessing without runtime plugin overhead, the workflow is: run an external ImageMagick or sharp pipeline before jekyll build, write resized variants into /assets/img/, then jekyll build simply copies them through. This pattern works well when image processing is heavy and you do not want to recompute on every site build.
Cross-reference framework-imageseo.md for the complete image SEO and optimization framework.
10.4 Alt Text Discipline
Every <img> needs an alt attribute. The pattern is to require alt text via frontmatter for hero images and via a Liquid include parameter for inline images. Jekyll does not enforce this; the convention is yours to maintain.
Cross-reference framework-accessibility.md for WCAG 2.2 alt text patterns.
11. Internationalization
Jekyll's i18n posture is weaker than 11ty, Hugo, or Astro because the core does not include first-class locale handling. The community has produced two main plugins, neither of which has the maturity of Hugo's i18n or Astro's content collection locales.
11.1 The jekyll-multiple-languages-plugin
The most widely used i18n plugin. Installation:
# Gemfile
gem "jekyll-multiple-languages-plugin", "~> 1.8"
Configuration in _config.yml:
plugins:
- jekyll-multiple-languages-plugin
languages: ["en", "es", "fr"]
default_lang: "en"
exclude_from_localization: ["assets", "robots.txt", "favicon.ico"]
Per-locale translation files in _i18n/en.yml, _i18n/es.yml, _i18n/fr.yml as nested YAML (nav, hero, etc.). Layouts call {% t key.subkey %} to render the active locale. The plugin builds a separate site per locale into subdirectories: _site/, _site/es/, _site/fr/. The default locale lives at root.
11.2 The Per-Locale Directory Pattern
A simpler pattern that avoids plugins entirely: use Jekyll collections, one per locale, with permalinks rooted at the locale:
# _config.yml
collections:
pages_en:
output: true
permalink: /:slug/
pages_es:
output: true
permalink: /es/:slug/
pages_fr:
output: true
permalink: /fr/:slug/
Pages live in _pages_en/, _pages_es/, _pages_fr/. This avoids plugin dependency and gives full control, at the cost of duplicating layout and include code per locale.
11.3 hreflang Generation
For either approach, hreflang tags need to be emitted in the head. The recommended pattern with the multiple-languages plugin:
{%- for lang in site.languages -%}
{%- assign hreflang_url = page.url | replace_first: "/" | prepend: "/" -%}
{%- if lang != site.default_lang -%}
{%- assign hreflang_url = hreflang_url | prepend: lang | prepend: "/" -%}
{%- endif -%}
<link rel="alternate" hreflang="{{ lang }}" href="{{ hreflang_url | absolute_url }}">
{%- endfor -%}
<link rel="alternate" hreflang="x-default" href="{{ page.url | absolute_url }}">
The simpler per-locale-directory pattern needs a per-page translations frontmatter:
---
title: "About Us"
permalink: /about/
lang: en
translations:
es: /es/sobre-nosotros/
fr: /fr/a-propos/
---
Then in the layout:
<link rel="alternate" hreflang="{{ page.lang }}" href="{{ page.url | absolute_url }}">
{% for translation in page.translations %}
<link rel="alternate" hreflang="{{ translation[0] }}" href="{{ translation[1] | absolute_url }}">
{% endfor %}
<link rel="alternate" hreflang="x-default" href="{{ '/' | append: page.permalink | absolute_url }}">
Cross-reference framework-international.md for the strategic international SEO framework. Cross-reference framework-hreflang.md for the comprehensive hreflang technical specification.
11.4 The Multilingual Sitemap
The jekyll-sitemap plugin does not emit alternate language links by default. For a multilingual sitemap, override sitemap.xml at the source root with layout: null and permalink: /sitemap.xml frontmatter, then iterate site.pages, emit <loc>, <lastmod>, and for each translation in page.translations emit <xhtml:link rel="alternate" hreflang="..." href="..."/> within the <url>.
12. GitHub Pages vs Self-Hosted
12.1 GitHub Pages Constraints
GitHub Pages pins Jekyll to the github-pages gem version (currently 3.10.0; Jekyll 4 is not on stock Pages). The plugin whitelist allows jekyll-feed, jekyll-seo-tag, jekyll-sitemap, jekyll-redirect-from, jekyll-paginate, jekyll-relative-links, jemoji, rouge, and a handful of others. Custom Liquid plugins in _plugins/ do not execute. Limits: 1 GB repo, 1 GB site, 100 GB/month bandwidth, 10 builds/hour. HTTPS is mandatory; CNAME file in repo root for custom domain; GitHub-managed Let's Encrypt SSL.
The constraint that bites SEO work most often is the plugin whitelist. jekyll-picture-tag, jekyll-multiple-languages-plugin, jekyll-archives, and custom Liquid filters all fail on stock Pages.
12.2 The External Build Workaround
The standard workaround is a GitHub Actions workflow that runs full Jekyll 4.x with arbitrary plugins, builds _site, and uploads the built directory via actions/upload-pages-artifact@v3 and actions/deploy-pages@v4. Plugin whitelist no longer applies because Pages serves the pre-built output. This is the modern compromise that most serious Jekyll users on GitHub Pages adopt in 2026.
12.3 The Self-Hosted Advantages
Plugin freedom (any gem on RubyGems, any custom Liquid filter), Jekyll version freedom (4.3.x latest), Ruby version per project via rbenv, arbitrary _data/ files, custom pre/post build hooks, custom nginx caching and headers, no size limits, Let's Encrypt with HSTS preload, no GitHub uptime dependency. For Joseph's pattern, self-hosted on Bubbles at IP 169.155.162.118 is the default for non-trivial Jekyll sites.
12.4 The Decision Matrix
| Use GitHub Pages | Use External Build → Pages | Use Self-Hosted |
|---|---|---|
| Personal dev blog, low traffic | Want plugin freedom + free hosting | Custom Liquid filters needed |
| Open source docs site | Comfortable with GitHub Actions | Multilingual via custom plugin |
| Whitelist-only plugins | GitHub-managed SSL is acceptable | Custom CSP / security headers |
| No budget for hosting | Traffic >100 GB/month | |
| GitHub uptime acceptable | Existing self-hosted infra |
12.5 The github-pages Gem Version Pinning
For stock GitHub Pages (not external build), pin gem "github-pages", "~> 232", group: :jekyll_plugins in the Gemfile so local bundle exec jekyll serve matches the GitHub build environment.
13. Migration to and from Jekyll
Jekyll migrations are common in two directions: WordPress sites moving to Jekyll for cost and simplicity, and Jekyll sites moving to 11ty or Hugo for build speed and ecosystem freshness. The patterns differ by direction.
13.1 WordPress to Jekyll
The jekyll-import gem provides a WordPress importer. The Gemfile pulls jekyll-import, hpricot, reverse_markdown, sequel, and mysql2. Invoke JekyllImport::Importers::WordPress.run(...) with the WP database connection params, clean_entities: true, and extension: "md". The importer writes one Markdown file per post into _posts/ with title/date/author/categories/tags frontmatter and the body converted from WP HTML via reverse_markdown.
Follow-up: review post bodies (Markdown conversion is imperfect for complex HTML), map old WP URLs to new Jekyll URLs and write nginx 301s, reprocess images into /assets/img/posts/, rebuild taxonomies if consolidating. To preserve the WP URL pattern exactly use permalink: /:year/:month/:title/.
13.2 Jekyll to 11ty
The 11ty eleventy-base-blog template ships with a Jekyll-compatible directory structure. Migration: replace Liquid with Nunjucks (or keep Liquid since 11ty supports both), swap Jekyll plugins for 11ty equivalents (eleventy-plugin-rss, @11ty/eleventy-plugin-sitemap), replace _config.yml with .eleventy.js, replace Gemfile with package.json. URL patterns translate cleanly via permalink frontmatter. Cross-reference framework-11ty.md.
13.3 Jekyll to Hugo
hugo import jekyll /path/to/jekyll/source /path/to/hugo/destination translates _posts/ to content/posts/ and _config.yml to config.toml. Layouts and includes need manual translation from Liquid to Go templates (Hugo does not support Liquid); this is the bulk of the work.
13.4 Jekyll to Next.js MDX
Jekyll Markdown posts move directly into Next.js App Router app/blog/[slug]/page.mdx. Frontmatter parses via gray-matter. Liquid does not translate; Next.js needs MDX components for interactive content. Cross-reference framework-nextjs.md.
13.5 The Redirect Map
Every Jekyll migration that changes URLs needs an nginx redirect map. Use the map $request_uri $jekyll_redirect { default ""; ~^/old-pattern/?$ /new-pattern/; } directive plus if ($jekyll_redirect != "") { return 301 $jekyll_redirect; } in the server block. For >1,000 redirects the map directive scales better than per-rule if blocks. Cross-reference framework-migration.md.
13.6 The Migration Risk Matrix
| Risk | Severity | Mitigation |
|---|---|---|
| URL change unmanaged | high | 1:1 URL mapping + 301 redirects |
| Content loss in MD conversion | medium | Manual review of every post |
| Taxonomy inconsistency | medium | Audit tag pages before/after, redirect orphans |
| Feed URL change | low | Keep old feed URL with 301 |
| Schema regression | medium | Validate schema on every page type post-migration |
| Sitemap gap | low | Resubmit to GSC and Bing immediately |
14. Bubbles-Hosted Jekyll
The recommended self-hosted pattern for TDG client Jekyll sites runs on Bubbles, the Debian server at LAN 192.168.1.173 with public IP 169.155.162.118. No third-party CDN or proxy. nginx serves the built _site/ directly.
14.1 The Server Layout
bubbles_jekyll_server_layout:
ip: "169.155.162.118"
os: "Debian 12 amd64"
ruby_install: "rbenv installed system-wide at /usr/local/rbenv"
ruby_versions_installed: ["3.2.4", "3.3.6", "3.4.1"]
default_ruby: "3.3.6 via /usr/local/rbenv/version"
nginx: "1.24 from Debian backports"
ssl: "Let's Encrypt via certbot, auto-renew via systemd timer"
site_root: "/var/www/sites/[domain]/"
site_layout:
src: "/var/www/sites/[domain]/src/"
site: "/var/www/sites/[domain]/_site/"
served: "/var/www/sites/[domain]/_site/"
backups: "/var/www/sites/[domain]/backups/"
logs: "/var/log/nginx/[domain].access.log, .error.log"
14.2 The Initial Site Setup
# Create site directory and clone source
sudo mkdir -p /var/www/sites/example.com
sudo chown -R user:user /var/www/sites/example.com
cd /var/www/sites/example.com
git clone git@github.com:user/example-com.git src
cd src
# Pin Ruby version
echo "3.3.6" > .ruby-version
rbenv local 3.3.6
# Install bundler and gems
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
# Initial build
bundle exec jekyll build --destination /var/www/sites/example.com/_site
# Confirm output
ls /var/www/sites/example.com/_site
# Set permissions for nginx
sudo chown -R user:www-data /var/www/sites/example.com/_site
sudo chmod -R 755 /var/www/sites/example.com/_site
14.3 The Deploy Script
#!/usr/bin/env bash
# /usr/local/bin/deploy-jekyll.sh
# Usage: deploy-jekyll.sh example.com
set -euo pipefail
DOMAIN="${1:?domain required}"
SITE_DIR="/var/www/sites/${DOMAIN}"
SRC_DIR="${SITE_DIR}/src"
SITE_OUT="${SITE_DIR}/_site"
BACKUP_DIR="${SITE_DIR}/backups"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
cd "${SRC_DIR}"
# Pull latest source
git fetch --all
git checkout main
git pull --rebase
# Install or update gems
bundle install
# Build to a temporary destination
TMP_OUT="/tmp/jekyll-build-${DOMAIN}-${TIMESTAMP}"
JEKYLL_ENV=production bundle exec jekyll build --destination "${TMP_OUT}"
# Verify the build produced output
if [ ! -f "${TMP_OUT}/index.html" ]; then
echo "ERROR: build failed, no index.html in ${TMP_OUT}"
exit 1
fi
# Backup the current site
if [ -d "${SITE_OUT}" ]; then
mkdir -p "${BACKUP_DIR}"
mv "${SITE_OUT}" "${BACKUP_DIR}/_site-${TIMESTAMP}"
fi
# Move new site into place
mv "${TMP_OUT}" "${SITE_OUT}"
# Permissions
chown -R user:www-data "${SITE_OUT}"
chmod -R 755 "${SITE_OUT}"
# Reload nginx (cached assets may need invalidation)
sudo nginx -t && sudo systemctl reload nginx
# Trim backups, keep last 7
cd "${BACKUP_DIR}"
ls -t | tail -n +8 | xargs -r rm -rf
echo "Deployed ${DOMAIN} at ${TIMESTAMP}"
14.4 Auto-Deploy
For a Bubbles-hosted source repo, a git post-receive hook calls deploy-jekyll.sh on push to main. For a GitHub-hosted source, a small webhook receiver on Bubbles (the webhook tool with HMAC-SHA256 verification) invokes the same script. Either way the deploy script is the entry point.
14.5 The nginx Site Configuration
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/sites/example.com/_site;
index index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log warn;
# Headers, caching, redirects per Section 7.5
}
14.6 Backup and Recovery
Source repo: daily snapshot to /mnt/storage. Built site: rolling 7 backups in /var/www/sites/[domain]/backups/ from the deploy script. Weekly tarball of /var/www/sites/[domain]/ to /mnt/storage. nginx config tracked in /etc/.git weekly. Quarterly recovery test rebuilds from /mnt/storage source to a staging path and verifies HTML output matches production.
14.7 No Third-Party CDN or Proxy
nginx at IP 169.155.162.118 serves traffic directly. SSL, compression, HTTP/2, caching all at nginx. Strengths: no third-party data exposure, no billing dependency, no rate limits, full control over caching. Constraints: geographic latency to distant clients, no built-in DDoS mitigation beyond nginx and host network. For most TDG client Jekyll sites under 100K page views per month the constraint set is acceptable.
End of Framework
This framework documents the Jekyll SEO surface as of 2026: project layout, Ruby and Bundler discipline, the layout and include system, the jekyll-seo-tag, jekyll-sitemap, and jekyll-feed core trio, manual schema injection patterns, the performance profile, URL structure and routing, image handling, internationalization, the GitHub Pages versus self-hosted decision, migration to and from Jekyll, and the Bubbles-hosted production pattern.
Jekyll's SEO posture in 2026 is conservative and competent. The platform does the boring SEO basics correctly with minimal configuration: clean static HTML, predictable URLs, automatic sitemap and feed, automatic meta tags and basic JSON-LD. The platform is not the fashionable choice and not the highest-velocity choice, but it remains the right choice for a specific audience: developers and technical writers who already live in Ruby and git, who value stability over feature velocity, and who want the GitHub Pages free hosting niche or a self-hosted setup that does not change every twelve months.
The constraints are real. Build speed lags Hugo and 11ty. The plugin ecosystem is contracting. GitHub Pages' plugin whitelist forces external build pipelines for any meaningful customization. Image handling requires either manual <picture> markup or the jekyll-picture-tag plugin with its ImageMagick dependency. Internationalization is workable but second-class.
The strengths are also real. The blog-aware conventions remain unmatched for blog-shaped content. Liquid is a forgiving, well-documented templating language. The file-based content model fits git naturally. The build output is predictable static HTML that nginx serves at speed with zero runtime dependency. The Bubbles-hosted pattern in Section 14 produces a Jekyll site that is fast, secure, self-contained, and free of third-party CDN or proxy entanglement.
Cross-references:
- framework-cross-stack-implementation.md for the same SEO patterns applied across plain HTML, React, Next.js, Vue, Nuxt, Astro, Hugo, 11ty, WordPress, Shopify, and Webflow.
- framework-schema.md for the comprehensive schema.org JSON-LD implementation reference.
- framework-hreflang.md for the hreflang technical specification.
- framework-international.md for the strategic international SEO framework.
- framework-migration.md for the platform migration playbook including URL inventory, redirect mapping, GSC handling, and post-migration monitoring.
- framework-pageexperience.md for Core Web Vitals targets and the LCP, INP, CLS rubric.
- framework-technicalseo.md for the technical SEO baseline including robots, sitemaps, canonical, redirects, and crawl control.
- framework-mobileseo.md for mobile-first indexing and responsive design verification.
- framework-accessibility.md for WCAG 2.2 compliance patterns that intersect with SEO.
- framework-aicitations.md for AI overview citation patterns and content surface optimization for LLM-based search.
- framework-aioverviews.md for Google AI Overviews and structured content patterns that increase citation likelihood.
- framework-imageseo.md for the complete image SEO and optimization framework.
- framework-headless.md for the headless architecture decision tree, relevant when Jekyll is paired with a separate frontend.
Jekyll is the SSG that does not need to change. It is the stable, mature, conservative choice that serves a specific audience well. Joseph's Bubbles infrastructure pattern handles Jekyll cleanly with no third-party CDN or proxy, full Ruby control via rbenv, automated builds via deploy script or webhook, and the same nginx posture that serves every other static site on the network.
Want this framework implemented on your site?
ThatDevPro ships these frameworks as productized services. SDVOSB-certified veteran owned. Cassville, Missouri.
See Engine Optimization service ›