Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ 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<VERSION>`) 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):

```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)

Expand Down Expand Up @@ -751,8 +784,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:
Expand Down
39 changes: 27 additions & 12 deletions docs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 --------------------------------------------------------------
Expand All @@ -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

Expand All @@ -65,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"]
43 changes: 43 additions & 0 deletions docs/app/views/docs/pages/deploy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,49 @@ 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). 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 "
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
Expand Down
51 changes: 11 additions & 40 deletions lib/docs_kit/templates/new_site.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -101,45 +104,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
Expand Down
Loading
Loading