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
4 changes: 2 additions & 2 deletions .claude/rules/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Two builder tests are known-failing on Apple Silicon only (host-arch dependent)

| What | Grammar | Example | Push |
|---|---|---|---|
| Gem (this repo) | `dash-v<upstream>.<n>` | `dash-v2.12.0.1` | `git push origin tag dash-v2.12.0.1` |
| Gem (this repo) | `dash-v<semver>` (own major from 3.0.0) | `dash-v3.0.0` | `git push origin tag dash-v3.0.0` |
| Proxy image (kamal-proxy) | `v<upstream-base>.<n>` | `v0.9.2.1` | `git push origin tag v0.9.2.1` |

- **NEVER** a bare `v<version>` tag — upstream owns that namespace on both repos
Expand All @@ -96,7 +96,7 @@ script/release-dash v0.9.2.1

# 2. this repo, on dash — confirm MINIMUM_VERSION matches, then:
bin/test
bin/release-dash 2.12.0.1
bin/release-dash 3.0.0
```

Full procedure, conflict playbook, and sync runbook: `.claude/rules/upstream-sync.md`.
Expand Down
8 changes: 4 additions & 4 deletions .claude/rules/upstream-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ git checkout dash && git merge feat/loadbalancing && <tests> && git push

| File | Resolution |
|---|---|
| `lib/kamal/version.rb` | take upstream's — the fork version is only ever written by `bin/release-dash` at release time |
| `lib/kamal/version.rb` | keep oursfrom 3.0.0 the fork declares its own major, so upstream's version never lands here; `bin/release-dash` is the only writer |
| `Gemfile.lock` | take either side, run `bundle install`, commit the result |
| `lib/kamal/configuration/proxy/run.rb` (`MINIMUM_VERSION`) | upstream bumped their proxy: release the proxy fork first (`v<new-base>.1`), then set that tag here |
| `lib/kamal/configuration/proxy/run.rb` (repository) / `boot.rb` (`repository_name`) | keep `ghcr.io/mhenrixon` |
Expand All @@ -47,10 +47,10 @@ docker buildx imagetools inspect ghcr.io/mhenrixon/kamal-proxy:v0.9.2.1 # amd6
# 2. this repo, on dash:
# ensure Kamal::Configuration::Proxy::Run::MINIMUM_VERSION == that tag
bin/test # full suite incl. integration
bin/release-dash 2.12.0.1 # version.rb + Gemfile.lock, tag dash-v2.12.0.1, gem push dash
bin/release-dash 3.0.0 # version.rb + Gemfile.lock, tag dash-v3.0.0, gem push dash
```

Tag grammar: gem tags `dash-v<upstream>.<n>`, proxy image tags `v<upstream-base>.<n>`. Gem versions are four-segment `<upstream>.<n>` — `Gem::Version` sorts them above the upstream base and below its next release. Never `-suffix` tags: Gem::Version treats `-` as a prerelease marker that sorts BELOW the base and breaks the proxy minimum-version check.
Tag grammar: gem tags `dash-v<version>`; from 3.0.0 the gem version is plain three-segment semver (`3.0.0`, tag `dash-v3.0.0`) — the fork declares its own major and no longer tracks upstream's number (the legacy four-segment `<upstream>.<n>` grammar covers only the pre-3.0 line). Proxy image tags stay `v<upstream-base>.<n>` — they still track upstream kamal-proxy releases. Never `-suffix` tags: Gem::Version treats `-` as a prerelease marker that sorts BELOW the base and breaks the proxy minimum-version check.

## Upstreaming a feature

Expand All @@ -74,6 +74,6 @@ Rejected-by-basecamp features (see `ROADMAP.md`'s "safe moat" list) are never up
## Never

- commit to `main` or rebase published branches
- `git push --tags` — single-tag pushes only (`git push origin tag dash-v2.12.0.1`)
- `git push --tags` — single-tag pushes only (`git push origin tag dash-v3.0.0`)
- release the gem while `MINIMUM_VERSION` names an unpublished proxy tag
- edit `kamal.gemspec`, `bin/release`, or other upstream-owned files
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ mhenrixon's maintained fork of [kamal](https://github.com/basecamp/kamal) — de

1. **NO commits on `main`** — it is a fast-forward-only mirror of basecamp/kamal
2. **NO edits to `kamal.gemspec` or `bin/release`** — upstream files kept pristine so syncs never conflict; the fork owns `dash.gemspec` and `bin/release-dash`
3. **NO `v*` git tags** — upstream owns that namespace; fork gem tags are `dash-v<version>`
4. **NO `git push --tags`** — it would push fetched upstream tags to the fork; push single tags (`git push origin tag dash-v2.12.0.1`)
3. **NO `v*` git tags** — upstream owns that namespace; fork gem tags are `dash-v<version>` (own semver major from 3.0.0)
4. **NO `git push --tags`** — it would push fetched upstream tags to the fork; push single tags (`git push origin tag dash-v3.0.0`)
5. **NO suffix proxy versions** like `v0.9.2-dash.1` — Gem::Version parses `-` as a prerelease, which sorts OLDER than the base and hard-fails `kamal proxy boot`
6. **NO gem release before the proxy image exists** — the tag named by `Kamal::Configuration::Proxy::Run::MINIMUM_VERSION` must be pullable from ghcr.io first
7. **NO rebasing published branches** — merge forward; history is shared
Expand All @@ -36,7 +36,7 @@ mhenrixon's maintained fork of [kamal](https://github.com/basecamp/kamal) — de
bin/test # Full suite (integration needs Docker + published proxy image)
bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].grep_v(/integration/).each { |f| require File.expand_path(f) }' # Unit tests only
bundle exec rubocop --parallel # Lint
bin/release-dash 2.12.0.1 # Release the dash gem (proxy image must exist first)
bin/release-dash 3.0.0 # Release the dash gem (proxy image must exist first)
git fetch upstream --tags --prune # Start of every sync
```

Expand Down
9 changes: 6 additions & 3 deletions bin/release-dash
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

# Release the fork gem "dash" to rubygems.org.
#
# Versions are four-segment: <upstream-version>.<fork-counter>, e.g. 2.12.0.1.
# From 3.0.0 the fork declares its own major: versions are ordinary
# three-segment semver (e.g. 3.0.0) and no longer track upstream's number.
# The legacy four-segment <upstream>.<counter> grammar (e.g. 2.12.0.1) stays
# accepted for patch releases of the pre-3.0 line.
# Git tags are dash-v<version> - upstream owns the v<version> namespace.
#
# Ordering rule: the proxy image tag named by
Expand All @@ -13,8 +16,8 @@ set -euo pipefail

VERSION=$1

if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: version must be four-segment, e.g. 2.12.0.1"
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
echo "Error: version must be three-segment (3.0.0) or the legacy four-segment form (2.12.0.1)"
exit 1
fi

Expand Down
62 changes: 62 additions & 0 deletions docs/proxy-basics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Proxy basics

The ten `proxy:` keys most apps ever need. The full reference — six tiers,
from these essentials down to the proxy container's own runtime — lives in
[`lib/kamal/configuration/docs/proxy.yml`](../lib/kamal/configuration/docs/proxy.yml),
which is also the validation schema: any key that file doesn't show is a key
kamal rejects.

```yaml
proxy:
# Which hostnames route to this app. One of host / hosts.
host: app.example.com
hosts:
- app.example.com
- www.example.com

# Automatic HTTPS via Let's Encrypt (needs a host and an open :443),
# or a hash for custom certificates / on-demand TLS / mTLS.
ssl: true

# Redirect HTTP to HTTPS (default true when ssl is on).
ssl_redirect: true

# Use Let's Encrypt's staging environment while testing.
ssl_staging: true

# The port your app container listens on (default 80).
app_port: 3000

# What the proxy polls until the new container is ready.
healthcheck:
path: /up
interval: 1
timeout: 5

# Multi-host apps: which host fronts the fleet. Auto-activates on the
# primary role's first host when it has more than one host; set false to
# opt out, or name a dedicated machine.
loadbalancer: lb.example.com

# Reboot the proxy automatically when its configuration drifts
# (default true; zero-downtime with run.port_holder).
reboot_on_deploy: true
```

## Where options apply with a loadbalancer

Every proxy option lives at exactly one layer — the loadbalancer (TLS,
access control, caching, affinity), the per-host proxies (headers, rewrites,
compression, sleep), or deliberately both (health checks, timeouts,
buffering). You don't place them; kamal does. The per-key table is at the top
of the full reference.

## When you need more

| You want | Look at |
|---|---|
| Header rules, redirects, rewrites, canonical host | §2 Traffic & routing |
| Basic auth, IP allow lists, rate limiting, dynamic TLS domains | §3 Security & access |
| Timeouts, connection pools, buffering, compression, response caching | §4 Performance & observability |
| Read/write splitting, session affinity, scale-to-zero | §5 Fleet |
| Ports, ACME/Let's Encrypt DNS credentials, cache store, zero-downtime reboots, escape hatches | §6 Proxy container (`run`) |
76 changes: 76 additions & 0 deletions docs/release-notes/3.0.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# dash 3.0.0

The first release where the fork declares its own major. Pairs with
`ghcr.io/mhenrixon/kamal-proxy:v1.0.0.1` — the gem will not boot an older
proxy. Everything in R1–R7 plus the v3.0.0 gate (#93) ships here; only
`dash 2.12.0` was ever on rubygems, so this is the surface's first and final
freeze.

## Act on these (behavior changes from 2.12.0)

- **The first deploy after upgrading reboots the proxy fleet once, host by
host.** The drift digest's composition changed (digest schema v1 +
secret-name hashing), so every host converges on the new format with one
reboot. With `proxy: run: port_holder: true` that reboot is zero-downtime —
generations overlap on the published ports via the port-holder handoff.
Without it (and for the loadbalancer container) it is a brief per-host gap.
Adopting port_holder itself takes one final brief-gap reboot; after that,
config and version reboots never drop a request.
- **Prune retention is now per role and destination.** `kamal prune`
previously kept the newest N containers per *host*; it now keeps N per
role+destination, so multi-role hosts retain more containers than before.
If your hosts are disk-tight, lower `retain_containers`.
- **Accessory image tag matching is now exact.** Accessory commands
previously matched running containers by image-name *prefix*; a deployment
relying on prefix matching (e.g. distinguishing `db` from `db-backup` by
prefix alone) must name images exactly.
- **Readiness warnings got teeth (R6).** Non-proxied roles without a
`healthcheck:` block, `boot/wait` without `boot/limit`, rate limiting that
cannot identify clients, `intercept_errors` without `error_pages_path`, and
`target/max_idle_conns: 0` now warn loudly at config time. The healthcheck
warning will become an error in a later release — add a block or opt out
with `healthcheck: false`.

## Renames and cuts (config update required)

No aliases — the old spellings fail validation with an unknown-key error:

| 2.12.0-era key | 3.0.0 |
|---|---|
| `tls_domains` | `ssl_domains` |
| `path_timeouts` | `path_response_timeouts` |
| `read_targets` / `read_target_websockets` / `writer_affinity_timeout` | `read_routing: {targets, websockets, writer_affinity_timeout}` |
| `tls.on_demand_url` | `ssl.on_demand_url` |
| `tls.client_ca_path` (local file path) | `ssl.client_ca_pem` (secret name in `.kamal/secrets`) |
| `tls.acme_cache_path` | cut — the proxy default already persists in the config volume |
| `run.cache.lease_ttl`, `run.cache.lease_wait` | cut — proxy defaults; `run.flags` is the escape hatch |
| `scope_cookie_paths` | cut |

## Highlights

- **Loadbalancer layering contract.** Every proxy option now lives at exactly
one layer under load balancing — edge, per-app, or deliberately both —
enforced by a canary test and documented per key at the top of the proxy
reference. Session affinity, canonical-host redirects, response caching and
read routing move to the edge; sleep and compression stay next to the app.
- **Secrets stay secret.** Basic-auth credentials print as `[REDACTED]`;
the cache store URL travels in a 0600 env file (`CACHE_STORE`) instead of
the `docker run` command line; ACME DNS credentials share the same file.
Rotating a secret's *value* doesn't move the drift digest — run
`kamal proxy reboot` after a rotation.
- **Zero-downtime proxy reboots** (`run.port_holder: true`) via the
generation handoff; reboots are verified against `kamal-proxy list --json`.
- **The loadbalancer grew up:** it boots with the full proxy run surface
(ACME issuance, cache store, apps-config mount, state volume at the right
path — dedicated-host routes now survive reboots), gets TLS material
uploaded to its host, and has the same drift detection/auto-reboot as the
proxy hosts.
- **Progressive-disclosure docs**: a ten-key [Proxy basics](../proxy-basics.md)
page, and a reference reorganized into six tiers from essentials to the
proxy container's runtime.

## Versioning

From 3.0.0 the gem uses its own three-segment semver (`dash-v3.0.0` tags) and
no longer tracks upstream kamal's version. Proxy image tags still track
upstream kamal-proxy (`v<upstream-base>.<n>`, e.g. `v1.0.0.1`).
Loading