diff --git a/.github/workflows/cloudflare-pages.yml b/.github/workflows/cloudflare-pages.yml
new file mode 100644
index 0000000..57cc656
--- /dev/null
+++ b/.github/workflows/cloudflare-pages.yml
@@ -0,0 +1,38 @@
+name: Deploy to Cloudflare Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: cloudflare-pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.4'
+ bundler-cache: true
+
+ # Layers _config.cloudflare.yml on top, swapping the GitHub Pages subpath
+ # for the pages.dev root.
+ - name: Build site
+ run: bundle exec jekyll build --config _config.yml,_config.cloudflare.yml
+ env:
+ JEKYLL_ENV: production
+
+ # Project name and output directory come from wrangler.jsonc.
+ - uses: cloudflare/wrangler-action@v4
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ command: pages deploy
diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml
new file mode 100644
index 0000000..8ebea2e
--- /dev/null
+++ b/.github/workflows/github-pages.yml
@@ -0,0 +1,49 @@
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Let a running deploy finish rather than cancelling it mid-publish.
+concurrency:
+ group: github-pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.4'
+ bundler-cache: true
+
+ # Uses _config.yml only, which carries the tiennm99.github.io/penny-pincher-provider
+ # url and baseurl. actions/configure-pages is deliberately not used — it would
+ # overwrite that baseurl with its own guess.
+ - name: Build site
+ run: bundle exec jekyll build
+ env:
+ JEKYLL_ENV: production
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 160c0d4..6dd2a4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,13 @@
session-state
settings.local.json
+
+# Jekyll build output
+_site/
+.jekyll-cache/
+.jekyll-metadata
+
+# Project-local gems (bundler path config + installed gems)
+.bundle/
+
+# Wrangler local state
+.wrangler/
diff --git a/404.html b/404.html
new file mode 100644
index 0000000..fc768e6
--- /dev/null
+++ b/404.html
@@ -0,0 +1,13 @@
+---
+layout: default
+title: Page not found
+permalink: /404.html
+---
+
+
Page not found
+
+That page does not exist. The provider list lives on the
+home page.
+
+If a link here is broken,
+open an issue.
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..e4b9be6
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,10 @@
+source "https://rubygems.org"
+
+gem "jekyll", "~> 4.3"
+gem "minima", "~> 2.5"
+
+# Ruby 3.x no longer bundles these as default gems.
+gem "webrick", "~> 1.8"
+gem "csv", "~> 3.3"
+gem "base64", "~> 0.2"
+gem "bigdecimal", "~> 3.1"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..e3aafaf
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,176 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ base64 (0.3.0)
+ bigdecimal (3.3.1)
+ colorator (1.1.0)
+ concurrent-ruby (1.3.8)
+ csv (3.3.6)
+ em-websocket (0.5.3)
+ eventmachine (>= 0.12.9)
+ http_parser.rb (~> 0)
+ eventmachine (1.2.7)
+ ffi (1.17.4)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-aarch64-linux-musl)
+ ffi (1.17.4-arm-linux-gnu)
+ ffi (1.17.4-arm-linux-musl)
+ ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86-linux-gnu)
+ ffi (1.17.4-x86-linux-musl)
+ ffi (1.17.4-x86_64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-musl)
+ forwardable-extended (2.6.0)
+ google-protobuf (4.36.0)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-aarch64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-aarch64-linux-musl)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-arm64-darwin)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86-linux-musl)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86_64-darwin)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86_64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86_64-linux-musl)
+ bigdecimal
+ rake (~> 13.3)
+ http_parser.rb (0.8.1)
+ i18n (1.15.2)
+ concurrent-ruby (~> 1.0)
+ jekyll (4.4.1)
+ addressable (~> 2.4)
+ base64 (~> 0.2)
+ colorator (~> 1.0)
+ csv (~> 3.0)
+ em-websocket (~> 0.5)
+ i18n (~> 1.0)
+ jekyll-sass-converter (>= 2.0, < 4.0)
+ jekyll-watch (~> 2.0)
+ json (~> 2.6)
+ kramdown (~> 2.3, >= 2.3.1)
+ kramdown-parser-gfm (~> 1.0)
+ liquid (~> 4.0)
+ mercenary (~> 0.3, >= 0.3.6)
+ pathutil (~> 0.9)
+ rouge (>= 3.0, < 5.0)
+ safe_yaml (~> 1.0)
+ terminal-table (>= 1.8, < 4.0)
+ webrick (~> 1.7)
+ jekyll-feed (0.17.0)
+ jekyll (>= 3.7, < 5.0)
+ jekyll-sass-converter (3.1.0)
+ sass-embedded (~> 1.75)
+ jekyll-seo-tag (2.9.0)
+ jekyll (>= 3.8, < 5.0)
+ jekyll-watch (2.2.1)
+ listen (~> 3.0)
+ json (2.21.2)
+ kramdown (2.5.2)
+ rexml (>= 3.4.4)
+ kramdown-parser-gfm (1.1.0)
+ kramdown (~> 2.0)
+ liquid (4.0.4)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
+ mercenary (0.4.0)
+ minima (2.5.2)
+ jekyll (>= 3.5, < 5.0)
+ jekyll-feed (~> 0.9)
+ jekyll-seo-tag (~> 2.1)
+ pathutil (0.16.2)
+ forwardable-extended (~> 2.6)
+ public_suffix (7.0.5)
+ rake (13.4.2)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ rexml (3.4.4)
+ rouge (4.7.0)
+ safe_yaml (1.0.5)
+ sass-embedded (1.103.1)
+ google-protobuf (~> 4.31)
+ rake (>= 13)
+ sass-embedded (1.103.1-aarch64-linux-android)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-aarch64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-aarch64-linux-musl)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-arm-linux-androideabi)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-arm-linux-gnueabihf)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-arm-linux-musleabihf)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-arm64-darwin)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-riscv64-linux-android)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-riscv64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-riscv64-linux-musl)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-x86_64-darwin)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-x86_64-linux-android)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-x86_64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.1-x86_64-linux-musl)
+ google-protobuf (~> 4.31)
+ terminal-table (3.0.2)
+ unicode-display_width (>= 1.1.1, < 3)
+ unicode-display_width (2.6.0)
+ webrick (1.9.2)
+
+PLATFORMS
+ aarch64-linux-android
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-androideabi
+ arm-linux-gnu
+ arm-linux-gnueabihf
+ arm-linux-musl
+ arm-linux-musleabihf
+ arm64-darwin
+ riscv64-linux-android
+ riscv64-linux-gnu
+ riscv64-linux-musl
+ ruby
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
+ x86_64-linux-android
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ base64 (~> 0.2)
+ bigdecimal (~> 3.1)
+ csv (~> 3.3)
+ jekyll (~> 4.3)
+ minima (~> 2.5)
+ webrick (~> 1.8)
+
+BUNDLED WITH
+ 2.6.9
diff --git a/_config.cloudflare.yml b/_config.cloudflare.yml
new file mode 100644
index 0000000..e233ed7
--- /dev/null
+++ b/_config.cloudflare.yml
@@ -0,0 +1,6 @@
+# Cloudflare Pages overrides, layered on top of _config.yml:
+# bundle exec jekyll build --config _config.yml,_config.cloudflare.yml
+#
+# The site sits at the domain root here, unlike the GitHub Pages subpath.
+url: https://penny-pincher-provider.pages.dev
+baseurl: ""
diff --git a/_config.yml b/_config.yml
index 32b043f..e606f98 100644
--- a/_config.yml
+++ b/_config.yml
@@ -2,3 +2,25 @@ title: Penny-Pincher Provider
description: A curated list of affordable (or almost free) LLM providers
theme: minima
show_downloads: false
+
+# GitHub Pages target. It only ever reads this file, so its values live here and
+# Cloudflare overrides them via _config.cloudflare.yml.
+url: https://tiennm99.github.io
+baseurl: /penny-pincher-provider
+
+# README.md is the site content, pulled into index.md via include_relative.
+# Excluding it stops the raw markdown shipping as a duplicate page.
+exclude:
+ - CLAUDE.md
+ - LICENSE
+ - README.md
+ - Gemfile
+ - Gemfile.lock
+ - wrangler.jsonc
+ - _config.cloudflare.yml
+ - .claude/
+ - docs/
+ - plans/
+
+sass:
+ sourcemap: never
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..b3ba61e
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,88 @@
+# Deployment
+
+The site is a Jekyll build of `README.md` (via `index.md`), published to two hosts
+from the same source. Both deploy from GitHub Actions on every push to `main`.
+
+| Target | URL | Workflow |
+| --- | --- | --- |
+| GitHub Pages | | `.github/workflows/github-pages.yml` |
+| Cloudflare Pages | | `.github/workflows/cloudflare-pages.yml` |
+
+Both workflows build with this repo's `Gemfile` (Jekyll 4.4.1 on Ruby 3.4), so the
+two sites cannot drift apart on toolchain.
+
+## Config layering
+
+The only difference between the targets is where the site sits on its domain.
+
+| File | Role |
+| --- | --- |
+| `_config.yml` | Base config **plus GitHub Pages values** — `url: https://tiennm99.github.io`, `baseurl: /penny-pincher-provider`. |
+| `_config.cloudflare.yml` | Overrides for the pages.dev root — `baseurl: ""`. Layered on top, never used alone. |
+
+```sh
+bundle exec jekyll build # GitHub Pages
+bundle exec jekyll build --config _config.yml,_config.cloudflare.yml # Cloudflare
+```
+
+Order matters: later files win. `_config.cloudflare.yml` is excluded from the site
+output.
+
+## GitHub Pages setup
+
+One repo setting, once: **Settings → Pages → Source → GitHub Actions**. Without it
+the workflow's deploy step fails.
+
+The workflow deliberately does not use `actions/configure-pages` — that action
+rewrites `baseurl` with its own guess, which would fight the explicit value in
+`_config.yml`.
+
+## Cloudflare Pages setup
+
+The project must be **Direct Upload**, not Git-connected. A Git-connected project
+would build on every push as well, deploying twice and racing the workflow.
+
+1. Create the project once, either in the dashboard (**Workers & Pages → Create →
+ Pages → Upload assets**, named `penny-pincher-provider`) or locally:
+
+ ```sh
+ npx wrangler pages project create penny-pincher-provider --production-branch main
+ ```
+
+2. Add two repository secrets under **Settings → Secrets and variables → Actions**:
+
+ | Secret | Where to get it |
+ | --- | --- |
+ | `CLOUDFLARE_API_TOKEN` | My Profile → API Tokens → Create Token, with the **Cloudflare Pages: Edit** permission. |
+ | `CLOUDFLARE_ACCOUNT_ID` | Workers & Pages overview sidebar, or `npx wrangler whoami`. |
+
+`wrangler.jsonc` supplies the project name and `pages_build_output_dir`, so the
+workflow's `pages deploy` command needs no arguments.
+
+## Building locally
+
+Requires Ruby 3.4.x with Bundler (3.4.8 is what this project was developed
+against). Jekyll does not yet support Ruby 4.0 — do not upgrade past the 3.4 branch.
+
+```sh
+bundle config set --local path .bundle/gems # first time only
+bundle install
+JEKYLL_ENV=production bundle exec jekyll build
+```
+
+Gems install into the gitignored `.bundle/` inside the repo rather than the shared
+rbenv gem home, keeping the project self-contained. To deploy to Cloudflare by hand,
+build with the Cloudflare config and run `npx wrangler pages deploy` after
+`npx wrangler login`.
+
+## Notes
+
+- `Gemfile.lock` is committed. It was generated on aarch64 but its `PLATFORMS` list
+ includes `x86_64-linux-gnu`, which is what GitHub runners use — without that entry
+ CI installs would fail. Re-check it after any `bundle update`.
+- `_config.yml` excludes `README.md` from the output. It is still the site's
+ content, pulled into `index.md` by `include_relative`, which reads excluded files
+ fine — the exclusion only stops the raw markdown shipping as a duplicate page.
+- `404.html` is served automatically by both hosts for unmatched routes.
+- Ruby is pinned to the `3.4` series in both workflows rather than an exact patch.
+ Cloudflare's own build image is irrelevant here, since Actions does the building.
diff --git a/plans/reports/research-260828-1318-ruby-version-selection.md b/plans/reports/research-260828-1318-ruby-version-selection.md
new file mode 100644
index 0000000..1beca03
--- /dev/null
+++ b/plans/reports/research-260828-1318-ruby-version-selection.md
@@ -0,0 +1,110 @@
+# Research Report: Stable Ruby Version for 1–2 Year Horizon
+
+Conducted 2026-08-28. Context: local rbenv install + Cloudflare Workers build for this Jekyll site.
+
+## Executive Summary
+
+**Install Ruby 3.4.4.** Not 4.0, not 3.3, and not the 3.2.2 recommended earlier in this session
+(3.2 hit EOL 2026-04-01).
+
+Two constraints decide it, both external:
+
+1. **Jekyll has no Ruby 4.0 support.** Jekyll 4.4.1 (Jan 2025, still latest) declares `>= 2.7.0`
+ and recommends 3.2+. Theme gems constrained to `~> 3.1` fail version solving under 4.0.
+2. **Cloudflare's build image defaults to Ruby 3.4.4.** Pinning anything else risks a build
+ image that cannot supply it — and there is conflicting evidence that recent 3.4.x patches
+ are unavailable (see Conflict below).
+
+3.4 is the only branch in normal (non-security-only) maintenance that Jekyll actually supports.
+Picking the exact patch Cloudflare defaults to gives local/CI parity for free.
+
+## Methodology
+
+- Sources: 5 (ruby-lang.org branches page, Cloudflare Workers build-image docs, 3 web searches)
+- Date range: 2024-12 (Ruby 3.4.0) → 2026-07 (Ruby 4.0.6)
+- Terms: ruby maintenance branches EOL, ruby 3.5/4.0 stable, jekyll ruby 4.0 compatibility,
+ cloudflare workers build image RUBY_VERSION
+
+## Key Findings
+
+### 1. Ruby 3.5 does not exist — it shipped as 4.0
+
+The version after 3.4 was renumbered. Ruby 4.0.0 released 2025-12-25; latest patch 4.0.6
+(2026-07-14). Anyone searching for "Ruby 3.5 stable" finds only the April 2025 preview.
+
+### 2. Branch status as of 2026-08-28
+
+| Branch | Status | Released | EOL | Verdict |
+| --- | --- | --- | --- | --- |
+| 4.0 | Normal maintenance | 2025-12-25 | TBD (~2029-03) | Too early — Jekyll unsupported |
+| 3.4 | Normal maintenance | 2024-12-25 | TBD (~2028-03) | **Pick this** |
+| 3.3 | Security maintenance only | 2023-12-25 | 2027-03-31 | <1 yr left, no bug fixes |
+| 3.2 | **EOL** | 2022-12-25 | 2026-04-01 | Dead — no security patches |
+
+EOL dates for 3.4/4.0 are unpublished. Ruby's historical cadence is ~2 yrs normal + ~1 yr
+security, so 3.4 EOL lands ~March 2028 — comfortably past a 2-year horizon. Treat as
+convention, not commitment.
+
+### 3. Jekyll compatibility
+
+Jekyll 4.4.0 (Jan 2025) added Ruby 3.4 support; 4.4.1 is current. No Jekyll release has
+declared Ruby 4.0 support. Community reports (chirpy theme issue #2640, Jan 2026) show
+dependency resolution failing on 4.0. Ruby 3.4 moved `csv`, `base64`, `bigdecimal` to bundled
+gems — this repo's `Gemfile` already declares them explicitly, so it is 3.4-ready as written.
+
+### 4. Cloudflare build image
+
+- Default Ruby **3.4.4**; OS Ubuntu 24.04; x86_64; Node 24.18.0.
+- Override via `RUBY_VERSION` env var or `.ruby-version` file.
+- Policy: minor versions may auto-update without notice; pin to avoid drift.
+
+**Conflict:** the docs claim "all versions are available for override," but open issue
+cloudflare-docs#27779 ("Recent Ruby versions aren't yet supported") reports 3.4.5–3.4.8
+unavailable. Unresolved. Pinning 3.4.4 sidesteps it entirely — it is the default, so it is
+guaranteed present.
+
+### 5. Security
+
+3.2 is EOL: no further CVE patches. Anything still on it should move. 3.3 gets security fixes
+only until 2027-03-31 — inside the requested horizon, so it fails the 1–2 year test. 3.4 is the
+oldest branch still receiving ordinary bug fixes.
+
+## Recommendation
+
+```sh
+rbenv install 3.4.4
+cd /config/workspace/tiennm99/penny-pincher-provider
+rbenv local 3.4.4 # writes .ruby-version; Cloudflare reads the same file
+```
+
+Requires `libyaml-dev` present first (psych failure seen earlier this session).
+
+Horizon: safe to ~March 2028 on 3.4. Revisit when Jekyll declares Ruby 4.0 support, or by
+early 2028, whichever is first.
+
+### Correction required in existing docs
+
+`docs/cloudflare-deployment.md` (written earlier this session) states the build image ships
+Ruby 3.2 and suggests `RUBY_VERSION=3.2.2`. Both wrong — default is 3.4.4. With a committed
+`.ruby-version` the env-var fallback becomes unnecessary.
+
+### Pitfalls
+
+- Don't pin a 3.4.x newer than 3.4.4 until #27779 resolves.
+- Don't rely on Cloudflare's default staying 3.4.4 — minors update without notice; the
+ `.ruby-version` file is the pin.
+- Ruby 4.0 local + Jekyll = dependency resolution failure, not a runtime error. Looks confusing.
+
+## Sources
+
+- [Ruby maintenance branches](https://www.ruby-lang.org/en/downloads/branches/)
+- [Cloudflare Workers build image](https://developers.cloudflare.com/workers/ci-cd/builds/build-image/)
+- [cloudflare-docs#27779 — recent Ruby versions unsupported](https://github.com/cloudflare/cloudflare-docs/issues/27779)
+- [Jekyll 4.4.0 release notes](https://jekyllrb.com/news/2025/01/27/jekyll-4-4-0-released/)
+- [chirpy#2640 — Add Ruby 4 support](https://github.com/cotes2020/jekyll-theme-chirpy/issues/2640)
+
+## Unresolved
+
+1. Exact EOL dates for 3.4 and 4.0 — unpublished; ~2028-03 / ~2029-03 inferred from cadence.
+2. Whether Cloudflare actually rejects 3.4.5+ (docs vs. issue conflict). Untested.
+3. Jekyll's Ruby 4.0 timeline — no roadmap statement found.
diff --git a/wrangler.jsonc b/wrangler.jsonc
new file mode 100644
index 0000000..158e0ef
--- /dev/null
+++ b/wrangler.jsonc
@@ -0,0 +1,9 @@
+{
+ // Cloudflare Pages project. The site is fully static — no Pages Functions —
+ // so this file only declares where Jekyll writes its output.
+ "name": "penny-pincher-provider",
+ "compatibility_date": "2026-08-28",
+
+ // Produced by `bundle exec jekyll build`; see docs/cloudflare-deployment.md
+ "pages_build_output_dir": "./_site"
+}