From b4e0a09fa54924fb4675c91695648c4d5984b742 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 09:16:25 +0200 Subject: [PATCH 1/2] feat(deploy): lean multi-stage Dockerfiles + a shared upgrade path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .dockerignore | 71 ++++++++++++ README.md | 28 ++++- docs/Dockerfile | 29 +++-- docs/app/views/docs/pages/deploy.rb | 39 +++++++ lib/docs_kit/templates/new_site.rb | 46 ++------ .../docs_kit/install/install_generator.rb | 38 +++++++ .../docs_kit/install/sync_report.rb | 30 ++++- .../docs_kit/install/templates/Dockerfile.tt | 79 +++++++++++++ .../docs_kit/install/templates/dockerignore | 57 ++++++++++ spec/generators/install_generator_spec.rb | 104 ++++++++++++++++++ 10 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 .dockerignore create mode 100644 lib/generators/docs_kit/install/templates/Dockerfile.tt create mode 100644 lib/generators/docs_kit/install/templates/dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7176836 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,71 @@ +# docs-kit dogfood .dockerignore — trims the REPO-ROOT build context. +# +# The docs site (docs/) dogfoods the gem via `gem "docs-kit", path: ".."`, so the +# Docker build context is the repo root: the gem at /app, the app at /app/docs +# (see docs/Dockerfile). This file keeps that context small — without it the +# daemon ships node_modules, coverage, the codedb snapshot, and .git into the +# build (tens of MB) and the COPY layers balloon. +# +# .git is safe to drop: docs-kit.gemspec falls back to a Dir[] glob (over exe/ +# lib/ app/ config/) when there's no .git, so the path gem still packages every +# file `bundle install` needs. Verified: the glob yields the full manifest. + +# Git metadata. +/.git/ +/.gitignore +/.gitattributes + +# Editor + AI-assistant configs (gem-level and app-level). +/.claude/ +/.cursor/ +/.vscode/ +/.idea/ +/.ruby-lsp/ +/.solargraph.yml +/.worktrees/ + +# Gem-level dev/test artifacts — not part of the docs app image. The gemspec's +# file list is exe/ + lib/ + app/ + config/ (+ a few md files); it never ships +# spec/ or coverage/, so excluding them can't starve `bundle install`. +/spec/ +/.rspec +/coverage/ +/codedb.snapshot +/tmp/ +/pkg/ +/doc/ +/.yardoc + +# Bundler config (the Dockerfile's BUNDLE_* env drives the install). +/.bundle +/docs/.bundle + +# --- The docs app (docs/) -------------------------------------------------- +# JS deps + generated assets: `bun install` + `assets:precompile` rebuild these +# inside the build stage. +/docs/node_modules/ +/docs/app/assets/builds/* +!/docs/app/assets/builds/.keep +# Generated by bin/build-css — Tailwind resolves the gem @source globs at build. +/docs/app/assets/stylesheets/tailwind.sources.css +/docs/public/assets + +# Logs and tempfiles (keep the dirs, drop the contents). +/docs/log/* +!/docs/log/.keep +/docs/tmp/* +!/docs/tmp/.keep +/docs/tmp/pids/* +!/docs/tmp/pids/.keep + +# Environment files and credentials — never bake secrets into an image. +/docs/.env* +/docs/config/master.key +/docs/config/credentials/*.key + +# The docs app's own tests + CI/deploy meta. +/docs/spec/ +/docs/.rspec +/docs/coverage/ +/docs/.github/ +/docs/.kamal/ diff --git a/README.md b/README.md index e637b19..69201b2 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,29 @@ generators: | `ApplicationController#render_page` defined by hand | `DocsKit::Controller#render_page` is included (the generator injects `include DocsKit::Controller`) | Delete the method — keep the `include`. | | `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. | | Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. | +| `Dockerfile` stamped by an older docs-kit (`# docs-kit Dockerfile vX.Y.Z`) | docs-kit ships an optimized, multi-stage Dockerfile; a stale copy misses image-size wins | Diff yours against the current template (`lib/generators/docs_kit/install/templates/Dockerfile.tt` in the gem), adopt the changes or replace it. See [Upgrading your Dockerfile](#upgrading-your-dockerfile). | + +### Upgrading your Dockerfile + +The generator ships two Docker files: + +- **`.dockerignore`** is gem-owned — every `docs_kit:install` (or `--sync`) run + **refreshes** it, so you always get the current build-context excludes + (`node_modules`, `.git`, `log`, `tmp`, `spec`, `coverage`, …). It carries no + site-specific content, so overwriting it is safe. +- **`Dockerfile`** is site-owned — the generator **never clobbers** it (you tune + packages, the `CMD`, extra build steps). Instead it stamps a version marker + (`# docs-kit Dockerfile v`) so `--sync` can tell you when yours is + stale relative to the gem's current template. + +When `--sync` reports your Dockerfile is behind, compare it against the shipped +template and pull in the improvements (or replace it wholesale if you never +customized it): + +```bash +# The template path is printed by the generator; it lives in the installed gem: +diff Dockerfile "$(bundle show docs-kit)/lib/generators/docs_kit/install/templates/Dockerfile.tt" +``` ## Configure (per site) @@ -751,8 +774,9 @@ and applies docs-kit's application template, which: - runs `rails g docs_kit:install` (initializers, controllers, a Doc registry, a sample guide page, the Bun/Tailwind build, the docs-nav Stimulus wiring), - syncs the lucide icons and builds the CSS, -- scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, `Dockerfile`) and a - thin `.github/workflows/deploy-docs.yml` that calls the reusable workflow. +- scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, an optimized + multi-stage `Dockerfile` + a `.dockerignore`) and a thin + `.github/workflows/deploy-docs.yml` that calls the reusable workflow. Then `cd my-docs && bin/dev`. Already have a Rails app? Run the install generator instead: diff --git a/docs/Dockerfile b/docs/Dockerfile index 2a9fb0a..210403c 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -3,7 +3,11 @@ # docs-kit's own docs site dogfoods the gem via `gem "docs-kit", path: ".."`, so # the build context is the REPO ROOT (gem at /app, docs app at /app/docs). Build # from the repo root: the reusable workflow uses context "." + dockerfile -# "docs/Dockerfile". +# "docs/Dockerfile". The repo-root .dockerignore keeps that context small. +# +# Multi-stage: the throwaway `build` stage carries the toolchain (build-essential, +# git, bun) and compiles gems + assets; the final stage copies only the installed +# bundle + the app, so none of the build tooling ships in the runtime image. ARG RUBY_VERSION=3.4.2 FROM ruby:$RUBY_VERSION-slim AS base @@ -20,29 +24,36 @@ ENV BUNDLE_DEPLOYMENT="0" \ BUNDLE_GEMFILE="/app/docs/Gemfile" \ RAILS_ENV="production" +# Runtime-only packages (jemalloc for a leaner heap, curl for the healthcheck). RUN apt-get update -qq && \ apt-get install --no-install-recommends -y curl libjemalloc2 && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives RUN gem update --system --no-document && gem install -N bundler -# --- Build stage -------------------------------------------------------------- +# --- Build stage (throwaway) -------------------------------------------------- FROM base AS build RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config unzip RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" -# The whole repo (gem at /app, app at /app/docs) — the path gem's gemspec runs -# `git ls-files`, so it needs the full checkout (incl .git) before bundle install. +# The whole repo (gem at /app, app at /app/docs). The path gem's gemspec packages +# its files via `git ls-files`, falling back to a Dir[] glob when .git is absent +# (the .dockerignore drops .git) — so bundle install still sees the full manifest. COPY . /app/ +# Install gems, then prune the bundler cache + any git-sourced gem checkouts so +# they don't bloat the layer the final stage copies. RUN bundle config set frozen false && bundle install && \ - rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git RUN bun install --frozen-lockfile -# assets:precompile runs bun run build:css via the css:build rake enhance. -RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile +# assets:precompile runs bun run build:css via the css:build rake enhance. Drop +# node_modules + the bun/asset caches afterwards — only the built CSS is kept, so +# the final stage's COPY of /app never carries the 39 MB of JS deps. +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \ + rm -rf node_modules /usr/local/bun tmp/cache # --- Final stage -------------------------------------------------------------- @@ -52,10 +63,6 @@ FROM base # config/deploy.yml (= the repo name, docs-kit). LABEL service="docs-kit" -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives - COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --from=build /app /app diff --git a/docs/app/views/docs/pages/deploy.rb b/docs/app/views/docs/pages/deploy.rb index e757c2b..ee4e985 100644 --- a/docs/app/views/docs/pages/deploy.rb +++ b/docs/app/views/docs/pages/deploy.rb @@ -29,6 +29,45 @@ def content SHELL end + DocsUI::Section("The Docker image", description: "Lean, multi-stage, and upgradable.") do + prose do + p do + plain "The scaffolded " + code { "Dockerfile" } + plain " is a multi-stage build: a throwaway " + code { "build" } + plain " stage carries the toolchain (build-essential, git, bun) and compiles the gems + assets, and the final stage copies " + strong { "only" } + plain " the installed bundle and the app — no compilers, no " + code { "node_modules" } + plain ". A shipped " + code { ".dockerignore" } + plain " keeps the build context small (no " + code { ".git" } + plain ", " + code { "node_modules" } + plain ", logs, specs, or coverage)." + end + p do + plain "The " + code { ".dockerignore" } + plain " is gem-owned — every " + code { "docs_kit:install" } + plain " run refreshes it. The " + code { "Dockerfile" } + plain " is yours to tune, so the generator never clobbers it; it stamps a version marker (" + code { "# docs-kit Dockerfile vX.Y.Z" } + plain ") so " + code { "--sync" } + plain " warns you when a newer, leaner template ships. Diff and adopt:" + end + end + DocsUI::Code(<<~SHELL, lexer: :shell) + bin/rails g docs_kit:install --sync # warns if your Dockerfile is stale + diff Dockerfile "$(bundle show docs-kit)/lib/generators/docs_kit/install/templates/Dockerfile.tt" + SHELL + end + DocsUI::Section("The reusable workflow") do prose do p do diff --git a/lib/docs_kit/templates/new_site.rb b/lib/docs_kit/templates/new_site.rb index 17940db..eb4e05e 100644 --- a/lib/docs_kit/templates/new_site.rb +++ b/lib/docs_kit/templates/new_site.rb @@ -101,45 +101,13 @@ KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD SH - create_file "Dockerfile", <<~DOCKER - # syntax = docker/dockerfile:1 - ARG RUBY_VERSION=3.4.2 - FROM ruby:$RUBY_VERSION-slim AS base - - ARG BUN_VERSION=1.3.2 - ENV BUN_INSTALL="/usr/local/bun" - ENV PATH="/usr/local/bun/bin:$PATH" - WORKDIR /rails - ENV BUNDLE_WITHOUT="development:test" RAILS_ENV="production" - - RUN apt-get update -qq && \\ - apt-get install --no-install-recommends -y curl libjemalloc2 && \\ - rm -rf /var/lib/apt/lists /var/cache/apt/archives - RUN gem update --system --no-document && gem install -N bundler - - FROM base AS build - RUN apt-get update -qq && \\ - apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config unzip - RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" - COPY Gemfile Gemfile.lock ./ - RUN bundle install && rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache - COPY . . - RUN bun install --frozen-lockfile - # assets:precompile runs bun run build:css via the css:build rake enhance. - RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile - - FROM base - # Kamal verifies this label on --skip-push deploy; must equal `service:`. - LABEL service="#{service}" - COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" - COPY --from=build /rails /rails - RUN groupadd --system --gid 1000 rails && \\ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \\ - chown -R 1000:1000 /rails/log /rails/tmp - USER 1000:1000 - EXPOSE 3000 - CMD ["./bin/rails", "server", "-b", "0.0.0.0"] - DOCKER + # The Dockerfile + .dockerignore are written by `docs_kit:install` (run above in + # after_bundle) so a scaffolded site and an upgrading site share ONE optimized, + # version-stamped Dockerfile — no divergent copy to maintain here. The generator + # derives the LABEL service from the app dir basename (= app_name); if the site + # deploys under a DIFFERENT Kamal service (`--service`), correct the label to + # match config/deploy.yml so Kamal's --skip-push validate_image passes. + gsub_file "Dockerfile", /LABEL service=".*"/, %(LABEL service="#{service}") if service != app_name create_file ".github/workflows/deploy-docs.yml", <<~YAML name: Deploy docs diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index f747edc..8384b0f 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -4,6 +4,7 @@ require "yaml" require "rails/generators/base" require_relative "sync_report" +require_relative "../../../docs_kit/version" module DocsKit module Generators @@ -178,6 +179,30 @@ def create_og_task template "docs_kit_og.rake", "lib/tasks/docs_kit_og.rake" end + # The production Dockerfile — a lean multi-stage build for a standalone + # docs site. Site-customizable (a site tunes packages/CMD), so skip when it + # exists and point an upgrader at the current template for a manual diff. + # The template stamps a `# docs-kit Dockerfile vX.Y.Z` marker so a `--sync` + # upgrade (SyncReport) can flag a stale copy. (create_initializer follows + # this same skip-if-exists + template-hint pattern.) + def create_dockerfile + dockerfile = "Dockerfile" + if File.exist?(File.join(destination_root, dockerfile)) + template_path = File.join(self.class.source_root, "Dockerfile.tt") + return say_status(:skip, "#{dockerfile} exists — compare with #{template_path} if upgrading", :blue) + end + + template "Dockerfile.tt", dockerfile + end + + # The .dockerignore — gem-owned build-context trimming (node_modules, .git, + # logs, specs, coverage). Refreshed on every run (like create_og_task): it + # carries no site-specific content, so a re-run always ships the current + # excludes rather than fossilizing an old list. + def create_dockerignore + copy_file "dockerignore", ".dockerignore", force: true + end + def wire_assets_and_package_json # Serve the bun-built CSS from app/assets/builds. inject_into_file "config/initializers/assets.rb", @@ -452,6 +477,19 @@ def app_brand name = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.class&.module_parent_name (name || File.basename(destination_root)).to_s.underscore.humanize end + + # The Kamal `service` name stamped as the Dockerfile's LABEL — the app dir + # basename (a docs site's repo name), matching the `docs-kit new` default. + # Used in Dockerfile.tt via <%= docker_service %>. + def docker_service + File.basename(destination_root) + end + + # The RUBY_VERSION build ARG default: the host's running Ruby (X.Y.Z), so a + # site's image matches its dev Ruby. Used in Dockerfile.tt. + def ruby_version_arg + RUBY_VERSION[/\d+\.\d+\.\d+/] || "3.4.2" + end end end end diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb index 3d7123a..2d9f1e5 100644 --- a/lib/generators/docs_kit/install/sync_report.rb +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../../../docs_kit/version" + module DocsKit module Generators # Detects manual drift in an existing docs site that the install generator @@ -9,13 +11,21 @@ module Generators # generator prints these as a checklist during a `--sync` upgrade; the site # owner deletes the flagged code by hand. # - # Two drift items, both from the consumer audits: + # Drift items, from the consumer audits: # - ApplicationController hand-defines `render_page` — DocsKit::Controller # (included by the generator for months) already provides it. # - a dead IconHelper copy — the gem renders icons via rails_icons. + # - a Dockerfile stamped by an OLDER docs-kit than the gem now ships — the + # site should diff against the current template and adopt the improvements. class SyncReport APPLICATION_CONTROLLER = "app/controllers/application_controller.rb" ICON_HELPER = "app/helpers/icon_helper.rb" + DOCKERFILE = "Dockerfile" + + # Matches the version stamp the Dockerfile template writes, e.g. + # `# docs-kit Dockerfile v1.0.2`. Absent on a hand-written Dockerfile a site + # brought itself — which we deliberately leave alone (no marker → no warning). + DOCKERFILE_MARKER = /docs-kit Dockerfile v(\d+\.\d+\.\d+)/ def initialize(destination_root) @root = destination_root @@ -24,7 +34,7 @@ def initialize(destination_root) # The drift messages, in the order a site should act on them. Empty when # the site is clean. def items - [render_page_drift, icon_helper_drift].compact + [render_page_drift, icon_helper_drift, dockerfile_drift].compact end def clean? @@ -53,6 +63,22 @@ def icon_helper_drift "rails_icons (DocsUI::Icon); delete it." end + # The site's Dockerfile carries a docs-kit version stamp OLDER than the gem + # now ships. We never rewrite the site's Dockerfile (it's tuned per site) — + # we point the owner at the current template to diff or replace. A file with + # no marker (a hand-written Dockerfile) is left alone: no stamp, no warning. + def dockerfile_drift + source = read(DOCKERFILE) + stamped = source&.match(DOCKERFILE_MARKER) + return unless stamped + + site_version = stamped[1] + return if site_version == DocsKit::VERSION + + "#{DOCKERFILE} is v#{site_version}, docs-kit now ships v#{DocsKit::VERSION} — " \ + "diff against the template (bin/rails g docs_kit:install shows the path) and adopt the changes." + end + def read(rel) path = File.join(@root, rel) File.exist?(path) ? File.read(path) : nil diff --git a/lib/generators/docs_kit/install/templates/Dockerfile.tt b/lib/generators/docs_kit/install/templates/Dockerfile.tt new file mode 100644 index 0000000..17ac42e --- /dev/null +++ b/lib/generators/docs_kit/install/templates/Dockerfile.tt @@ -0,0 +1,79 @@ +# syntax = docker/dockerfile:1 +# docs-kit Dockerfile v<%= DocsKit::VERSION %> — regenerate/diff: bin/rails g docs_kit:install +# +# A standalone docs-kit site: a lean, multi-stage production image. The final +# stage carries only the runtime (Ruby + the installed bundle + the app), never +# the build toolchain (build-essential, git, bun) — those live in the throwaway +# `build` stage. Pair it with the shipped .dockerignore so the build context +# stays small (no node_modules, .git, logs, specs, coverage). +# +# Build context is the app root; `docker build .` (Kamal: context: "."). + +ARG RUBY_VERSION=<%= ruby_version_arg %> +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here. +WORKDIR /rails + +ARG BUN_VERSION=1.3.2 +ENV BUN_INSTALL="/usr/local/bun" \ + PATH="/usr/local/bun/bin:$PATH" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" \ + RAILS_ENV="production" + +# Base packages the RUNTIME needs (jemalloc for a smaller/faster heap, curl for +# the container healthcheck). Anything only the build needs goes in `build`. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + + +# --- Build stage (throwaway — its layers never reach the final image) --------- +FROM base AS build + +# Toolchain to compile native gems + fetch bun. Skip the apt cleanup: this whole +# stage is discarded. +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config unzip +RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}" + +# Install gems first (cached until Gemfile/Gemfile.lock change), then prune the +# bundler cache + any git-sourced gem checkouts so they don't bloat the layer. +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git + +# Install JS deps (the Tailwind/daisyUI CLI), then the app. +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile +COPY . . + +# assets:precompile runs `bun run build:css` via the css:build rake enhance. +# Drop the node_modules + bun cache afterwards — the built CSS is all we keep. +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \ + rm -rf node_modules .bun tmp/cache + + +# --- Final stage -------------------------------------------------------------- +FROM base + +# Kamal's validate_image greps this label on a --skip-push deploy; it must equal +# `service:` in config/deploy.yml. The reusable deploy workflow also stamps it, +# but keeping it here means `docker build` alone produces a Kamal-valid image. +LABEL service="<%= docker_service %>" + +# Copy ONLY the built artifacts from the build stage: the installed bundle and +# the app (with precompiled assets). No toolchain, no node_modules. +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run as a non-root user; own only the runtime dirs it writes. +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R 1000:1000 /rails/log /rails/tmp +USER 1000:1000 + +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0"] diff --git a/lib/generators/docs_kit/install/templates/dockerignore b/lib/generators/docs_kit/install/templates/dockerignore new file mode 100644 index 0000000..86cac7a --- /dev/null +++ b/lib/generators/docs_kit/install/templates/dockerignore @@ -0,0 +1,57 @@ +# docs-kit .dockerignore — keep the build context (and image layers) small. +# Gem-owned: `bin/rails g docs_kit:install` refreshes this on every run. +# See https://docs.docker.com/build/building/context/#dockerignore-files + +# Git metadata — the app build never needs it (a released gem carries its files). +/.git/ +/.gitignore +/.gitattributes + +# Bundler config (BUNDLE_* env in the Dockerfile drives the install instead). +/.bundle + +# Environment files and credentials — never bake secrets into an image. +/.env* +!/.env*.erb +/config/master.key +/config/credentials/*.key + +# Logs and tempfiles (keep the dirs, drop the contents). +/log/* +!/log/.keep +/tmp/* +!/tmp/.keep +/tmp/pids/* +!/tmp/pids/.keep + +# JS deps + generated assets: `bun install` + `assets:precompile` rebuild them. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +# Generated by bin/build-css — Tailwind resolves the gem @source globs at build. +/app/assets/stylesheets/tailwind.sources.css +/public/assets + +# Tests + coverage — not needed to run the site in production. +/spec/ +/.rspec +/coverage/ +/spec/examples.txt + +# CI / deploy / Docker meta — not part of the runtime image. +/.github/ +/.kamal/ +/Dockerfile* +/.dockerignore + +# Editor + AI-assistant configs. +/.claude/ +/.cursor/ +/.vscode/ +/.idea/ +/.ruby-lsp/ +/.solargraph.yml + +# Docs-kit's own tooling snapshots (harmless if absent in a consuming site). +/codedb.snapshot +/.worktrees/ diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 02c9431..9c4abbe 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -137,6 +137,80 @@ def capture_stream end end + # The Docker delivery: a gem-owned `.dockerignore` (refreshed every run, like + # the og rake task) and a site-customizable `Dockerfile` (skip-if-exists, like + # the config initializer). The Dockerfile carries a version marker so a `--sync` + # upgrade can flag it as stale against the gem's current template. + describe "Docker files (create_dockerfile + create_dockerignore)" do + before do + build_skeleton + run_generator + end + + it "creates a Dockerfile and a .dockerignore" do + expect(exist?("Dockerfile")).to be(true) + expect(exist?(".dockerignore")).to be(true) + end + + it "stamps the Dockerfile with the gem's version marker (so a --sync can detect staleness)" do + dockerfile = read("Dockerfile") + + expect(dockerfile).to include("docs-kit Dockerfile v#{DocsKit::VERSION}") + end + + it "writes a standalone-site Dockerfile (WORKDIR /rails, whole-app context)" do + dockerfile = read("Dockerfile") + + # A consuming site is a standalone Rails app at the context root — NOT the + # gem's /app + /app/docs monorepo layout. + expect(dockerfile).to include("WORKDIR /rails") + expect(dockerfile).to include("COPY --from=build /rails /rails") + expect(dockerfile).not_to include("/app/docs") + end + + it "uses a multi-stage build that keeps build tooling out of the final image" do + dockerfile = read("Dockerfile") + + expect(dockerfile).to include("FROM base AS build") + # build-essential/git/pkg-config are build-stage only; the final stage + # copies just the bundle + app from the build stage. + expect(dockerfile).to include("build-essential") + expect(dockerfile).to include(%(COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}")) + end + + it "excludes build cruft in .dockerignore (node_modules, git, logs, tmp, specs, coverage)" do + dockerignore = read(".dockerignore") + + %w[node_modules .git log tmp spec coverage].each do |pattern| + expect(dockerignore).to include(pattern) + end + end + + it "keeps the built assets and the lockfile OUT of .dockerignore (the build needs them)" do + dockerignore = read(".dockerignore") + + # Gemfile.lock is required for a reproducible `bundle install --frozen`. + expect(dockerignore).not_to match(/^\s*Gemfile\.lock\s*$/) + end + + it "does not clobber a site's customized Dockerfile on re-run (site-owned, skip-if-exists)" do + write("Dockerfile", "# my hand-tuned Dockerfile\nFROM ruby:3.2\n") + + run_generator + + expect(read("Dockerfile")).to eq("# my hand-tuned Dockerfile\nFROM ruby:3.2\n") + end + + it "refreshes the gem-owned .dockerignore on re-run (not site-owned)" do + write(".dockerignore", "# stale\n") + + run_generator + + expect(read(".dockerignore")).to include("node_modules") + expect(read(".dockerignore")).not_to eq("# stale\n") + end + end + describe "config/initializers/docs_kit.rb" do before do build_skeleton @@ -763,5 +837,35 @@ def render_page(view) expect(output).not_to include("render_page") expect(output).not_to include("IconHelper") end + + it "warns when the site's Dockerfile is stamped with an older docs-kit version" do + build_skeleton + # A site scaffolded by an older docs-kit carries an older version marker. + write("Dockerfile", "# docs-kit Dockerfile v0.9.0\nFROM ruby:3.4-slim\n") + + output = capture_generator(sync: true) + + expect(output).to include("Dockerfile") + expect(output).to include("0.9.0") + expect(output).to include(DocsKit::VERSION) + end + + it "does NOT warn when the site's Dockerfile marker matches the gem version" do + build_skeleton + write("Dockerfile", "# docs-kit Dockerfile v#{DocsKit::VERSION}\nFROM ruby:3.4-slim\n") + + output = capture_generator(sync: true) + + expect(output).not_to match(/Dockerfile.*older|stale.*Dockerfile/i) + end + + it "does NOT warn about a Dockerfile with no docs-kit marker (site brought its own)" do + build_skeleton + write("Dockerfile", "FROM ruby:3.4-slim\n# a hand-written Dockerfile, no marker\n") + + output = capture_generator(sync: true) + + expect(output).not_to match(/Dockerfile is v|Dockerfile.*older/i) + end end end From 24aa9695bda274a0ec18ac2f9c2ae1282c3e122c Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 09:46:43 +0200 Subject: [PATCH 2/2] feat(deploy): front Puma with Thruster in the Docker images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- README.md | 10 ++ docs/Dockerfile | 10 +- docs/app/views/docs/pages/deploy.rb | 6 +- lib/docs_kit/templates/new_site.rb | 5 +- .../docs_kit/install/install_generator.rb | 56 +++++++ .../docs_kit/install/templates/Dockerfile.tt | 17 ++ spec/generators/install_generator_spec.rb | 146 ++++++++++++++++++ 7 files changed, 247 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 69201b2..7737c02 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,16 @@ The generator ships two Docker files: (`# docs-kit Dockerfile v`) so `--sync` can tell you when yours is stale relative to the gem's current template. +When the site bundles `thruster` (a Rails 8 default), the generated Dockerfile +fronts Puma with Thruster (`CMD ["./bin/thrust", "./bin/rails", "server"]`) for +HTTP caching, compression, and X-Sendfile — and the generator scaffolds the +`bin/thrust` binstub if the app lacks one, since the exec-form CMD needs the +file to exist in the image. Thruster listens on the routed port +(`HTTP_PORT=3000` — Kamal's `app_port`) and proxies to Puma on `TARGET_PORT=3001`. +Without thruster in the *production* bundle (absent, or only in a +development/test group that `BUNDLE_WITHOUT` excludes) the CMD falls back to +plain `rails server` — never a thrust CMD that would crash at boot. + When `--sync` reports your Dockerfile is behind, compare it against the shipped template and pull in the improvements (or replace it wholesale if you never customized it): diff --git a/docs/Dockerfile b/docs/Dockerfile index 210403c..e18852f 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -72,4 +72,12 @@ RUN groupadd --system --gid 1000 rails && \ USER 1000:1000 EXPOSE 3000 -CMD ["./bin/rails", "server", "-b", "0.0.0.0"] +# Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on +# HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which +# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's +# `app_port: 3000` in config/deploy.yml, the EXPOSE above) — Thruster's default +# is 80, which the non-root user can't reliably bind AND which kamal-proxy would +# never route to, silently bypassing Thruster straight into Puma. +ENV HTTP_PORT="3000" \ + TARGET_PORT="3001" +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/docs/app/views/docs/pages/deploy.rb b/docs/app/views/docs/pages/deploy.rb index ee4e985..9623d49 100644 --- a/docs/app/views/docs/pages/deploy.rb +++ b/docs/app/views/docs/pages/deploy.rb @@ -46,7 +46,11 @@ def content code { ".git" } plain ", " code { "node_modules" } - plain ", logs, specs, or coverage)." + plain ", logs, specs, or coverage). When the site bundles " + code { "thruster" } + plain " (a Rails 8 default), " + code { "bin/thrust" } + plain " fronts Puma with HTTP caching, compression, and X-Sendfile — Thruster listens on the routed port (3000) and proxies to Puma." end p do plain "The " diff --git a/lib/docs_kit/templates/new_site.rb b/lib/docs_kit/templates/new_site.rb index eb4e05e..ce10734 100644 --- a/lib/docs_kit/templates/new_site.rb +++ b/lib/docs_kit/templates/new_site.rb @@ -4,7 +4,10 @@ # Rails application template for a docs-kit docs site. Run via: # -# rails new my-docs --minimal -a propshaft -j importmap --skip-... -m new_site.rb +# rails new my-docs -a propshaft -j importmap --skip-... -m new_site.rb +# +# (NOT --minimal — that strips JS the shell needs AND the thruster gem the +# generated Dockerfile fronts Puma with; exe/docs-kit passes the right flags.) # # or, more simply, via the `docs-kit new` CLI (exe/docs-kit) which supplies the # right `rails new` flags. It: diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 8384b0f..054fa39 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -203,6 +203,25 @@ def create_dockerignore copy_file "dockerignore", ".dockerignore", force: true end + # When the site bundles thruster but lacks the binstub, create it — the + # Dockerfile's exec-form `CMD ["./bin/thrust", ...]` 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 this, the + # image builds green and the container crashes at boot. Skip-if-exists: a + # hand-tuned binstub is never touched. + def create_thrust_binstub + return unless gemfile_bundles_thruster? && !thrust_binstub? + + create_file "bin/thrust", <<~RUBY + #!/usr/bin/env ruby + require "rubygems" + require "bundler/setup" + + load Gem.bin_path("thruster", "thrust") + RUBY + chmod "bin/thrust", 0o755 + end + def wire_assets_and_package_json # Serve the bun-built CSS from app/assets/builds. inject_into_file "config/initializers/assets.rb", @@ -490,6 +509,43 @@ def docker_service def ruby_version_arg RUBY_VERSION[/\d+\.\d+\.\d+/] || "3.4.2" end + + # True when the site bundles Thruster (HTTP caching + compression + + # X-Sendfile in front of Puma) — a Rails 8 `rails new` ships bin/thrust + + # the gem, but docs_kit:install also runs on older apps where a thrust CMD + # would crash the container at boot (Gem.bin_path raises). Decides which + # CMD Dockerfile.tt emits. A committed binstub is authoritative; otherwise + # the Gemfile decides (and create_thrust_binstub scaffolds the binstub). + def thruster? + thrust_binstub? || gemfile_bundles_thruster? + end + + def thrust_binstub? + File.exist?(File.join(destination_root, "bin/thrust")) + end + + # True when the Gemfile declares thruster where the PRODUCTION bundle sees + # it. The Dockerfile sets BUNDLE_WITHOUT="development:test", so a + # `group :development do ... end` entry (or an inline `group:` kwarg) must + # NOT count — the gem wouldn't be installed and thrust would raise at boot. + # Line-level block tracking: any `... do` pushes, `end` pops; a gem line + # counts only outside every group block (nested non-group blocks are fine). + def gemfile_bundles_thruster? + gemfile = File.join(destination_root, "Gemfile") + return false unless File.exist?(gemfile) + + block_stack = [] + File.foreach(gemfile) do |line| + if line.match?(/\bdo\s*(\|[^|]*\|)?\s*$/) + block_stack.push(line.match?(/^\s*group\b/)) + elsif line.match?(/^\s*end\b/) + block_stack.pop + elsif block_stack.none? && line.match?(/^\s*gem\s+["']thruster["']/) && !line.match?(/\bgroup:/) + return true + end + end + false + end end end end diff --git a/lib/generators/docs_kit/install/templates/Dockerfile.tt b/lib/generators/docs_kit/install/templates/Dockerfile.tt index 17ac42e..c4d2b40 100644 --- a/lib/generators/docs_kit/install/templates/Dockerfile.tt +++ b/lib/generators/docs_kit/install/templates/Dockerfile.tt @@ -76,4 +76,21 @@ RUN groupadd --system --gid 1000 rails && \ USER 1000:1000 EXPOSE 3000 +<% if thruster? -%> +# Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on +# HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which +# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's +# `app_port`, the EXPOSE above) — Thruster's default 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. +ENV HTTP_PORT="3000" \ + TARGET_PORT="3001" +CMD ["./bin/thrust", "./bin/rails", "server"] +<% else -%> +# No Thruster in this app's bundle — plain Puma. To front it with Thruster +# (HTTP caching/compression/X-Sendfile): add `gem "thruster"`, then delete this +# file and re-run `bin/rails g docs_kit:install` — the generator never +# overwrites an existing Dockerfile, so a re-run alone won't update it. (It +# also scaffolds the bin/thrust binstub the thrust CMD needs.) CMD ["./bin/rails", "server", "-b", "0.0.0.0"] +<% end -%> diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 9c4abbe..f7b9914 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -193,6 +193,16 @@ def capture_stream expect(dockerignore).not_to match(/^\s*Gemfile\.lock\s*$/) end + it "falls back to a plain rails server CMD when the site has no Thruster" do + # The default skeleton has no bin/thrust and no Gemfile — a thrust CMD + # would crash the container at boot (Gem.bin_path raises), so the plain + # server CMD is the only safe default. + dockerfile = read("Dockerfile") + + expect(dockerfile).to include(%(CMD ["./bin/rails", "server", "-b", "0.0.0.0"])) + expect(dockerfile).not_to include(%(CMD ["./bin/thrust")) + end + it "does not clobber a site's customized Dockerfile on re-run (site-owned, skip-if-exists)" do write("Dockerfile", "# my hand-tuned Dockerfile\nFROM ruby:3.2\n") @@ -211,6 +221,142 @@ def capture_stream end end + # Thruster (HTTP caching + compression + X-Sendfile in front of Puma) is used + # when the site actually bundles it — a `rails new` on Rails 8 ships + # `gem "thruster"` + bin/thrust, but docs_kit:install also runs on older apps + # where a thrust CMD would crash the container at boot. Detection: bin/thrust + # OR a Gemfile thruster entry. + describe "Dockerfile Thruster CMD (thruster-aware sites)" do + context "when the site has a bin/thrust binstub" do + before do + build_skeleton + write("bin/thrust", "#!/usr/bin/env ruby\nload Gem.bin_path(\"thruster\", \"thrust\")\n") + run_generator + end + + it "fronts Puma with Thruster" do + expect(read("Dockerfile")).to include(%(CMD ["./bin/thrust", "./bin/rails", "server"])) + end + + it "pins HTTP_PORT to 3000 (kamal-proxy's app_port; non-root can't rely on 80)" do + dockerfile = read("Dockerfile") + + # Thruster's default HTTP_PORT is 80: as USER 1000 the bind can fail, and + # kamal-proxy routes to app_port 3000 — which would hit Puma directly and + # silently bypass Thruster. HTTP_PORT=3000 keeps Thruster on the routed + # port; TARGET_PORT moves Puma out of the way. + expect(dockerfile).to match(/HTTP_PORT="?3000"?/) + expect(dockerfile).to match(/TARGET_PORT="?3001"?/) + end + + it "does not also emit the plain-server fallback CMD" do + expect(read("Dockerfile")).not_to include(%(CMD ["./bin/rails", "server", "-b", "0.0.0.0"])) + end + end + + context "when the site's Gemfile bundles thruster (no binstub yet)" do + before do + build_skeleton + write("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + gem "thruster", require: false + RUBY + run_generator + end + + it "fronts Puma with Thruster" do + expect(read("Dockerfile")).to include(%(CMD ["./bin/thrust", "./bin/rails", "server"])) + end + + it "creates the bin/thrust binstub the CMD needs (or the container crashes at boot)" do + # `bundle install` installs the gem, not app binstubs, and `COPY . .` can't + # copy a file the repo doesn't have — an exec-form CMD pointing at a + # missing ./bin/thrust builds green and then dies at container start. + expect(exist?("bin/thrust")).to be(true) + expect(read("bin/thrust")).to include(%(Gem.bin_path("thruster", "thrust"))) + + mode = File.stat(File.join(destination, "bin/thrust")).mode & 0o777 + expect(mode).to eq(0o755) + end + end + + context "when the site already has its own bin/thrust" do + before do + build_skeleton + write("bin/thrust", "#!/usr/bin/env ruby\n# hand-tuned\nload Gem.bin_path(\"thruster\", \"thrust\")\n") + run_generator + end + + it "never overwrites the existing binstub" do + expect(read("bin/thrust")).to include("# hand-tuned") + end + end + + context "when thruster is only in a group BUNDLE_WITHOUT excludes" do + before do + build_skeleton + write("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + + group :development do + gem "thruster", require: false + end + RUBY + run_generator + end + + it "keeps the plain rails server CMD (the production bundle won't have the gem)" do + # BUNDLE_WITHOUT="development:test" means Gem.bin_path("thruster") raises + # at boot — a dev-group entry must NOT trigger the thrust CMD. + dockerfile = read("Dockerfile") + + expect(dockerfile).to include(%(CMD ["./bin/rails", "server", "-b", "0.0.0.0"])) + expect(dockerfile).not_to include(%(CMD ["./bin/thrust")) + end + + it "does not scaffold a binstub for a gem the production bundle excludes" do + expect(exist?("bin/thrust")).to be(false) + end + end + + context "when thruster uses the inline group: kwarg" do + before do + build_skeleton + write("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + gem "thruster", require: false, group: :development + RUBY + run_generator + end + + it "keeps the plain rails server CMD" do + expect(read("Dockerfile")).to include(%(CMD ["./bin/rails", "server", "-b", "0.0.0.0"])) + expect(read("Dockerfile")).not_to include(%(CMD ["./bin/thrust")) + end + end + + context "when the site's Gemfile has no thruster" do + before do + build_skeleton + write("Gemfile", <<~RUBY) + source "https://rubygems.org" + gem "rails" + RUBY + run_generator + end + + it "keeps the plain rails server CMD" do + dockerfile = read("Dockerfile") + + expect(dockerfile).to include(%(CMD ["./bin/rails", "server", "-b", "0.0.0.0"])) + expect(dockerfile).not_to include(%(CMD ["./bin/thrust")) + end + end + end + describe "config/initializers/docs_kit.rb" do before do build_skeleton