feat(deploy): lean multi-stage Dockerfiles + a shared upgrade path#52
Merged
Merged
Conversation
## Summary
Optimize the Docker build for every docs-kit site and give existing sites a
real upgrade path for their Dockerfile — the file was previously a one-shot
heredoc in the new-site template that no upgrade ever touched.
- **New root `.dockerignore` (dogfood)** — the repo-root build context dropped
from 76 MB to ~24 MB by excluding `node_modules` (39 MB), `.git` (8.5 MB),
`coverage` (2.4 MB), `codedb.snapshot`, specs, and tmp/logs. Safe to drop
`.git`: the path-gem gemspec falls back to a `Dir[]` glob when it's absent
(verified — the glob yields the full 93-file manifest).
- **`docs/Dockerfile` (dogfood) tightened** — prune the bundler cache +
git-in-gems after `bundle install`, and drop `node_modules` + the bun/asset
caches after `assets:precompile`, so the final stage's `COPY /app` never
carries the 39 MB of JS deps. Built image: 327 MB, boots and serves `/up` +
`/` with 200, no `node_modules`/`.git` leaked.
- **`docs_kit:install` now templates a `Dockerfile` + `.dockerignore`** for
consuming sites (standalone layout, `WORKDIR /rails`, whole-app context).
`.dockerignore` is gem-owned (refreshed every run, like the og rake task);
`Dockerfile` is site-owned (skip-if-exists, like the config initializer).
- **Upgrade path via a version stamp** — the templated Dockerfile carries a
`# docs-kit Dockerfile vX.Y.Z` marker. `SyncReport` (the `--sync` drift
checklist) warns when a site's marker is older than the gem's `VERSION`,
pointing at the template to diff/replace. A hand-written Dockerfile (no
marker) is left alone.
- **`new_site.rb`** stops hand-writing the inline Dockerfile heredoc and defers
to the generator, so scaffolded and upgrading sites share ONE optimized file.
A custom `--service` still corrects the LABEL to match `config/deploy.yml`.
- Docs: README "Upgrading your Dockerfile" + a deploy-page section.
## Test Coverage
- generator spec: writes `Dockerfile` + `.dockerignore`; version marker present;
standalone `WORKDIR /rails` layout; multi-stage build keeps toolchain out of
the final image; `.dockerignore` excludes the build cruft; Dockerfile
skip-if-exists (site-owned); `.dockerignore` force-refresh (gem-owned).
- SyncReport spec: stale-marker warns (older → warn, equal → silent, no marker
→ silent).
## Verification
- [x] bundle exec rspec — 704/704 (the rubocop cop specs fail identically on
clean main due to a local rubocop-rake resolution quirk; main CI is green)
- [x] bundle exec rubocop — 128 files, no offenses
- [x] docker build (repo root, docs/Dockerfile) — succeeds, 327 MB, boots + serves 200
- [x] gemspec Dir[] fallback verified for the .git-less build
Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX
## Summary
Thruster (HTTP caching + compression + X-Sendfile) now fronts Puma in both
Docker flavors — the dogfood docs/Dockerfile and the generated site template.
The port topology is explicit because Thruster's defaults are a trap here:
its default HTTP_PORT is 80, which the non-root user can't reliably bind AND
which kamal-proxy (app_port: 3000) would never route to — silently bypassing
Thruster straight into Puma. So HTTP_PORT=3000 (the routed port: Kamal's
app_port, EXPOSE, the healthcheck) and TARGET_PORT=3001 (Puma, unpublished;
Thruster sets PORT for the child, which config/puma.rb reads).
The template emits the thrust CMD only when the site bundles thruster in the
PRODUCTION bundle: a committed bin/thrust is authoritative; otherwise the
Gemfile decides, ignoring entries in development/test groups (BUNDLE_WITHOUT
excludes them — the gem wouldn't be installed and thrust would raise at boot).
When the Gemfile has the gem but no binstub, the generator scaffolds
bin/thrust (skip-if-exists): the exec-form CMD needs the file to EXIST in the
image — bundle install installs the gem, not app binstubs, and COPY can't
ship a file the repo doesn't have. Without thruster, the CMD falls back to
plain rails server — never a thrust CMD that builds green and crashes at boot.
The binstub scaffolding + group-aware detection came out of an adversarial
multi-agent review of the initial diff, which confirmed the naive
Gemfile-regex branch produced boot-crashing images for gem-without-binstub
and dev-group-only sites.
## Test Coverage
- generator spec: thrust CMD + port env when bin/thrust exists; thrust CMD +
binstub scaffolded (0755) for a Gemfile-only site; plain CMD for no
thruster, dev-group-only thruster, and inline `group:` kwarg; an existing
hand-tuned bin/thrust is never overwritten.
## Verification
- [x] bundle exec rspec — 715/715 (excluding the pre-existing local-env cop-spec quirk)
- [x] bundle exec rubocop — no offenses
- [x] docker build + run: Puma logs Listening on :3001 (TARGET_PORT honored),
/up + / answer 200 on 3000, CSS asset returns Content-Encoding: gzip —
Puma alone never compresses, so the gzip response proves Thruster serves
Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Optimize the Docker build for every docs-kit site and give existing sites a real upgrade path for their Dockerfile — previously a one-shot heredoc in the new-site template that no upgrade ever touched.
There are two Docker flavors, both improved:
docs/Dockerfile, repo-root context, gem at/app+ app at/app/docs)docs_kit:install,WORKDIR /rails, whole-app context)What changed
.dockerignore(dogfood)node_modules(39 MB),.git(8.5 MB),coverage(2.4 MB),codedb.snapshot, specs, tmp/logs.docs/Dockerfiletightenedbundle install; dropnode_modules+ bun/asset caches afterassets:precompile, so the finalCOPY /appnever carries the 39 MB of JS deps.docs_kit:installtemplatesDockerfile+.dockerignore.dockerignoreis gem-owned (refreshed every run, like the og rake task);Dockerfileis site-owned (skip-if-exists, like the config initializer).# docs-kit Dockerfile vX.Y.Zmarker.SyncReportwarns on--syncwhen a site's marker is older than the gem'sVERSION, pointing at the template to diff/replace. A hand-written Dockerfile (no marker) is left alone.new_site.rbdeduplicated--servicestill corrects the LABEL.CMD ["./bin/thrust", "./bin/rails", "server"]— HTTP caching, compression, X-Sendfile.HTTP_PORT=3000(the routed port: Kamal'sapp_port, EXPOSE, healthcheck),TARGET_PORT=3001(Puma, unpublished). The template emits the thrust CMD only when the site bundles thruster in the production bundle (bin/thrust, or a Gemfile entry outside thedevelopment/testgroupsBUNDLE_WITHOUTexcludes); otherwise it falls back to plainrails server, sodocs_kit:installcan never scaffold a CMD that crashes at boot. When the Gemfile has the gem but the binstub is missing, the generator scaffoldsbin/thrust— the exec-form CMD needs the file to exist in the image.Why the explicit ports
Thruster's default
HTTP_PORTis 80: asUSER 1000that bind can fail, and — worse — kamal-proxy routes toapp_port: 3000, which would hit Puma directly and silently bypass Thruster. PinningHTTP_PORT=3000/TARGET_PORT=3001keeps Thruster on the routed port everywhere (Kamal, localdocker run, the healthcheck).Why drop
.gitis safeThe path-gem gemspec (
docs-kit.gemspec) falls back to aDir[]glob overexe/ lib/ app/ config/when.gitis absent. Verified: the glob yields the full 93-file manifest, sobundle installfor the path gem still sees every file it needs.Test Coverage
Generator spec (
spec/generators/install_generator_spec.rb):Dockerfile+.dockerignorev<VERSION>markerWORKDIR /railslayout, not the/app/docsmonorepo layout.dockerignoreexcludesnode_modules,.git,log,tmp,spec,coverage.dockerignoreforce-refresh (gem-owned)SyncReport spec (drift detection):
Verification
bundle exec rspec— 710/710 pass (thespec/rubocop/cop/**specs fail identically on cleanmaindue to a localrubocop-rakeresolution quirk;mainCI is green)bundle exec rubocop— no offensesdocker build -f docs/Dockerfile .— succeeds, 327 MB image; boots and serves/up+/with HTTP 200; nonode_modules/.gitleaked; built CSS presentListening on :3001(TARGET_PORT honored), traffic answers on 3000, and the CSS asset returnsContent-Encoding: gzipwithAccept-Encoding: gzip— Puma alone never compresses, so the gzip response is Thruster servingDir[]fallback verified for the.git-less buildTest plan for a reviewer
https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX