Skip to content

feat(deploy): lean multi-stage Dockerfiles + a shared upgrade path#52

Merged
mhenrixon merged 2 commits into
mainfrom
feature/optimize-dockerfile-and-upgrade-path
Jul 4, 2026
Merged

feat(deploy): lean multi-stage Dockerfiles + a shared upgrade path#52
mhenrixon merged 2 commits into
mainfrom
feature/optimize-dockerfile-and-upgrade-path

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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:

  1. Dogfood (docs/Dockerfile, repo-root context, gem at /app + app at /app/docs)
  2. Standalone site (templated by docs_kit:install, WORKDIR /rails, whole-app context)

What changed

Change Detail
New root .dockerignore (dogfood) Repo-root build context drops from 76 MB → ~24 MB: excludes node_modules (39 MB), .git (8.5 MB), coverage (2.4 MB), codedb.snapshot, specs, tmp/logs.
docs/Dockerfile tightened Prune the bundler cache + git-in-gems after bundle install; drop node_modules + bun/asset caches after assets:precompile, so the final COPY /app never carries the 39 MB of JS deps.
docs_kit:install templates Dockerfile + .dockerignore .dockerignore is gem-owned (refreshed every run, like the og rake task); Dockerfile is site-owned (skip-if-exists, like the config initializer).
Version-stamped upgrade path The templated Dockerfile carries a # docs-kit Dockerfile vX.Y.Z marker. SyncReport warns on --sync 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 deduplicated Stops hand-writing the inline Dockerfile heredoc; defers to the generator so scaffolded and upgrading sites share ONE optimized file. A custom --service still corrects the LABEL.
Thruster fronts Puma CMD ["./bin/thrust", "./bin/rails", "server"] — HTTP caching, compression, X-Sendfile. HTTP_PORT=3000 (the routed port: Kamal's app_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 the development/test groups BUNDLE_WITHOUT excludes); otherwise it falls back to plain rails server, so docs_kit:install can never scaffold a CMD that crashes at boot. When the Gemfile has the gem but the binstub is missing, the generator scaffolds bin/thrust — the exec-form CMD needs the file to exist in the image.
Docs README "Upgrading your Dockerfile" + a deploy-page "The Docker image" section.

Why the explicit ports

Thruster's default HTTP_PORT is 80: as USER 1000 that bind can fail, and — worse — kamal-proxy routes to app_port: 3000, which would hit Puma directly and silently bypass Thruster. Pinning HTTP_PORT=3000/TARGET_PORT=3001 keeps Thruster on the routed port everywhere (Kamal, local docker run, the healthcheck).

Why drop .git is safe

The path-gem gemspec (docs-kit.gemspec) falls back to a Dir[] glob over exe/ lib/ app/ config/ when .git is absent. Verified: the glob yields the full 93-file manifest, so bundle install for the path gem still sees every file it needs.

Test Coverage

Generator spec (spec/generators/install_generator_spec.rb):

  • writes Dockerfile + .dockerignore
  • Dockerfile carries the v<VERSION> marker
  • standalone WORKDIR /rails layout, not the /app/docs monorepo layout
  • multi-stage build keeps the toolchain out of the final image
  • .dockerignore excludes node_modules, .git, log, tmp, spec, coverage
  • Dockerfile skip-if-exists (site-owned, never clobbered)
  • .dockerignore force-refresh (gem-owned)

SyncReport spec (drift detection):

  • older marker → warns; equal marker → silent; no marker → silent

Verification

  • bundle exec rspec710/710 pass (the spec/rubocop/cop/** specs fail identically on clean main due to a local rubocop-rake resolution quirk; main CI is green)
  • bundle exec rubocop — no offenses
  • docker build -f docs/Dockerfile .succeeds, 327 MB image; boots and serves /up + / with HTTP 200; no node_modules/.git leaked; built CSS present
  • Thruster proven in the request path: Puma logs Listening on :3001 (TARGET_PORT honored), traffic answers on 3000, and the CSS asset returns Content-Encoding: gzip with Accept-Encoding: gzip — Puma alone never compresses, so the gzip response is Thruster serving
  • gemspec Dir[] fallback verified for the .git-less build

Test plan for a reviewer

# The optimized dogfood image builds + runs from the repo root:
docker build -f docs/Dockerfile -t docs-kit:test .
docker run --rm -p 3000:3000 -e SECRET_KEY_BASE=test docs-kit:test
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/up   # → 200

# Thruster (not Puma) is answering — Puma alone never gzips:
curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' \
  "localhost:3000$(curl -s localhost:3000 | grep -oE '/assets/[^\"]+\.css' | head -1)" \
  | grep -i content-encoding                                  # → gzip

# The generator ships the Docker files + the version marker + the thrust CMD:
bundle exec rspec spec/generators/install_generator_spec.rb

https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX

## 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
@mhenrixon mhenrixon self-assigned this Jul 4, 2026
## 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
@mhenrixon mhenrixon added documentation Improvements or additions to documentation enhancement New feature or request labels Jul 4, 2026
@mhenrixon mhenrixon merged commit a196620 into main Jul 4, 2026
5 checks passed
@mhenrixon mhenrixon deleted the feature/optimize-dockerfile-and-upgrade-path branch July 4, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant