GitHub Actions CI/CD for Static Sites (Jekyll & Vite) — Production Pipeline
Pipeline stages (arjankc.com.np)
- Checkout repo + submodules (optional skip flags)
-
npm ci+ Jekyll build for blog -
deploy.sh— rsync static root, prune forbidden artifacts - Sitemap generation (
tools/build/generate-sitemap.js) - Upload
dist/to GitHub Pages
This is not a theoretical pipeline — it is what runs on every push to main for arjankc.com.np. At Gurkha Technology we use the same pattern for client static sites, documentation hubs, and hybrid repos where marketing pages live beside a Jekyll or Vite sub-app. The goal is simple: one commit, one reproducible build, zero manual FTP uploads.
Why not build on Pages alone?
This site mixes root static HTML, Jekyll blog, and Vite sub-apps (NetQuest, etc.). A single Jekyll-only build cannot produce the full dist/.
GitHub Pages’ built-in Jekyll mode works for pure Jekyll repos. It breaks down when you need:
- Root-level static HTML outside the Jekyll source folder
- Multiple Vite or React sub-apps compiled into the same output tree
- Post-build SEO scripts (sitemap, schema injection, canonical normalization)
- Artifact validation that fails the build if
scripts/or source tooling leaks into production
Our deploy.sh orchestrates rsync from the repo root, Jekyll output from /blog, compiled submodule assets, and post-processing scripts under tools/build/. Treating CI as “just run Jekyll” would ship an incomplete site.
For agencies in Nepal building client sites, the lesson is: match the pipeline to the repo structure, not the hosting marketing page. A brochure site with one Vite contact form needs a different workflow than a pure Markdown docs site.
Step-by-step: production workflow structure
Here is how we split the workflow into reliable stages:
| Stage | Purpose | Typical failure |
|---|---|---|
| Checkout | Full history + submodules | Submodule auth or stale .gitmodules
|
| Path filter | Skip submodule rebuilds when unchanged | Wrong glob in filter config |
| Node + Ruby setup | Lock versions via .nvmrc / Gemfile.lock
|
Version drift between local and CI |
| Cache restore | Jekyll + submodule build cache | Cache key too broad or too narrow |
| Lint + test | Validate tooling before expensive build | ESLint on build scripts blocks deploy |
| Build |
npm run build → deploy.sh
|
Missing env flag for skip logic |
| Validate | validate-dist-artifacts.js |
Forbidden files in dist/
|
| Upload + deploy | Pages artifact → live | Wrong artifact path |
1. Checkout and permissions
The workflow needs pages: write and id-token: write for OIDC-based Pages deployment. Use fetch-depth: 0 if your build depends on git history (ours does for some SEO tooling).
2. Conditional submodule builds
Submodule Vite apps are expensive. We use dorny/paths-filter to detect changes under submodule paths and a build cache keyed on submodule file hashes. When nothing changed and cache hits, BUILD_SKIP_SUBMODULES=1 skips recompilation. This cut average build time significantly on blog-only commits.
3. Dual runtime setup
Jekyll lives in /blog with its own Gemfile. Root-level Node handles build orchestration. Setup both explicitly:
-
actions/setup-node@v4withcache: npm -
ruby/setup-ruby@v1withworking-directory: blogandbundler-cache: true
Nepali dev teams often develop on Windows or mixed OS — lock Ruby and Node versions in repo config, not in someone’s laptop notes.
4. Pre-build validation
Before the heavy build, we run npm test on API JSON fixtures and ESLint/Prettier on tools/build/ scripts. Catching a broken sitemap generator before Jekyll runs saves 5+ minutes per failed deploy.
5. Build and post-build guards
npm run build invokes deploy.sh, which:
- Rsyncs static root HTML, assets, and legal pages
- Builds Jekyll into the unified
dist/tree - Runs submodule builds unless skipped
- Executes post-build SEO scripts (sitemap, schema, canonical fixes)
- Strips forbidden paths (
docs/,tools/, rawscripts/)
Then validate-dist-artifacts.js fails the job if production artifacts contain development-only files. We have seen agencies accidentally ship .env.example or internal PDFs — this guard is non-negotiable for client work.
Essential guards
-
validate-dist-artifacts.js— fail ifdeploy.shorscripts/leak - Strip AggregateRating from service pages post-build
- Exclude
docs/andtools/from rsync
SEO-specific post-processing
Static sites do not get a CMS early warning system. Broken canonicals and duplicate sitemap entries accumulate silently. Our pipeline runs:
-
generate-sitemap.js— unified sitemap for root + blog + sub-apps -
normalize-seo.js/inject-schema.js— structured data consistency - AggregateRating stripping on service pages where auto-generated schema would violate Google guidelines
After deploy, IndexNow submission notifies search engines of sitemap updates. See our automated SEO pipeline architecture for the broader content automation context.
Starter workflow snippet
- run: npm ci
- run: BUILD_SKIP_SUBMODULES=1 bash deploy.sh
- run: node tools/test/validate-dist-artifacts.js
- uses: actions/upload-pages-artifact@v3
with:
path: dist
For a minimal Jekyll-only site, replace deploy.sh with cd blog && bundle exec jekyll build --destination ../dist. Keep the artifact upload pattern identical.
Full deploy job pattern
deploy:
environment:
name: github-pages
url: $
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/deploy-pages@v4
id: deployment
Enable GitHub Pages source = “GitHub Actions” in repo settings. The first deploy often fails until this is set — a common support ticket pattern for client handoffs.
Common mistakes (and what we learned)
| Mistake | Symptom | Fix |
|---|---|---|
Building on master vs main mismatch |
Push does not trigger workflow | Align branch names in on.push.branches
|
| Submodule SSH URLs in CI | Checkout fails | Use HTTPS URLs or deploy keys |
| No artifact validation | Internal paths live in production | Add dist validation script |
Caching Jekyll without key on _config.yml
|
Stale layouts after config change | Include config in cache hash |
| Skipping concurrency group | Overlapping deploys race | Use concurrency: group: pages
|
| Local-only build flags | “Works on my machine” | Document env vars in README |
Nepal-specific notes
- Power and connectivity: CI runs in the cloud — local load-shedding does not block deploys. That alone justifies GitHub Actions for Kathmandu teams.
-
Registrar DNS lag: After first Pages deploy,
.com.npnameserver or CNAME changes at register.com.np can take 24–48 hours. Plan client launch dates accordingly — see web hosting in Nepal. - Client repos on free tiers: Public client portfolio sites fit free Actions minutes. Private client repos on org free plans — monitor minute usage if you rebuild heavy frontends daily.
- Handoff documentation: Nepali SMEs rarely maintain CI config themselves. Deliver a one-page “how to publish” doc: edit content → push to main → wait for green check.
When to add preview deployments
GitHub Pages lacks Netlify-style branch previews out of the box. Options:
- Separate workflow on
pull_requestuploading artifact without deploy (download ZIP for QA) - Cloudflare Pages or Netlify for preview, GitHub Pages for production
- Playwright visual diff in CI (heavier, but catches layout breaks)
For agency review cycles, preview URLs reduce “please check on mobile” back-and-forth. Compare platform limits in GitHub Pages vs Netlify vs Cloudflare Pages.
Monitoring and rollback
GitHub Pages keeps deployment history in the Actions tab. Rollback = revert commit and redeploy. There is no one-click “previous artifact” button like some PaaS hosts offer.
We recommend:
- Tag releases for major site overhauls (
v2026.08-redesign) - Keep
_redirectsand meta-refresh stubs in sync when URL structures change - Run Core Web Vitals checks after deploy, not before — CDN cache may delay visible changes
Cost vs WordPress maintenance
Static CI/CD front-loads complexity but reduces ongoing server patching. For TCO comparison with WordPress hosting and plugin updates, see WordPress vs static TCO. Most Nepali brochure sites under 50 pages land cheaper on static + Actions within 12 months if you already pay for managed WordPress maintenance.


