Skip to content

Content: the Kamal 2 guide was documenting Kamal 1 (page-1 rankings, 0% CTR) - #506

Merged
pftg merged 6 commits into
masterfrom
fix/kamal-2-guide-rewrite
Aug 20, 2026
Merged

pftg merged 6 commits into
masterfrom
fix/kamal-2-guide-rewrite

Conversation

@pftg

@pftg pftg commented Aug 20, 2026

Copy link
Copy Markdown
Member

Why

Paul flagged the Kamal guide as "not top world class" - duplicated title, irrelevant paragraphs, repetition, and suspected wrong versions. All four were real, and the version problem was worse than stale numbers.

The post was a Kamal 1 guide wearing a Kamal 2 title. Every claim below was checked against the real gem (kamal-2.12.0, fetched and unpacked), not from memory.

Old post taught Kamal 2.12 reality Source
traefik: block in the flagship deploy.yml Traefik is removed; cleanup_traefik, replaced by kamal-proxy, and traefik reboot hooks are rejected cli/proxy.rb:151, main.rb:204, configuration.rb:383
top-level healthcheck: w/ port:, max_attempts: lives under proxy: with interval/path/timeout configuration/docs/proxy.yml:117
.env.erb + .env secrets, taught in 5 places .kamal/secrets - never mentioned once in the old post cli/main.rb:159-163
bin/kamal created by init only with --bundle, default false cli/main.rb:147
strategy: blue_green, max_surge, max_unavailable do not exist - zero matches in the gem. Invented config. -
"Ruby 2.7+ required" EOL March 2023 -
"Ubuntu 20.04+" EOL April 2025 -

A reader pasting the old flagship deploy.yml got a deployment that does not work. Same defect class as the P0 in #498.

What the live data said

32 impressions, 0 clicks, 0.0% CTR over 28 days - while ranking 9.5 for "kamal deploy minimum ram", 9.3 for its variant, and 7.5 for "kamal 2.0". It earned page-one placement and got passed over, with Deploy Without Heroku in 2025 in the SERP title in August 2026.

Also fixed

  • Duplicate title: single.html:47 already renders <h1>{{ .Title }}</h1>; the body repeated it verbatim. The post now emits one H1, matching the clean sibling post. (The site-wide "Welcome to JetThoughts" hero H1 exists on every page - pre-existing, not touched here, worth its own ticket.)
  • Repetition: config.force_ssl taught 3x (221/589/661), production.rb edited across 5 separate blocks, 3 overlapping performance sections, 3 closing sections.
  • Unsourced cost table ($155/mo, "54% savings", "75% savings"): deleted rather than re-sourced - a pricing digression in a deploy tutorial.
  • dev_to_id: 1 placeholder (the same field P0: recover #2 organic page from a 404, fix unbootable published configs + broken sample code #498 stripped from the Falcon post).
  • Flat file -> page bundle, so cover.png can be a bundle resource.

Scope, and why this isn't a duplicate

qmd surfaced the content-plan row showing kamal-2-multi-server-deployment-complete-guide (published 2026-08-07) targets the same "kamal 2.0" keyword. That post is correct Kamal 2 and owns multi-server.

This post takes first-deploy intent - which is what it actually ranks for (setup, install, rollback, RAM) - and hands roles, boot limits and multi-host TLS off to the sibling rather than re-teaching them.

The review caught me fabricating, twice

A core-reviewer pass ran kamal config instead of reading the gem, and falsified my opening thesis. I re-ran all three myself before fixing:

  1. I wrote that a traefik: block produces no warning. It produces ERROR (Kamal::ConfigurationError): unknown key: traefik. Kamal validates top-level keys and refuses. My error: I read Validator::Configuration#allow_extensions? => true and concluded unknown keys pass - that flag is for YAML extension keys, not arbitrary config keys.
  2. Same for top-level healthcheck:. My wording contradicted our own multi-server post, which correctly says Kamal rejects unrecognized keys at validation. That post was right; this one was wrong.
  3. kamal config does not print secrets or env - to_h emits roles/hosts/image/builder/accessories only. The gem's own help string ("including secrets!") is stale and I inherited it. Verified: mistyping a secret name leaves output byte-identical, exit 0. The post sent a stalled reader to a command that cannot show what it claimed.

Also corrected: setup has no env-push step; upgrade confirms once, not per step; traefik hooks should be renamed to (pre|post)-proxy-reboot per the error's own text, not deleted; rollback gates on a container and prune keeps 5 by default, which is the real limit on how far back you can roll; the rollback example was a literal bracketed placeholder that errors when pasted.

The corrected framing is a better post: Kamal 2 fails loudly, so the useful content is the key mapping.

Gates

  • bin/hugo-build: 8/8 validators, 1174 pages
  • URL preserved at /blog/kamal-2.0-complete-rails-deployment-guide-deploy-without-heroku-in-2025/ - the old file had no slug:, so Hugo derived the permalink from the title; an explicit slug: now pins the same URL while the title drops the stale "in 2025"
  • Rendered scroll gate: desktop 1280x800 + mobile 390x844, zero console errors, no horizontal page overflow, the one wide code block scrolls in its own container
  • Mermaid pre-rendered to SVG (ships no mermaid.js); viewBox 272x563 - 12px min font renders ~17.2px at 390px, clear of the 9px floor, and under one mobile viewport so it is not a wall
  • Cover: first render stranded a word on its own line; shortened and re-rendered, then scored against the 4 criteria
  • Content-only diff, so qtest/test/dtest correctly out of scope per CLAUDE.md

Open item for you

The visible date still reads JAN 15, 2025. Keeping it preserves the page's age signal, but a reader in August 2026 sees a 2025 date on a post about Kamal 2.12. I did not change it unilaterally - bumping date: would re-sort the blog index and rewrite the publication history. Say the word if you want it bumped or lastmod surfaced in the template.

🤖 Generated with Claude Code

pftg added 4 commits August 20, 2026 22:07
The published post ranked page-1 for "kamal deploy minimum ram" (9.5),
its variant (9.3) and "kamal 2.0" (7.5) - and took 0 clicks on 32
impressions over 28 days. It earned the traffic while being wrong.

Verified against the real gem (kamal-2.12.0, fetched and unpacked), not
from memory. What the old post taught vs what Kamal 2 does:

  traefik: block in the flagship deploy.yml
    -> Traefik is REMOVED in Kamal 2. cli/proxy.rb:151 cleanup_traefik,
       main.rb:204 "replace Traefik with kamal-proxy",
       configuration.rb:383 rejects pre/post-traefik-reboot hooks.
       Correct config is proxy: ssl/host.

  top-level healthcheck: with port:/max_attempts:
    -> lives under proxy: with interval/path/timeout
       (configuration/docs/proxy.yml:117)

  .env.erb + .env secrets model
    -> kamal init creates .kamal/secrets (cli/main.rb:159-163).
       The old post never mentioned .kamal/secrets once, across 5 places
       that taught .env. The whole secrets workflow was a major version
       behind.

  bin/kamal "created by init"
    -> only with --bundle, which defaults false (cli/main.rb:147)

  strategy: blue_green / max_surge / max_unavailable / deployment:
    -> ZERO matches anywhere in the gem. Invented config. Kamal's real
       rolling deploy is boot: limit/wait.

  "Ruby 2.7+ required" (EOL 2023), "Ubuntu 20.04+" (EOL Apr 2025)

A reader pasting the old flagship deploy.yml got a deployment that does
not work. That is the same defect class as the P0 fixed in #498.

Also fixed, per review:
- duplicate H1: single.html:47 already renders <h1>{{ .Title }}</h1>,
  and the body repeated the title verbatim - two identical H1s
- repetition: config.force_ssl taught 3x (221/589/661), production.rb
  edited across 5 blocks, 3 overlapping performance sections, 3 closing
  sections
- unsourced cost table ($155/mo, "54% savings", "75% savings") - deleted
  rather than re-sourced; it was a digression in a deploy tutorial
- dev_to_id: 1 placeholder (same field #498 stripped from the Falcon post)
- flat file -> page bundle so cover.png can be a bundle resource

URL is preserved. The old file had no slug:, so Hugo derived the permalink
from the title; an explicit slug: now pins the same URL while the title
drops the stale "in 2025" that was showing in the SERP.

Scope: this post owns first-deploy intent (setup, install, rollback, RAM).
kamal-2-multi-server-deployment-complete-guide (2026-08-07) already owns
multi-server correctly, so roles, boot limits and multi-host TLS hand off
to it instead of being re-taught here. Found via qmd, which surfaced the
content-plan row showing both target "kamal 2.0".

Still to come on this branch: cover.png, pre-rendered mermaid SVG, gates.
Cover follows the 6-slot house layout. Chips carry the post's three
findings rather than decoration: kamal-proxy not Traefik, .kamal/secrets,
build runs on your laptop. Status reads KAMAL 2.12.

First render wrapped hero line 3 ("Rails on your own / servers") and
stranded one word on its own line, turning a 3-line template into 4.
Shortened to "Your own servers" and re-rendered. Scored after looking at
it: look OK, readable without zoom, earns the scroll (the Traefik chip
tells you something), chips helpful not decorative.

Mermaid pre-rendered to SVG at authoring time per bin/render-mermaid, so
the page ships no mermaid.js. Vertical 4-node chain keeps viewBox at
272x563 - 12px min font renders ~17.2px at 390px wide, clear of the 9px
floor, and 563px tall is under one mobile viewport so it is not a wall.

bin/hugo-build: 8/8 validators, 1174 pages. URL preserved at
/blog/kamal-2.0-complete-rails-deployment-guide-deploy-without-heroku-in-2025/.
Post now emits one H1 (the theme's), matching the clean sibling post - the
site-wide "Welcome to JetThoughts" hero H1 is pre-existing on every page
and is not touched here.
…ning kamal

A 4-eyes reviewer RAN `kamal config` against the post's own deploy.yml
instead of reading the gem, and falsified the article's opening thesis.
I re-ran all three myself before fixing.

1. The hook was inverted. I wrote that pasting a `traefik:` block into a
   Kamal 2 config produces no warning. It produces:
     ERROR (Kamal::ConfigurationError): unknown key: traefik
   Kamal validates top-level keys and refuses. My error was reading
   Validator::Configuration#allow_extensions? => true and concluding
   unknown keys pass - that flag is for YAML extension keys, not
   arbitrary config keys. The same read-source-assert-behavior mistake
   this post exists to correct.

2. Same for a top-level `healthcheck:` block - also rejected. The old
   wording contradicted our own multi-server post, which correctly says
   Kamal rejects unrecognized keys at validation time. That post was
   right and this one was wrong.

   Both now show the real error text, and the section turns the
   validator into the porting tool it actually is.

3. `kamal config` does not print secrets or env. configuration.rb#to_h
   emits roles/hosts/image/builder/accessories only; the gem's own desc
   string ("including secrets!") is stale and I inherited it. Verified:
   mistyping a secret name leaves output byte-identical and exit 0. The
   post told a stalled reader to run a command that cannot show what it
   claimed - worse than no advice at all.

Also corrected against the gem:
- setup is server:bootstrap + deploy(boot_accessories: true); there is
  no env-push step (no cli/env.rb in 2.12)
- upgrade confirms ONCE, then runs both sub-upgrades with confirmed: true
- traefik reboot hooks should be RENAMED to (pre|post)-proxy-reboot, per
  the error's own text - "delete those files" threw away working hooks
- rollback gates on a container, not an image, and prune keeps 5 by
  default (configuration.rb:216) - that retention is the real limit on
  how far back you can roll
- the rollback example was a literal bracketed placeholder that errors
  when pasted; now a real SHA

Voice: dropped a first-person-singular "the question I get most" under a
Team byline, an "almost everyone" generalization, an unsourced
superlative, and a rule-of-three in twitter_description.

bin/hugo-build green. URL unchanged.
.stitch/designs/ is gitignored, but the cover HTMLs that back published
posts are force-added by convention - falcon-production-tuning-cover.html
(this one's parent), rails-8-authentication, solid-queue-advanced and two
others are all tracked. Without the source, regenerating or tweaking this
cover means rebuilding the 6-slot layout from scratch.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a098be06-c78f-428b-bae4-db1222575260


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

pftg added 2 commits August 20, 2026 22:33
… port

Paul asked for a prompt a reader can hand to their AI agent instead of
doing the migration by hand.

The prompt's spine is that kamal config is a real validator: it raises
ConfigurationError on every unrecognised top-level key, so the agent gets
a loop it can close without human judgement. It is told not to declare
the port finished until that command exits 0.

Two guards matter more than the key mapping. First, most Kamal material
in training data predates Kamal 2, so the prompt explicitly forbids
relying on recall and points at kamal docs <section> - the reference that
ships inside the installed gem. Second, it forbids inventing keys, which
is the exact failure the post documents: the guide this replaces carried
strategy: blue_green and max_surge, neither of which Kamal has ever had.
An agent trained on pages like that will reach for them.

The prompt also refuses to touch credentials (names only, keep .env until
the human confirms), renames traefik reboot hooks rather than deleting
them, and stops before deploy/setup/upgrade so a person reads the diff
before anything restarts real infrastructure.

bin/hugo-build: 8/8 validators.
I wrote this post's citation list as a bare 'Further reading:' paragraph,
copying the form its RubyLLM neighbours used. That form is drift - the
repo's convention is '## Sources', which 4 posts already used and 16 more
are being converted to on chore/further-reading-typography.

Fixing it here so this post doesn't merge as the 17th instance of a
pattern being removed in parallel.

bin/hugo-build: 8/8 validators.
@pftg
pftg merged commit 9ca8e1a into master Aug 20, 2026
5 checks passed
@pftg
pftg deleted the fix/kamal-2-guide-rewrite branch August 20, 2026 21:00
pftg added a commit that referenced this pull request Aug 20, 2026
The repo was split: 9 posts used '## Further reading', 4 used a bare
'Further reading:' paragraph. The 4 are the whole RubyLLM cluster, and the
Kamal guide on #506 copied the same plain-text form from its neighbours -
so the drift was still spreading.

As plain text it is a paragraph ending in a colon: no table-of-contents
entry, no landmark for a screen reader, and no visual weight separating it
from the body above it. The heading form is what the other 9 posts already
do.

Content unchanged - only the marker line.
pftg added a commit that referenced this pull request Aug 20, 2026
…ts (#510)

* chore(content): make 'Further reading' a real heading everywhere

The repo was split: 9 posts used '## Further reading', 4 used a bare
'Further reading:' paragraph. The 4 are the whole RubyLLM cluster, and the
Kamal guide on #506 copied the same plain-text form from its neighbours -
so the drift was still spreading.

As plain text it is a paragraph ending in a colon: no table-of-contents
entry, no landmark for a screen reader, and no visual weight separating it
from the body above it. The heading form is what the other 9 posts already
do.

Content unchanged - only the marker line.

* chore(content): rename 'Further reading' to the existing 'Sources' convention

Paul flagged that 'Further reading' and the theme's auto-generated
'Read next' both read as 'more things to read', with nothing signalling
that one is external citations and the other is internal JT posts.

Checked before renaming: every one of these lists is 100% external. The
internal links that appear near them live on a separate 'Related:' prose
line (2 posts), not inside the list - an earlier count of mine said
otherwise because it read to end-of-file rather than to the end of the
section.

'Sources' is not a new name. 4 posts on master already used '## Sources';
16 had drifted to 'Further reading' in three different forms:
  9  ## Further reading
  4  Further reading:          (bare paragraph)
  3  **Further reading:**      (bold - missed by my first survey, which
                                only matched the bare and heading forms)
All 16 now match the 4. Twenty posts, one convention.

What this does NOT touch: the 'Related:' prose line and the theme's
'Read next' (related-posts.html:11) are both internal links and still
overlap in meaning. That is pre-existing and a separate editorial call -
15 posts already opt out of 'Read next' via related_posts: false.

bin/hugo-build: 8/8 validators, 1174 pages. Marker lines only.
pftg added a commit that referenced this pull request Aug 20, 2026
Sync for 2026-08-20's merged work (#499, #501, #506, #509, #510). All
three land in workflows/blog-pipeline.md because they are gates, not
background - per the bundle's own rule that a lesson mattering six weeks
later belongs in a concept rather than the log.

1. Technical claims must be executed, not read. Every wrong technical
   claim shipped today came from reading source and inferring behaviour.
   The Kamal guide asserted a stale traefik: key sits in deploy.yml doing
   nothing; kamal config answers 'unknown key: traefik'. The bad
   inference came from Validator::Configuration#allow_extensions? => true,
   which governs YAML extension keys and not arbitrary config keys.

2. Frontmatter is published copy. #509 shipped live with a body softened
   to 'whether retired or quietly substituted' while twitter_description
   still asserted 'a retired model took out five features at once'.

3. Citation lists use ## Sources. 4 posts already used it; 16 had drifted
   across three forms, and the bold variant survived the first survey
   because my grep matched only the other two.

Concept timestamp + verified stamp added honestly (claude/opus-5, today).
workflows/index.md entry widened to name the three gates so a cold
session finds them without opening the file.

Validated: uv run okf_validate.py .okf --strict -> conformant, 0 errors.
Warning count unchanged at 64 (measured against a clean tree first, so
the number is a baseline rather than a claim).
pftg added a commit that referenced this pull request Aug 20, 2026
docs(okf): three publishing gates learned from claims we shipped wrong

Sync for 2026-08-20's merged work (#499, #501, #506, #509, #510). All
three land in workflows/blog-pipeline.md because they are gates, not
background - per the bundle's own rule that a lesson mattering six weeks
later belongs in a concept rather than the log.

1. Technical claims must be executed, not read. Every wrong technical
   claim shipped today came from reading source and inferring behaviour.
   The Kamal guide asserted a stale traefik: key sits in deploy.yml doing
   nothing; kamal config answers 'unknown key: traefik'. The bad
   inference came from Validator::Configuration#allow_extensions? => true,
   which governs YAML extension keys and not arbitrary config keys.

2. Frontmatter is published copy. #509 shipped live with a body softened
   to 'whether retired or quietly substituted' while twitter_description
   still asserted 'a retired model took out five features at once'.

3. Citation lists use ## Sources. 4 posts already used it; 16 had drifted
   across three forms, and the bold variant survived the first survey
   because my grep matched only the other two.

Concept timestamp + verified stamp added honestly (claude/opus-5, today).
workflows/index.md entry widened to name the three gates so a cold
session finds them without opening the file.

Validated: uv run okf_validate.py .okf --strict -> conformant, 0 errors.
Warning count unchanged at 64 (measured against a clean tree first, so
the number is a baseline rather than a claim).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant