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

Merged
MH
4mo ago
+713-56

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

ChangeDetail
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 tightenedPrune 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 pathThe 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 deduplicatedStops 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 PumaCMD ["./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.
DocsREADME "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 rspec — 710/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