From 70d2f553b3dc8fab64f3517dd917c4c90b652264 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Fri, 9 Oct 2026 14:51:13 -0400 Subject: [PATCH] Simplified manager contracts, recovery state and override inventory ref https://linear.app/ghost/issue/PLA-511/simplify-and-harden-the-next-docker-manager-following-branch-review The next-docker documentation repeated current contracts alongside completed work and superseded designs. Give operator guidance, architecture and the remaining roadmap distinct homes so maintainers update fewer copies. Derive additional overrides from Compose's ordered file list. This also fixes a nested compose.override.yml being omitted from backups when no root override exists. Preserve the startup boundary that prevents update recovery from overwriting accepted writes. - docs/architecture.md: own current boundaries and recovery invariants. - docs/ghost-cli-replacement.md: retain remaining requirements and gates. - docs/install.md, docs/configuration.md, docs/bundle-v1.md: consolidate operator contracts and remove stale implementation history. - AGENTS.md, README.md, help: route readers to authoritative guidance. - manager/src/compose.ts, resolved.ts, backup.ts, restore.ts: derive one override inventory and avoid repeated state and transformations. - manager/src/commands/self-update.ts: represent the recovery boundary directly and remove unused update context. - manager/test/backup.test.ts, integration/compose.test.ts: verify nested overrides survive backup/restore and merge through real Compose. - manager/test/integration/resolved.test.ts, services.test.ts: remove the duplicated resolved-state fixture field. - manager/src comments and runtime/workflow entry points: link current architecture instead of obsolete plan sections. - .env.example, ghost.env.example, pages/index.html: correct stale usage. - tests/e2e/self-update.sh: describe the existing write-retention checks. --- .env.example | 10 +- .github/workflows/image.yml | 2 +- .github/workflows/launcher.yml | 2 +- AGENTS.md | 266 +-- README.md | 70 +- docs/architecture.md | 256 +++ docs/bundle-v1.md | 79 +- docs/configuration.md | 173 +- docs/ghost-cli-replacement.md | 2100 +++------------------ docs/install.md | 79 +- ghost-docker | 2 +- ghost.env.example | 2 +- help | 99 +- manager/Dockerfile | 2 +- manager/entrypoint.sh | 2 +- manager/package.json | 2 +- manager/src/asroot.ts | 2 +- manager/src/backup.ts | 48 +- manager/src/backup/manifest.ts | 8 +- manager/src/cli.ts | 2 +- manager/src/commands/backup.ts | 2 +- manager/src/commands/common.ts | 2 +- manager/src/commands/doctor.ts | 2 +- manager/src/commands/self-update.ts | 76 +- manager/src/compose.ts | 22 +- manager/src/config.ts | 20 +- manager/src/context.ts | 2 +- manager/src/errors.ts | 2 +- manager/src/import.ts | 2 +- manager/src/lock.ts | 2 +- manager/src/meta.ts | 16 +- manager/src/payload.ts | 2 +- manager/src/recovery.ts | 16 +- manager/src/release.ts | 2 +- manager/src/resolved.ts | 10 +- manager/src/restore.ts | 47 +- manager/src/site.ts | 2 +- manager/src/verify.ts | 27 +- manager/src/versions.ts | 6 +- manager/src/writers.ts | 21 +- manager/test/backup.test.ts | 40 +- manager/test/integration/compose.test.ts | 36 +- manager/test/integration/resolved.test.ts | 1 - manager/test/services.test.ts | 1 - pages/index.html | 2 +- scripts/lib/release.ts | 2 +- tests/e2e/self-update.sh | 10 +- 47 files changed, 896 insertions(+), 2683 deletions(-) create mode 100644 docs/architecture.md diff --git a/.env.example b/.env.example index b6f95d30..252f5c68 100644 --- a/.env.example +++ b/.env.example @@ -41,8 +41,7 @@ URL="https://example.com" # ADMIN_URL="https://admin.example.com" # Requested Ghost repository/tag. Installation pins the resolved artifact below. -# The `next` variants install Ghost directly under /home/ghost rather than the older -# /var/lib/ghost/versions/ layout. +# Install and import require the `next` layout; see docs/configuration.md. GHOST_IMAGE="ghost" GHOST_VERSION="6-next-alpine" @@ -51,9 +50,8 @@ GHOST_VERSION="6-next-alpine" # Without it, manually configured sites use GHOST_IMAGE:GHOST_VERSION. # GHOST_IMAGE_REF="ghost@sha256:..." -# Paths inside the Ghost image. The defaults match the `next` variants. Pinning -# a GHOST_VERSION with the older layout means setting both of these to -# /var/lib/ghost/content and /var/lib/ghost/current/core/server/data/tinybird. +# Paths inside the Ghost image, derived by install from its declared layout. +# These defaults match the `next` variants. # GHOST_CONTENT_PATH="/home/ghost/content" # GHOST_TINYBIRD_PATH="/home/ghost/core/server/data/tinybird" @@ -75,7 +73,7 @@ HTTPS_PORT="443" RESTART_POLICY="unless-stopped" # --- Database ------------------------------------------------------------- -# Parameterized now so backup, restore and import all share one connection +# Backup, restore and import share this connection # contract. The defaults are correct for a single-site installation. DATABASE_HOST="db" DATABASE_PORT="3306" diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 8b81e74b..e88d3056 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -9,7 +9,7 @@ name: "Manager image" # # A release is published when the Release workflow calls this one with its # tag, or when a release tag is pushed by hand. The moving tags only resolve a -# channel to a release; a site always runs a digest (plan §2.7). +# channel to a release; a site always runs a digest (docs/architecture.md#releases-and-compatibility). on: push: branches: diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 464e6852..fcd875d3 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -5,7 +5,7 @@ name: "Served launcher" # GitHub Pages from this workflow. There is no gh-pages branch: each deploy is # the whole site, built from the release. The custom domain is set in the # repository's Pages settings (a CNAME file is ignored for an Actions deploy). -# Only the newest release is served, and never by hand (plan §2.7). Then +# Only the newest release is served, and never by hand (docs/architecture.md#releases-and-compatibility). Then # tests what is served, not the checkout's copy: it installs the newest beta, # and an explicit --release. # diff --git a/AGENTS.md b/AGENTS.md index 535b1dd1..15d0dccf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,232 +1,38 @@ # AGENTS.md -Guidance for coding agents working in this repository. This is the only such -file: there is no separate `CLAUDE.md`. - -## What this branch is - -`next-docker` rebuilds the self-hosted Ghost Docker setup around a **manager -image**: a TypeScript CLI in a container, started by a launcher (`ghost-docker`) -that needs only Docker and bash on the host. The plan, with the -architecture, the contracts and the step breakdown, is -[docs/ghost-cli-replacement.md](docs/ghost-cli-replacement.md). **Read the -step you are implementing and the §2 contracts it names before writing code.** - -Branches: - -- `main` — the released layout. Existing installations update from it with - `git pull`, so nothing here merges into it until the legacy migration (S6b). -- `next-docker` — this branch. Pull requests target it. -- `next` — frozen. A complete bash implementation of the configuration - foundation, installer and local bundle import, with tests. It is the - behaviour reference for steps that port it (`git show origin/next:`). - Do not add to it, and do not port the bash; port what it does. - -## Current state - -Steps N1–N3, S4, S5b, S5c and S6a: the stack's files and contracts, the -launcher and manager image, releases, and the commands: `install` (local and -production, from the image or a clone, from a channel or a release, and -`--import` of a local Ghost-CLI site's bundle), `self-update` (between releases, -for image-mode sites; a clone is refused, and updated with git and Compose), `backup` and `restore` (over the site, or into a new -directory), `config get|set|validate`, `check`, `info`, `list`, plus -`version`, `doctor` and `help`. Every other `./ghost-docker ...` command or -option in the documents is the planned interface: it does not exist until its -step lands (an unknown command or option exits 2), and the plan says which step -delivers it. `docs/install.md` describes what exists. - -- `ghost-docker` (bash) is the only host code. It checks Docker, chooses the - image, and `docker run`s it (plan §2.10). Add no logic to it that the - manager could hold. Windows is WSL2 only; there is no native launcher. - There is no `--migrate`: moving a Ghost-CLI site is documented as - `ghost stop`, `ghost migrate-export`, `install --import` (S5c). It reads - `--channel`, `--release` and `--to` to choose the image (and passes them on); - `self-update` from a pinned site runs the newest release on the site's channel - (`GD_PINNED_CHANNEL`), not its pin. It mounts `--import`'s bundle, and - `restore`'s backup when it is outside the site, read-only at its own path. -- `manager/` is the CLI: TypeScript run directly by Node (types stripped, no - build step, so `erasableSyntaxOnly`), with dependencies installed by pnpm - (version pinned in `package.json`; `npm i -g corepack && corepack enable` - provides it). A command's options are a zod object keyed in camelCase, - each with its brief as `.describe()` (`src/command.ts`): the command line - (`--admin-domain`, a boolean as a flag) is derived from it and parsed by - Node's own `util.parseArgs` (strict), then the values by the schema, which - refuses a bad value or combination as a usage error. `src/cli.ts` holds the - dispatch table (each command's options, its positional arguments and its - handler), renders help from it and maps errors to exit codes. Handlers get - the schema's output and return the exit status. `src/commands/` holds what each command does, `src/context.ts` the - `GD_*` environment the launcher passes, `src/io.ts` the seam tests - substitute. Programs are run with execa, the daemon is spoken to directly (below). -- The manager talks to the daemon over the **Engine API** on the mounted - socket (`src/docker/`: undici transport, zod-typed endpoints, a `runOnce` - for one-shot containers). It does not shell out to the `docker` CLI; the - CLI is in the image for Compose only, which has no API (`src/compose.ts`). -- `manager/entrypoint.sh` drops from root to the caller's uid and gid, keeping - the Docker socket's group. It does not drop under rootless Docker. -- `manager/Dockerfile` builds from the repository root and also carries the - stack's files under `/opt/ghost-docker/stack` (the payload `install` writes in - image mode) and the launcher under `/opt/ghost-docker/launcher`. -- `src/project.ts` is a site's Compose project name, chosen once by install - (production: the domain's; local: the directory's and a random - adjective-animal pair no project on the daemon has), and who owns a project: - a command that changes a site first refuses one whose containers another - directory made (`com.docker.compose.project.working_dir`). - `src/resolved.ts` is the site as Compose resolves it from every file it - runs with (project name, files, overrides, each service's image, mounts and - networks) and as the daemon runs it (each container's image and image ID). - Backup, restore and check read the site from it, not from `.env`. -- `src/env.ts` is the one dotenv encoder and parser; nothing else reads or - writes `.env` or `ghost.env`. `src/fs.ts` writes atomically. `src/site.ts` - holds file names, modes and profiles; `src/config.ts` validation; - `src/caddy.ts` fills `templates/site.caddy`, the routes install writes; `src/meta.ts` the metadata schema; `src/ghost.ts` - image resolution; `src/payload.ts` the image-mode files and pinned launcher. -- `src/bundle/manifest.ts` is the bundle v1 manifest as a zod schema. It - imports nothing but zod, so the exporter in Ghost-CLI can share it; keep - importer policy out of it. `src/bundle/stage.ts` unpacks and validates a - bundle in staging; `src/import.ts` holds the import's steps and `Importing`, - what `install --import` adds to an installation, with the pieces that - print nothing in `src/import/config.ts` (what ghost.env carries over) and - `src/import/database.ts` (the client, the DEFINER filter, row counts). - `src/undo.ts` records what an installation created and removes it on - failure; an import keeps that record in its marker file. -- Releases (`src/release.ts`): only `vX.Y.Z` and `vX.Y.Z-beta.N`, ordered - by semver. The manager holds only that; cutting releases is `scripts/`, a - package of its own that is not in the image. The Release workflow cuts them - as Ghost and Ghost-CLI do (`scripts/release.ts`: ✨ commits make a minor, - anything else a patch; release-note emojis select the notes), then publishes the image, its - moving `beta`/`stable` tags (`image.yml`), and the launcher to GitHub Pages as - `https://docker.ghost.org/install.sh` (`launcher.yml`). Every release is a - beta until S6b. -- `src/commands/self-update.ts` moves a site to the release it runs as: the - release's images pulled while the site runs, a snapshot in - `.ghost-docker-update/`, the writers paused and a backup taken, managed - files by checksum (an edited one is kept beside `.new`), validate, - pull, `up --wait`, verify. A failure before the release's services start - puts the files back and resumes the writers (restored); after, it stops - them, puts the files back, loads nothing, and names the backup for the - operator to restore. It refuses a clone, - whose update is git's and Compose's. `src/lock.ts` is the site lock (§2.2). -- Until the first stable release, `.ghost-docker.json`, the backup manifest and - the launcher's `GD_*` contract are development formats (plan §2.7, - "Compatibility"): change them directly, keep the schemas strict, and add no - defaults, adapters or migrations for earlier shapes. The layout on `main` is - released, and S6b's migration of it is not covered by this. -- `src/backup.ts` takes a backup (§2.5): a mysqldump of each of the site's - databases as its own user, the content as a tarball, the site's files, and - `backup/manifest.ts` (images, row counts, checksums), written as - `backups/..partial` and renamed only once every dump has loaded into a - scratch MySQL (a `runOnce` of the site's db image) and the archive lists. - `src/writers.ts` pauses Ghost and ActivityPub: a consistent backup for its - capture alone, an operation that may load its backup back (self-update, - later Ghost upgrades) from before the backup until its own services start - or the site is put back. `src/restore.ts` restores one over its site (set aside in - `.ghost-docker-restore/` until verified) or into an empty directory, through - a fresh MySQL and the import's client and DEFINER filter. Its outcome is - done or needs the operator; it never puts the old site back by itself. - `siteFiles` (`src/meta.ts`), with the overrides, is the one inventory of a - site's files that backup copies, self-update snapshots and restore sets - aside. Backup refuses a site whose running images are not what its - configuration names, stopped ones included; restore resolves the inputs - it will write (`restoredFiles` and the restored `.env`, through - `ComposeInputs`) in the destination, and checks the pulled images' - identities, before it changes anything. -- The manager asks a site's services directly: `src/network.ts` joins the - manager's own container to the network the site's running containers share - (discovered, never guessed) and leaves it however the work ends, and - `src/clients.ts` holds the clients, `mysql2` and a TLS handshake. - Every service is addressed by its per-site alias. Queries go through - `withSiteDatabase`; dumps are still made and loaded by the db container's own - `mysqldump` and `mysql`. `src/verify.ts` reports each service's own health check - and asks the network only for Caddy's certificate: `127.0.0.1` in the - manager is the manager, and it cannot reach the host's ports. Dockerode and the Docker CLI were weighed and - not adopted (plan §2.10). - -- `compose.yml` — Ghost, MySQL, Caddy, optional analytics and ActivityPub, - and for local sites Mailpit (`--with mailpit`; validation keeps it out of - production). - Site mode is `local` or `production`, selected in `COMPOSE_PROFILES`; - optional profiles are additive. Long-running services take - `RESTART_POLICY`; one-shot jobs keep `restart: "no"`. -- `caddy/Caddyfile` is tracked and generic. `install` writes a production - site's routes into `caddy/sites/site.caddy` once; after that it is the - operator's and the manager never rewrites it. Other sites go in - `caddy/custom/`, global options in `caddy/global/`. Snippets take their upstreams and domains as import - arguments. -- `.env` holds Compose and operator settings, including the MySQL root - password, and is never passed into the Ghost container. `ghost.env` holds - Ghost application settings and is the `ghost` service's only `env_file`. -- `docs/configuration.md`, `docs/caddy.md`, `docs/bundle-v1.md` — contracts. -- Migration is `install --import` for local sites only. The legacy - `scripts/migrate.sh` on `main` does not understand this layout and was not - brought across; it remains the production path on `main` until production - import (S5e) exists. - -## Rules that hold whatever is being built - -- Compose interpolates dotenv values even inside double quotes: a literal `$` - is written `$$`. Never source or evaluate an env file. One encoder, tested by - a round trip through real containers. See "Value encoding" in - `docs/configuration.md`. -- Compose is invoked with `--project-directory` and an explicit `-f`, never - `-C`, and without an inherited `COMPOSE_FILE`. The site's - `compose.override.yml`, when there is one, is added after `compose.yml` - (`composeFiles` in `src/compose.ts`), as plain Compose would. -- A site directory must be mounted into the manager at its own absolute host - path, because the daemon resolves bind mounts on the host. -- A Ghost version is resolved to a digest and pinned (`GHOST_IMAGE_REF`). - Changing `GHOST_VERSION` alone never changes what a site runs. -- Installation never stops or reconfigures anything already running. A busy - port is an error naming what holds it. -- Readiness is a health check passing and the Admin API answering through the - site's own ingress. A running container is not readiness. -- Docker access is established by asking the daemon, never from group - membership. -- Commands and options are added when their step lands, not stubbed ahead of - it; usage errors exit `2`. -- The launcher holds no logic that could live in the manager. - -## Tests - -Unit tests for the CLI are TypeScript, in `manager/test/`, run by Node's own -test runner against a fake `Io`. Integration tests, in -`manager/test/integration/`, run the same code against the real daemon and the -stack's real MySQL and Caddy, from a container (the manager -Dockerfile's `integration` stage), because the manager joins its own container -to a site's network. End-to-end scenarios that only run the real commands and -check outcomes are shell scripts in `tests/e2e/`. Shell code passes -ShellCheck. - -```bash -cd manager && pnpm install -pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm test -pnpm run test:integration # real daemon and services, from a container; pulls images -tests/e2e/launcher.sh # stand-in docker, then the real image -tests/e2e/install.sh # real installs; binds 80/443, pulls images -tests/e2e/import.sh # Ghost-CLI sites exported and imported; needs Node -tests/e2e/self-update.sh # self-updates between locally built releases, writes kept through a failed one, its backup restored; a clone refused; needs jq -tests/e2e/backup.sh # a site with ActivityPub backed up, restored over itself and elsewhere; needs jq -cd scripts && pnpm install && pnpm run typecheck && pnpm test # the release tooling -``` - -Unit tests fake the daemon at the transport (`test/helpers.ts`: `api`, `run` -and `containers` for the Engine API, `composeRun` for Compose), and the site -network and its services at the `Io` (`sql`, `certificate` and -`network.refuse`). The fake only puts each running service at -its per-site alias; how the network is found, joined and left is the -integration tests' to prove, not the fake's to imitate. `test/site.ts` makes a -site directory from the repository's own files. - -## Commits - -Commit messages follow [.agents/skills/commit/SKILL.md](.agents/skills/commit/SKILL.md) -(also reachable as `.claude/skills/commit`). - -## Common commands - -```bash -docker compose ps -docker compose logs -f ghost -docker compose exec ghost sh -docker compose exec db mysql -u root -p -./help -``` +Execution guidance for agents. Shared behavior belongs in human documentation. + +## Start here + +- [README](README.md): setup and validation commands. +- [Architecture](docs/architecture.md): current boundaries, state and recovery. +- [Operator guide](docs/install.md): supported commands and recovery steps. +- [Configuration](docs/configuration.md), [Caddy](docs/caddy.md) and + [bundle v1](docs/bundle-v1.md): read the relevant contract before changing it. +- [Roadmap](docs/ghost-cli-replacement.md): remaining requirements. Do not add + command stubs for unimplemented steps. + +## Workflow + +- Target pull requests at `next-docker`. `main` is the released layout and must + not receive this branch before its migration and release gates are satisfied. +- Use pnpm with the versions pinned in each package; the manager requires Node + 26. Run the README's format, lint, type and test checks for affected packages. +- Validate Compose semantics with the real Compose parser. Fakes cannot prove + interpolation, file merging, mounts or networking. +- End-to-end scenarios fail on unavailable prerequisites. Use + `GD_E2E_ALLOW_SKIP=1` only deliberately, and report what skipped. +- Before committing, read [.agents/skills/commit/SKILL.md](.agents/skills/commit/SKILL.md). + +## High-value constraints + +- Keep the launcher limited to work that must happen before the image starts; + preserve bash 3.2 and WSL2 support. The site mount must use its absolute host path. +- Use the shared dotenv encoder; never source env files or log their values. +- Use Compose's resolved configuration for effective images, mounts and project + identity. Reuse the file inventory rather than maintaining another list. +- Never automatically load a pre-update backup after service startup was + attempted: newer writes may exist even if startup failed. +- Keep native service clients and version-matched MySQL tools for bulk data. +- Keep image-only self-update and strict development formats; retain released-main + migration and exact-version bundle import. See the linked contracts for details. diff --git a/README.md b/README.md index 7344f1ed..642e448d 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ Configuration to run Ghost and its services with Docker Compose. > **This is the `next-docker` development branch.** It is being rebuilt around -> a manager image: a small CLI in a container, started by a launcher that needs -> only Docker. It installs local and production sites, imports local Ghost-CLI -> sites, and updates between its own beta releases; backups and production -> imports are still to come. See [the plan](docs/ghost-cli-replacement.md) for -> what lands when. For a supported setup today, use the `main` branch. +> a manager image: a small CLI in a container, started by a bash launcher that +> uses Docker. It installs local and production sites, imports local Ghost-CLI +> sites, backs up and restores them, and updates image installations between +> beta releases. Production import and migration from the released `main` layout +> remain on the [roadmap](docs/ghost-cli-replacement.md). For a supported setup +> today, use the `main` branch. ## The launcher @@ -22,8 +23,9 @@ curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local exact image digest, writes Caddy's routes, starts the site and reaches it through its own ingress; a failed installation removes what it created. `config`, `check`, `info`, `list` and `doctor` look after it -afterwards, and `self-update` moves it to a newer release of ghost-docker. See [docs/install.md](docs/install.md). The other commands in these -documents are the planned interface, and each arrives with its plan step. +afterwards. `backup` and `restore` protect its data; `self-update` moves image +installations to a newer stack release. Checkouts use Git and Compose directly. +See [docs/install.md](docs/install.md) for supported commands and recovery. Everything runs in a container. From a clone of this repository the launcher builds that image from the clone; anywhere else it uses the published one, @@ -45,29 +47,20 @@ Linux. There is no native Windows launcher. | [`caddy/`](caddy) | The tracked Caddyfile and snippets, and where the site's routes go | | [`.env.example`](.env.example), [`ghost.env.example`](ghost.env.example) | Operator settings and Ghost application settings, deliberately separate | | [`docs/install.md`](docs/install.md) | Installing a site, and how it is verified | -| [`docs/configuration.md`](docs/configuration.md) | The configuration contract: the two files, value encoding, site modes, metadata | +| [`docs/configuration.md`](docs/configuration.md) | The configuration contract: env files, value encoding, site modes and overrides | | [`docs/caddy.md`](docs/caddy.md) | The site's routes, and how to change them | | [`docs/bundle-v1.md`](docs/bundle-v1.md) | The Ghost-CLI migration bundle format | | [`scripts/`](scripts) | Release tooling the workflows run: cutting a release, moving tags, release notes | | [`pages/`](pages) | The front page of docker.ghost.org, published with each release beside `install.sh` | -| [`docs/ghost-cli-replacement.md`](docs/ghost-cli-replacement.md) | The plan: architecture, contracts, and steps | +| [`docs/architecture.md`](docs/architecture.md) | Current manager boundaries, state ownership and recovery invariants | +| [`docs/ghost-cli-replacement.md`](docs/ghost-cli-replacement.md) | Remaining requirements and release gates | ## Site modes -Exactly one site mode is selected through `COMPOSE_PROFILES`: - -```sh -# Local: Ghost + MySQL, published on 127.0.0.1:${GHOST_PORT} -COMPOSE_PROFILES=local docker compose up -d - -# Production: Ghost + MySQL + Caddy with automatic HTTPS -COMPOSE_PROFILES=production docker compose up -d -``` - -Optional per-site profiles are additive: `analytics`, `activitypub`. - -`./ghost-docker install` sets them, and writes a production site's routes into -`caddy/sites/site.caddy` once; the file is yours from then on. +`install --local` selects Ghost and MySQL on loopback. A production install +adds Caddy with automatic HTTPS. [Configuration](docs/configuration.md#site-modes-and-profiles) +defines the modes and optional profiles; [installation](docs/install.md#optional-services) +explains how to enable them. ## Requirements @@ -76,19 +69,38 @@ launcher. Nothing else on the host. Windows is supported through WSL2. ## Developing +Use Node 26 and the pnpm version pinned by each package (through Corepack). +The repository has two independent packages, `manager/` and `scripts/`; there is +no root bootstrap or check command. + +From `manager/`: + ```sh -cd manager && pnpm install # pnpm via corepack: npm i -g corepack && corepack enable -pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm test +pnpm install --frozen-lockfile +pnpm run format:check +pnpm run lint +pnpm run typecheck +pnpm test +pnpm run test:integration # real daemon, MySQL and Caddy; runs in a container +``` -manager/test/integration/run.sh # the manager against the real daemon, MySQL and Caddy +From `scripts/`, run `pnpm install --frozen-lockfile`, `pnpm run typecheck` and +`pnpm test` for release-tool changes. From the repository root: -tests/e2e/launcher.sh # the launcher against a stand-in docker, then the real image +```sh +tests/e2e/launcher.sh # stand-in Docker, then the real image tests/e2e/install.sh # real installations: pulls images, binds 80 and 443 -tests/e2e/self-update.sh # real updates between releases built here; a clone refused, backed up and restored -tests/e2e/backup.sh # real backups and restores, with ActivityPub +tests/e2e/self-update.sh # release updates, failed-update write retention and recovery +tests/e2e/backup.sh # real backups and restores, including ActivityPub tests/e2e/import.sh # real Ghost-CLI sites exported and imported ``` +Shell changes also pass ShellCheck. Unit tests substitute `Io` at the Engine API, +Compose and service-client boundaries (`manager/test/helpers.ts`). Integration +tests use the manager Dockerfile's `integration` stage to exercise real Compose +and network attachment. E2E tests check operator-visible outcomes. New tests +should prove behavior at the appropriate boundary, not duplicate the code path. + An e2e script fails when this host cannot run one of its scenarios (no Docker, port 80 or 443 already held, no `mysqldump`), so a passing run, and CI, ran everything. `GD_E2E_ALLOW_SKIP=1` skips those on purpose instead, and diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..7d17ff17 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,256 @@ +# Manager architecture + +The manager handles Ghost-specific installation, configuration, diagnosis, +import, backup/restore and image-installation self-update. Docker Compose owns +ordinary start, stop and logs. Unimplemented features and their acceptance +criteria live in the [roadmap](ghost-cli-replacement.md). + +## Documentation ownership + +| Subject | Authoritative home | +| --- | --- | +| Commands, installation, migration steps and operator recovery | [install.md](install.md) | +| Environment encoding, profiles, overrides and image pins | [configuration.md](configuration.md) | +| Routes and operator edits | [caddy.md](caddy.md) | +| Migration bundle format, source requirements and fidelity | [bundle-v1.md](bundle-v1.md) | +| Manager boundaries, state and recovery invariants | This document | +| Outstanding requirements and release gates | [ghost-cli-replacement.md](ghost-cli-replacement.md) | +| Developer setup and validation commands | [README](../README.md#developing) | + +Schemas and file inventories are defined in code; documentation explains their +meaning rather than maintaining another field list. + +## Launcher and manager + +The host runs the bash launcher, `ghost-docker`. It checks Docker availability, +daemon access and the Compose plugin, selects a manager image, mounts inputs, +attaches a terminal and passes the manager's exit code through. Everything that +can run after the image starts belongs in the manager. The launcher must remain +compatible with bash 3.2; Windows uses WSL2. + +For image installations, the site's launcher is pinned to an immutable manager +image. A `self-update` invocation selects the target release on the requested +channel or `--to` version; the updater runs outside the files it replaces. +A checkout builds `ghost-docker:checkout` from its current files. Git and Compose +own checkout updates; the manager records no persistent checkout history. + +The image runs TypeScript directly on Node with type stripping +(`erasableSyntaxOnly`, no build step). Command schemas in `src/command.ts` supply +options and help to the dispatch table in `src/cli.ts`; parsing is strict and +usage errors exit 2. `src/io.ts` provides the test seam. + +The manager uses the Engine API through `src/docker/` (undici transport and +zod-validated responses). Compose runs as a program because it has no API. The +image carries the standalone Compose binary, not the Docker CLI. Service probes +use native clients; bulk database operations use version-matched MySQL tools. + +### Host boundary + +- Mount the site at its **own absolute host path**: the daemon resolves bind + sources on the host, not inside the manager. `src/context.ts` validates the + launcher's `GD_*` input; command preflight checks the working directory and + `PROJECT_DIR` agree. +- The Docker socket grants host authority. The entrypoint drops to the caller's + uid/gid with the socket's group added. Under rootless Docker, container root + already maps to the caller and must not drop again. Short-lived root helpers + handle service-owned data, such as MySQL's files. +- The launcher resolves local sockets from Docker's context, using the VM socket + on Docker Desktop/OrbStack. Remote daemons are refused because host bind paths + would refer to a different machine. +- External import bundles and restore backups are mounted read-only at their + own paths. Prompts use the terminal even when the launcher is piped from curl. +- `doctor` writes a file and reads it through a sibling container mounting the + same host path. Seeing the directory inside the manager alone cannot establish + that the daemon sees the same files. + +## Configuration and site identity + +[Configuration](configuration.md) owns the dotenv and Compose invocation +contracts. `src/env.ts` is the one encoder/parser, `src/fs.ts` writes atomically, +and `src/config.ts` checks the configuration split. Operator keys are derived +from Compose's interpolated variables, `.env.example`, `COMPOSE_*` and existing +`.env` keys. Container-owned values are checked against Compose's resolved +Ghost environment. + +`src/site.ts` defines names and profiles and reads operator settings. +`src/resolved.ts` reads effective images, mounts, networks and project identity +from Compose, including overrides; container identity comes from the daemon. +Use this resolved view when checking or recording what a site will run, rather +than inferring it from `.env`. `composeFileList` in `src/compose.ts` owns file +ordering, and `composeOverrides` derives additional overrides from that list. + +`src/project.ts` chooses a stable project name at install. Local names include +a random adjective/animal pair unused on the daemon; production names derive +from the domain. Compose addresses containers by project name alone, so commands +that operate on a site refuse containers belonging to another working directory +(`com.docker.compose.project.working_dir`). + +### Installation metadata + +The strict schema in [`src/meta.ts`](../manager/src/meta.ts) defines +`.ghost-docker.json`. It records installation provenance, resolved Ghost identity +and managed-file checksums. It is private, gitignored, atomically written and +machine-owned. Missing metadata is distinct from invalid metadata; commands +requiring it refuse either with an actionable diagnostic. + +`source` distinguishes files installed from the image from files used in a +checkout. `stack.image` is the manager pin. `payload` stores the checksum of each +managed file; it is empty for a checkout. A self-update replaces untouched files, +keeps edited ones beside the release's `.new`, and records the new release's +checksums. The launcher must be re-pinned even if edited; its edited copy is kept. +`updatedAt` and `stack.previous` describe the last successful update. + +Metadata describes the installation, not a second live configuration. Backup +asks Git for a checkout's actual current commit and Compose/the daemon for its +configuration and images at capture time. + +## Installation and import + +Installation uses a fresh destination and never stops or reconfigures an existing +site or proxy. Container-published ports can be checked before startup; conflicts +outside Docker may surface only when Compose starts services. `src/undo.ts` +records created resources so a failed installation removes only its own work. +`--no-start` creates no application containers. + +`src/ghost.ts` resolves and inspects the pulled image, including its declared +layout. `src/payload.ts` writes the image's stack files and pinned launcher; +checkout files stay in place. Caddy routes are rendered once from +`templates/site.caddy`, then belong to the operator. [caddy.md](caddy.md) owns +route edits and reload instructions. + +`src/bundle/manifest.ts` defines the migration bundle schema and depends only on +zod so the exporter can share it. Import policy stays outside that schema. +`src/bundle/stage.ts` stages and validates input, `src/import.ts` sequences the +import, and `src/import/config.ts` and `database.ts` handle configuration and SQL. +The [bundle contract](bundle-v1.md#minimum-source-version) owns minimum versions +and exact-version loading: import and upgrade remain separate operations. + +An import writes into a fresh site and records what it creates in an incomplete +marker. Until verified, `.env` selects no services, so ordinary Compose cannot +start a half-imported site. A failed import removes its work; the next import +clears interrupted work before retrying. A portable bundle's JSON and members CSV +are imported through Ghost Admin, as [install.md](install.md) describes. + +## Verification and service access + +`src/commands/site.ts` judges services against the resolved configuration and their +lifecycle labels: a long-running service must be running and, if it has a health +check, healthy. One-shot jobs must exit successfully; absence before their first +run is reported separately. Unknown services default to the stricter long-running +rule. Restart policy alone is neither readiness nor migration orchestration. + +`src/network.ts` discovers a shared network from the site's running containers, +attaches the manager, and detaches it when the last overlapping phase finishes. +An attachment that already existed is preserved. Detachment must finish before +Compose removes the site's network. Per-site aliases avoid ambiguity on shared +networks; a container address is the fallback when an override removes its alias. + +`src/clients.ts` uses mysql2 and HTTPS/TLS directly. Query deadlines and bounded +connection cleanup ensure that a stalled database cannot hold the network or +site lock indefinitely. Dumps and loads still use the db image's `mysqldump` +and `mysql`; native clients handle queries, not bulk data movement. + +`src/verify.ts` reports container health and, in production, an ingress request +for Ghost's Admin site endpoint. Production requests reach Caddy on the site's network with +the public/admin Host and SNI, and must return the configured canonical site URL. +A healthy proxy or a certificate alone does not prove the route reaches this +site. Published host ports are reported separately: the manager's loopback is +not the host's. [Installation verification](install.md#how-a-site-is-verified) documents +operator-visible results, including pending certificates before DNS cutover. + +## Recovery + +`src/lock.ts` serializes backup, restore, self-update and `config set`. Atomic +file replacement alone cannot prevent overlapping read/modify/write operations +from losing a change. Lock acquisition refuses rather than waits. An interrupted +operation leaves its lock for the operator; nothing clears it automatically. +Install/import operate on a fresh destination and do not take this lock. + +### File inventory and backups + +`siteFiles` in `src/meta.ts` combines operator paths from `src/site.ts`, additional +Compose overrides and an image installation's payload. Backup copies that +inventory; restore sets it aside together with the backup's recorded files. +Update snapshots cover operator files and every path that update can replace, +remove or create. An override is identified by its full path: a nested +`overrides/compose.override.yml` is an additional file, not the root override. +Additional overrides outside the site are refused for backup. + +`src/backup.ts` refuses moved/nested data mounts it cannot capture and image drift +before starting anything for a dump. Stopped long-running containers count: their +data may have been written by an image different from the newly configured one. +One-shot containers describe completed jobs and do not establish image drift. +The strict [`backup/manifest.ts`](../manager/src/backup/manifest.ts) schema records +configured images, immutable identities of running services, checksums and +consistency. Image backups carry the stack and launcher; checkout backups require +the captured Git revision on restore. + +A backup becomes complete only after its dumps load in scratch MySQL of the +site's version and its content archive lists successfully. Until then it is a +private `.partial` directory. The [operator guide](install.md#backup-and-restore) +owns backup contents, exclusions and live/consistent usage. + +### Writer ownership and update failures + +`src/writers.ts` pauses only writers that were running. Standalone consistent +backup resumes them after capture, before scratch validation. Self-update owns +the pause from before capture until it attempts to start the release, or restores +the old files and resumes the same containers after an early failure. + +**Attempting service startup is the recovery boundary.** Even a failing +`up --wait` can migrate data and accept writes. After that attempt, self-update +stops services, restores files when stopping succeeds, and leaves data untouched +for the operator. It never automatically loads the earlier backup over newer +writes. Before that boundary, recovery restores files and resumes the paused +writers. Recovery reports observed service state and retains the snapshot if an +operator is needed. The [operator guide](install.md#self-update) owns the recovery +commands and their data-loss tradeoffs. + +### Restore + +Before changing the destination, restore validates checksums, target ownership, +override selection, configuration and immutable image identity. Compose resolves +exactly the files restore will write, using the backup's copies (the checkout's +own base Compose file for checkout restores) and `.env` with the destination's +`PROJECT_DIR`. Absolute override/data paths must retain their meaning; validation +must not reinterpret them under a temporary project directory. + +`src/recovery.ts` provides observed service shutdown, verified copies set aside +one boundary at a time, and loading backup data into a fresh MySQL directory. +Before restore writes anything, a failed set-aside puts back what moved. After +writing begins, a failure stops services and needs the operator; it does not +automatically put the old site back. Instructions name only copies that exist. +The set-aside site is removed only after verification succeeds. + +There is no automatic crash resume, maintenance ingress, or backup retention +policy. Future Ghost upgrades and the supervisor must use these same recovery +invariants; their remaining requirements belong in the roadmap. + +## Releases and compatibility + +`src/release.ts` accepts and orders `vX.Y.Z` and `vX.Y.Z-beta.N` with semver. +The separate `scripts/` package cuts releases; it is not in the manager image. +The Release workflow selects a minor for a feature commit (✨), otherwise a +patch, and selects release notes by the commit skill's emojis. Publishing moves +channel tags only forwards, refuses to overwrite release images and serves the +launcher only from the newest release. GitHub Pages deploys through Actions. + +`manager/Dockerfile` carries the runtime, stack payload and launcher. Payload +coverage tests check the mounts/build contexts referenced by Compose. A release +must retain operator routes or supply a deliberate migration when snippet +arguments change. Self-update preserves Ghost's exact pin and refuses incompatible +Ghost versions and stack downgrades. Operator release selection is documented in +[install.md](install.md#releases). + +### Compatibility + +The first stable release establishes the metadata, backup and launcher compatibility +baseline. Before it, these are development formats: change strict schemas directly, +without defaults, adapters or migrations for earlier development snapshots. Keep +useful fresh/damaged-install diagnostics. Bundle v1 has its own +[contract](bundle-v1.md). + +S12 must record the exact schema/format/launcher versions supported by the stable +release here. Subsequent changes must be compatible or explicitly versioned with +a migration or old-format reader; pinned launchers must still start newer update +managers. Migration from the released `main` layout remains required in S6b. diff --git a/docs/bundle-v1.md b/docs/bundle-v1.md index 42b79e27..c77d35ae 100644 --- a/docs/bundle-v1.md +++ b/docs/bundle-v1.md @@ -1,43 +1,19 @@ # Migration bundle v1 — encoding contract Ghost-CLI exports a migration bundle; ghost-docker imports it. This document -fixes the parts of the format that the importer depends on. The exporter is -`ghost migrate-export`, released in Ghost-CLI 1.33.0; its -[`docs/migration-bundle.md`](https://github.com/TryGhost/Ghost-CLI/blob/v1.33.0/docs/migration-bundle.md) -describes what a bundle contains, and this document is kept in step with it. - -**The export command is in beta and bundle v1 is not frozen.** There is no -draft-format compatibility path: a bundle that does not meet this contract is -rejected with an actionable error, not silently adapted. Exporter, importer, -documentation and fixtures change together, and only then is v1 frozen. - -Steps referenced below are defined in -[the implementation plan](ghost-cli-replacement.md); each lands as its own -pull request. - -Status of the work: - -- This document and the importer-side contract: **S1**, implemented. -- Exporter implementation, fixtures and cutover support: **S3**, released in - Ghost-CLI 1.33.0. -- Alignment of this document and its fixtures with the released exporter, - including the `mysql-data` kind: **S5a**, implemented. -- Importer for local `mysql-dump` and `mysql-data` bundles: **S5b**, - implemented as `./ghost-docker install --import BUNDLE`. The manifest schema - is `manager/src/bundle/manifest.ts`, a zod schema that depends on nothing - else so that the exporter can share it. -- Moving a local site: **S5c**, documented rather than automated ("Moving a - site to Docker" in `docs/install.md`): `ghost stop`, `ghost migrate-export`, - then `install --import` on the source's port. -- **S5e** (production import and cutover): not yet implemented. A `portable` - bundle's site and content are imported by the manager; its content JSON and - members CSV are imported through Ghost Admin (plan §2.4). -- Updating existing ghost-docker installations from the pre-S1 layout: **S6b**. - -The importer's target is `ghost.env`. Replacing it with a mounted Ghost JSON -config file was evaluated and rejected; see §2.1 of -[the plan](ghost-cli-replacement.md). The serialization rules below therefore -stand as written. +owns its format and importer requirements. Use Ghost-CLI **1.33.3 or later**; +see the exporter's [migration bundle documentation](https://github.com/TryGhost/Ghost-CLI/blob/v1.33.3/docs/migration-bundle.md) +and [operator import steps](install.md#importing-a-ghost-cli-site). + +The strict schema in [`manager/src/bundle/manifest.ts`](../manager/src/bundle/manifest.ts) +depends only on zod so the exporter can share it; importer policy stays outside +it. Bundle v1 is not frozen: exporter, importer, documentation and fixtures +change together, without draft-format adapters. + +Local `mysql-dump`, `mysql-data` and `portable` bundles are supported. For a +portable bundle the manager installs the site and content; JSON and members CSV +are imported through Ghost Admin. Production import remains [S5e roadmap +work](ghost-cli-replacement.md#s5e--production-import-and-cutover). ## Bundle kinds @@ -113,8 +89,6 @@ Portable `database` example (filenames can vary; always read the manifest): ``` `kind` appears only at the top level and the version only at `ghost.version`. -There are no `database.kind`, `ghostVersion`, or `sourceEnvironment` aliases; -a manifest carrying one is rejected. Matching exporter fixtures are in `tests/fixtures/migration-bundle-v1/` and Ghost-CLI's `test/fixtures/migration-bundle-v1/`. @@ -264,7 +238,7 @@ does guarantee is narrower and worth having: a bundle cannot write outside the site directory, and its SQL runs as the site's own database user, so it can touch nothing but that site's database. -## Source consistency and cutover (S3) +## Source consistency and cutover Ghost-CLI's `ghost migrate-export --leave-stopped` deliberately leaves the source stopped after successful export and attempts to stop it after export failure. @@ -312,22 +286,9 @@ integrations; reconnect/reconcile Stripe using a supported importer. relationships without these losses; external services and storage still need separate configuration. -See the exporter's [fidelity and recovery documentation](https://github.com/TryGhost/Ghost-CLI/blob/v1.33.0/docs/migration-bundle.md). -S3 verifies schema, source lifecycle, private output, system-tar extraction, -real Compose value transport, and a `mysql-data` load into a MySQL schema -created by the Ghost image. The manager places a `portable` bundle's content -and leaves its content JSON and members CSV to Ghost Admin, so their fidelity -is that of Ghost Admin's own import. S3 does not implement -the Docker importer. - -## Remaining S5 work - -Local `mysql-dump` and `mysql-data` bundles import into a fresh site -directory (S5b), and moving a local site is documented as stop, export, -import (S5c). Still to come: S5e adds production import and the documented -cutover. See §2.4 and S5 of -[the plan](ghost-cli-replacement.md). - -Keep the final source stopped and intact until the destination is accepted; -restarting it permits writes that invalidate the final snapshot. Real production -cutover must prevent writes before the final MySQL export. +See the exporter's [fidelity and recovery documentation](https://github.com/TryGhost/Ghost-CLI/blob/v1.33.3/docs/migration-bundle.md). +The manager places a portable bundle's content and leaves its content JSON and +members CSV to Ghost Admin; their fidelity is that of Ghost Admin's own import. + +Keep the final source stopped and intact until the destination is accepted. +Restarting it permits writes that invalidate the final snapshot. diff --git a/docs/configuration.md b/docs/configuration.md index 3b65201a..a2acd538 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,9 +1,5 @@ # Configuration -> **Status.** `./ghost-docker install`, `config`, `check`, `info` and `list` -> exist (plan step N3); see [install.md](install.md). Commands the plan has not -> delivered yet do not exist until their step lands. - ## Two files, two audiences | File | Contents | Read by | @@ -43,7 +39,7 @@ atomically with a restrictive umask and preserve the mode of an existing file. Migration bundle v1 config values are raw strings, with no dotenv encoding. The importer applies the rules below exactly once when writing `ghost.env`; public and admin URLs are separate manifest fields mapped to `.env`. See -[bundle-v1.md](bundle-v1.md) for the agreed S3 schema and source guarantees. +[bundle-v1.md](bundle-v1.md) for the schema and source guarantees. Compose interpolates dotenv values, **including inside double quotes**, and `env_file` values are no exception. A literal dollar sign must be written `$$`. @@ -77,16 +73,9 @@ accident. It cannot catch every case: a hand-written `$$` is indistinguishable from a correctly escaped single `$`, so writing values through `config set` is the only way to be sure. -### A limit worth knowing - -Nothing above makes a hand-written `Pa$$w0rd!` safe: `$$` is indistinguishable -from a correctly escaped single `$`, so no linter can catch it. **Do not -hand-edit a value containing `$`** — use `./ghost-docker config set`, which encodes -it correctly. - -Mounting Ghost's own JSON config file instead of `ghost.env` would remove the -interpolation layer entirely. It was evaluated and rejected; §2.1 of -[the plan](ghost-cli-replacement.md) records the findings and the reasons. +`ghost.env` keeps Ghost's image-provided defaults. Replacing its JSON config +would require maintaining a copy of those defaults; use `config set` for +values needing escaping. ### The rules @@ -223,79 +212,14 @@ contract. ## Installation metadata -`.ghost-docker.json` records the schema version, installation and last update -time, mode, release channel, how the stack was installed (from the image or a -clone), installed stack version/ref and manager image and the ones -before the last update, project identity, -resolved Ghost image and digest, selected profiles, and checksums of the files the -manager wrote. Its schema -is specified in §2.2 of [the plan](ghost-cli-replacement.md). It is gitignored, -mode `0600`, and machine generated — do not hand-edit it. Operations that -change a running site hold `.ghost-docker.lock` while they run (`self-update`, -`backup`, `restore` and `config set` now; Ghost upgrades when they land). An update keeps -what it would put back in `.ghost-docker-update/` until it finishes, and a -restore over a site keeps the site as it was in `.ghost-docker-restore/` -until it is verified; see [self-update](install.md#self-update) and -[backup and restore](install.md#backup-and-restore). Backups are directories -under `backups/`, private and gitignored, kept until the operator removes -them. A backup holds a copy of this file; restoring into a new directory -rewrites `site.dir` (and `PROJECT_DIR` in `.env`) to that directory. - -```json -{ - "schemaVersion": 1, - "installedAt": "2026-09-03T09:12:44Z", - "updatedAt": "2026-10-08T15:02:10Z", - "mode": "production", - "channel": "stable", - "source": "image", - "stack": { - "version": "v1.2.3", "ref": "v1.2.3", - "image": "ghcr.io/tryghost/ghost-docker@sha256:…", - "previous": { "version": "v1.2.2", "image": "ghcr.io/tryghost/ghost-docker@sha256:…" } - }, - "site": { - "project": "ghost-example-com", "dir": "/opt/ghost/example.com", - "url": "https://example.com", "domain": "example.com", "adminDomain": null - }, - "ghost": { - "image": "ghost", "tag": "6.62.0-next-alpine", - "version": "6.62.0", "digest": "sha256:…" - }, - "profiles": ["production"], - "payload": { "compose.yml": "", "caddy/Caddyfile": "", "ghost-docker": "" } -} -``` +`.ghost-docker.json` is private and machine-owned; do not hand-edit it. Use +`./ghost-docker info` to inspect it. Its meaning and schema ownership are +specified in [architecture.md](architecture.md#installation-metadata). +[Compatibility](architecture.md#compatibility) begins with the first stable +release; earlier development formats may be refused. -`source` is `image` when `install` wrote the stack's files from the manager -image, and `checkout` for a clone of the repository whose files are used in -place; it records no git commit, which is git's to know (a backup records the -one checked out when it is taken). `stack.image` is the manager image the site's launcher is pinned to: -a repository digest, or, for an image only this host holds, its ID. `payload` -is the SHA-256 of every file `install` wrote from the image, so that an update -can tell a file nobody edited from one somebody did (plan §2.7); it is empty -in clone mode, where Git already knows. `self-update` records the checksums of the -release it moved to, `stack.previous` (what the site ran before, which a failed -update recovers to) and `updatedAt`; both are `null` until the first update. -`channel` is the channel `self-update` follows by default. `self-update` does not -update a clone: that is git's and Compose's ([install.md](install.md#updating-a-clone)). - -Every field is required. A field that was not supplied is `null` rather than an -empty string, so "not known" and "deliberately empty" stay distinguishable. The digest is the -immutable image identity, recorded so the exact image can be found again during -recovery even after a tag moves. - -The manager is the reader and writer. It writes atomically, refuses a -document without the right `schemaVersion`, and refuses to *read* one written by -a newer schema rather than misinterpreting it. A document that does not match -the schema is refused, naming its fields: it is damaged, or was written by a -development release. Until the first stable release, this format may change -in any release, and earlier shapes are not read; the first stable release is -the compatibility baseline (plan §2.7, "Compatibility"). - -A directory without the file was not made by `./ghost-docker install`. -`./ghost-docker info` says so, and commands that need the metadata refuse, -changing nothing. +For locks, interrupted updates and set-aside restores, follow the +[operator recovery instructions](install.md#backup-and-restore). ## The Compose invocation contract @@ -311,12 +235,22 @@ selected list. The manager adds the site's `compose.override.yml` after `compose.yml` when it exists, which is what plain `docker compose` does on its own, so the two run the same site (see [Your own Compose overrides](#your-own-compose-overrides)). Opt into any other override file -with `GD_COMPOSE_OVERRIDES`: +with `GD_COMPOSE_OVERRIDES` (a comma-separated list, in merge order): ```bash GD_COMPOSE_OVERRIDES=compose.ipv6.yml ./ghost-docker check ``` +Relative paths are relative to the site. Only the root `compose.override.yml` +is implicit: `overrides/compose.override.yml` is an additional override like +any other. Backup requires additional overrides to be inside the site, records +their order and copies them; restore requires the same selection. Use matching +`-f` arguments for direct Compose commands. + +The manager passes only Docker connection/tool-path variables from its own +environment, so its image's settings cannot override the site's `.env`. +Explicit profile selections apply only to that invocation. + ## Your own Compose overrides For local changes the stack does not offer, such as an extra mount or a @@ -350,61 +284,20 @@ not show. ## Ghost image layout -The default `GHOST_VERSION` is a `next` variant, which installs Ghost directly -under `/home/ghost`. The older variants use `/var/lib/ghost/versions/` with a -`current` symlink. Two variables carry the difference so that pinning an older -image still works: - -| Variable | Default (`next`) | Older layout | -| --- | --- | --- | -| `GHOST_CONTENT_PATH` | `/home/ghost/content` | `/var/lib/ghost/content` | -| `GHOST_TINYBIRD_PATH` | `/home/ghost/core/server/data/tinybird` | `/var/lib/ghost/current/core/server/data/tinybird` | - -Set both if you pin a `GHOST_VERSION` from the older layout; the content mount -and the Tinybird sync job read them. - -`./ghost-docker config validate` checks that `GHOST_CONTENT_PATH` matches the image -by reading the image's own `GHOST_CONTENT` variable, rather than inferring it -from the tag name — so a future layout change is caught without updating a -mapping. The check is skipped when the image has not been pulled yet. - -## Prerequisites - -The host needs **Docker Engine 25.0+** with the **Compose v2.24+** plugin, and -`bash` to start the launcher (on Windows, inside WSL2). Nothing else. The manager image carries its own Node runtime, Docker -client and tools, so the host needs no `jq`, `curl`, `git` or Node. - -`git` is needed only to work from a clone of this repository instead of a -published image. See "Where the tooling runs" in -[the plan](ghost-cli-replacement.md). - -Docker access is established by asking the daemon, never by checking `docker` -group membership: neither rootless Docker nor a remote `DOCKER_HOST` involves -that group, and being in it does not mean the daemon is running. +Install and import require the `next` image layout. The manager reads the +image's `GHOST_CONTENT` and `GHOST_INSTALL` declarations to set +`GHOST_CONTENT_PATH` and `GHOST_TINYBIRD_PATH`; it refuses the older +Ghost-CLI-installed layout. `config validate` checks the content path against +the pulled image's own declaration, and skips that check before it is pulled. +The [bundle contract](bundle-v1.md#minimum-source-version) defines the migration +minimum and exact source-version requirement. ## Existing installations -This layout is a breaking change for checkouts made before it landed. The -migration is owned by the stack updater (S6b); the changes it has to handle are: - -- `caddy/Caddyfile` is now **tracked**. An existing installation has an - untracked file at that exact path, and Git refuses to overwrite an untracked - file with a tracked one. The updater moves the operator's file aside — into - `caddy/custom/` where its routes keep working — *before* the checkout. -- The snippets in `caddy/snippets/` now take their upstreams and domains as - import arguments instead of reading `{$DOMAIN}` / `{$ACTIVITYPUB_TARGET}` - from the Caddy container's environment, which the `caddy` service no longer - sets. A hand-written Caddyfile that imports them needs the arguments added. -- Ghost application configuration moves from `.env` to `ghost.env`. `.env` - keeps the Compose and operator settings and is no longer passed into the - Ghost container. -- `COMPOSE_PROFILES` must gain a site mode (`production` for an existing - server), and `SITE_MODE`, `URL`, `PROJECT_DIR` and an exact `GHOST_IMAGE_REF` - pin must be added. - -Moving a Ghost-CLI installation to Docker is a separate matter: see -[bundle-v1.md](bundle-v1.md). The legacy `scripts/migrate.sh` on `main` predates -this layout and is not part of it. +Do not use `git pull` to move a released `main` installation to this layout. +That migration is still [roadmap work](ghost-cli-replacement.md#s6b--migration-from-the-released-main-layout). +Moving a Ghost-CLI site is a separate, supported local import described in +[install.md](install.md#importing-a-ghost-cli-site). ## Installed image pins diff --git a/docs/ghost-cli-replacement.md b/docs/ghost-cli-replacement.md index e472c542..4ca01763 100644 --- a/docs/ghost-cli-replacement.md +++ b/docs/ghost-cli-replacement.md @@ -1,517 +1,144 @@ -# Plan: ghost-docker as a Ghost-CLI replacement - -Status: re-based 2026-10-05 around a manager image. This is an implementation -plan, not authorization to execute its steps. - -This document lives in the repository so that every branch carries the -contracts it is implementing against. Amend it in the pull request that changes -a decision, rather than letting the code and the plan drift apart. - -**How we got here.** A first implementation was written as bash scripts on the -`next` branch: configuration and Compose foundation (S1), an installer (S2) and -local bundle import (S5b), about 3,900 lines of bash with a Node test suite. -It worked, and it showed where the approach was heading: every remaining step -(portable import, updates, backup, multi-site) would be more bash, while backup, -upgrades and the supervisor were already planned for a container image, leaving -the logic split across two languages. This branch, `next-docker`, starts again -from `main` with one implementation: a small CLI inside a manager image, started -by a launcher that needs only Docker. `next` is frozen and kept as the behaviour -reference; where a step below ports something that exists there, it names the -files. - -Repos involved: - -- `TryGhost/ghost-docker`: the Compose stack, the manager image, the launchers. -- `TryGhost/Ghost-CLI`: migration bundle export (`ghost migrate-export`, released - in 1.33.0). -- `TryGhost/Ghost`: upgrade adapter and Admin API (PR #31277, open), and Admin UI. - -Each step in §3 is a separate work package and a separate pull request. Read its -dependencies and the contracts in §2 before implementing it. - -## 1. Scope and decisions - -| Topic | Decision | -| --- | --- | -| Initial audience | Local and single-site production installations; most servers run one site. Theme developers and migration-tool authors moving local Ghost-CLI sites come first. | -| Site model | One directory = one site. One `compose.yml`, with `local` and `production` modes selected through `COMPOSE_PROFILES`. | -| Where tooling runs | In a **manager image** published from this repository: a TypeScript CLI with its own dependencies and Compose. The host runs only a **launcher** that checks Docker and starts the image. See §2.10. | -| Supported platforms | Production: Linux with Docker Engine (rootful), the counterpart of Ghost-CLI's Ubuntu with systemd. Local sites: also Docker Desktop, OrbStack and WSL2. Rootless Docker is best effort: the identity rules handle it (§2.10), but it is not in the qualification matrix. | -| Host requirements | Docker Engine 25.0+ with the Compose v2.24+ plugin, and bash for the launcher. No `jq`, `curl`, `git` or Node on the host. `git` only when working from a clone. | -| Distribution | The manager image carries `compose.yml`, the Caddy configuration and the CLI, and writes them into the site directory. A tagged release is an image tag. A git clone of this repository also works: the launcher builds the image from the checkout and uses the files in place, and the operator updates it with git and Compose. See §2.7. | -| Docker socket | The manager is given the Docker socket for every command, including install. That is host-privileged, and it is accepted: whoever runs the launcher already has that access. | -| File ownership | Everything the manager writes into the site directory is owned by the user who ran the launcher. The entrypoint starts as root to read the socket's group, then drops to the caller's uid and gid. See §2.10. | -| Windows | Through WSL2 only, which is Linux: Docker Desktop's WSL2 backend puts `docker` and its socket inside the distro, and the launcher runs there unchanged. No native launcher; §2.10 records the design to use if one is ever wanted. | -| Versions | Resolve and persist an exact Ghost image version on installation. Ghost upgrades and stack updates are separate operations. Record resolved image digests for recovery. | -| Installation | Scriptable `install`, with a flag for every prompt. Local mode uses MySQL too. | -| Migration | Ghost-CLI exports a bundle (`ghost migrate-export`, Ghost-CLI 1.33.0+); the manager imports it. Three kinds: `mysql-dump` (MySQL sources), `mysql-data` (default for local SQLite sources: data-only MySQL inserts loaded into a schema Ghost creates), and `portable` (an explicit SQLite fallback through the Admin API). For a `portable` bundle the manager installs the site and places its content; its content JSON and members CSV are imported through Ghost Admin on the new site, as anyone moving a Ghost site by hand does today. Moving a site is documented, not wrapped: `ghost stop`, `ghost migrate-export`, `install --import` on the source's port. The legacy `scripts/migrate.sh` stays on `main`, where it works, and is not carried onto this branch, whose layout it does not understand; it disappears from `main` when this branch merges, which S12 allows only after production import (S5e) has passed its tests. | -| Upgrades | Optional supervisor using a file exchange and the Docker socket. Ship a tested host-driven upgrade first, then reuse its recovery contract in the supervisor. Both are commands of the manager. The supervisor runs one job at a time, publishes durable status, and marks work it finds interrupted as `interrupted` rather than resuming it (§2.6). | -| UX | Standard Compose commands for daily operation; `./ghost-docker` for installation, diagnosis, configuration, migration, backup/restore, and upgrades. No wrapper binary named `ghost`. | -| Configuration | `.env` contains Compose/operator settings; `ghost.env` contains only Ghost application settings. Do not pass the whole `.env` into Ghost. A mounted Ghost JSON config file was evaluated as a replacement for `ghost.env` and rejected; see §2.1. | -| Recovery rigor | Ghost-CLI's level, made reliable: back up before changing, restore on failure, report truthfully, one operation at a time on a site (§2.5). No journals, maintenance ingress or crash-resume; more is built only when a real failure shows it is needed. | -| Shared infrastructure | S13: one Caddy shared by several sites on a server, each site keeping its own MySQL. Not a dependency of local or single-site production installations. Scheduled after tagged single-site production and before Admin-driven upgrades, so the upgrade supervisor is built once against per-site projects. | -| ActivityPub and analytics | Per-site, including for future members of shared infrastructure. Each site owns its ActivityPub database/storage and Tinybird configuration/deployment lifecycle. | -| Ghost nightly channel | Future explicit opt-in via `--ghost-channel nightly`; published to GHCR, independently of the stack release channel. Stable remains the default. | -| Service image registry | No registry selector. Each service's image is a full, registry-qualified reference the release pins and `.env` can override (S14); the manager records the exact digest it resolved. Decided 2026-10-09 (PLA-519). | -| Redis | Opt-in (`--with redis`) once S16 ships. It becomes a default only if measurements show Ghost needs it, local installs included (§2.9). Decided 2026-10-09 (PLA-519). | -| Tests | Unit tests for the CLI in TypeScript. End-to-end scenarios that only run the real commands and check outcomes are shell scripts in `tests/e2e/`, so they do not depend on how the commands are implemented. A scenario the host cannot run fails the script unless `GD_E2E_ALLOW_SKIP=1`, so a green run, in CI above all, ran everything. | - -Explicitly document initial limitations: no shared-infra provisioning, no automatic -major Ghost/MySQL upgrades, no arbitrary downgrade support, and no import of a -`portable` bundle's content JSON and members CSV (they go through Ghost Admin). - -## 2. Architecture and contracts - -### 2.1 Compose modes and configuration - -Initial service profiles: - -| Service | Profiles | Lifecycle | +# Roadmap: replacing Ghost-CLI for Docker installations + +This document describes remaining work, not implemented interfaces. Add commands and +options only when their step ships. Current usage is in [install.md](install.md), and +current invariants and code ownership are in [architecture.md](architecture.md). When a +step lands, update those documents and remove its completed requirements here. +Scheduling and issue status belong in [the project in +Linear](https://linear.app/ghost/issue/PLA-412). + +## Delivery dependencies + +| Outcome | Remaining work | Dependencies | | --- | --- | --- | -| `ghost` | `local`, `production` | Long-running | -| `db` | `local`, `production` | Long-running | -| `caddy` | `production` | Long-running | -| `traffic-analytics` | `analytics` | Long-running, per-site | -| `activitypub` | `activitypub` | Long-running, per-site | -| `activitypub-migrate` | `activitypub` | One-shot | -| `tinybird-*` | `analytics` | Setup/deployment jobs, per-site | -| `upgrade-supervisor` | `supervisor` | Long-running, optional | - -Caddy is part of the `production` mode, not an optional profile. Making -bring-your-own-proxy a first-class path was considered and rejected: it would -mean owning validation of the operator's proxy configuration, and the failure it -guards against is subtle — a wrong `X-Forwarded-Proto` yields incorrect absolute -URLs and non-secure cookies, so the site half works rather than failing. That is -a poor thing to support on someone else's proxy. - -An operator who already runs nginx or Apache can still do it, as a manual -customization rather than a supported mode: Ghost publishes on -`127.0.0.1:${GHOST_PORT}` in every mode, so they point their proxy there and -edit `compose.yml` to drop the caddy service or move it off 80/443. Document -that this is unsupported and that stack updates may touch `compose.yml`. - -Profiles are additive, not mutually exclusive or conditional configuration. Validate -that exactly one site mode is selected. Optional services must not accidentally -activate unrelated modes. Explicitly targeted Compose services can run even when -their profiles are inactive; helper commands must account for dependencies. - -Example generated Compose settings (credentials omitted): - -```dotenv -# Local -COMPOSE_PROFILES=local -COMPOSE_PROJECT_NAME=ghost-local-example-secondary-roadrunner -PROJECT_DIR=/absolute/path/to/site -NODE_ENV=development -URL=http://localhost:2368 -GHOST_PORT=2368 -RESTART_POLICY=no -GHOST_VERSION=6.61.0-next-alpine -DATABASE_HOST=db -DATABASE_NAME=ghost -DATABASE_USER=ghost - -# Production uses the same variable contract with: -# COMPOSE_PROFILES=production -# NODE_ENV=production -# URL=https://example.com -# RESTART_POLICY=unless-stopped -# Optional: ADMIN_URL=https://admin.example.com -# Optional profiles are added only after their configuration is validated. -``` - -Requirements: - -- Ghost publishes `127.0.0.1:${GHOST_PORT:-2368}:2368`. When no port is supplied - the installer starts at 2368 and skips ports that Docker's own containers - already publish, which is what keeps several local sites apart. An explicit - port is never changed. See "Ports are not probed" in §2.8. -- Parameterize database host, name, and user now, even though single-site defaults - remain `db`/`ghost`/`ghost`. Use the same connection contract for backup and import. -- Set a unique Ghost network alias `ghost-${COMPOSE_PROJECT_NAME}` and use it in - generated proxy routes and helper clients. Never rely on `ghost` for shared-network - addressing when S13 is introduced. -- Persist the project name independently of the directory name. Moving a site still - requires updating and validating `PROJECT_DIR` and bind mounts. -- Choose a local project name no project on the daemon has (the directory's - name and a random adjective-animal pair), and refuse, before any change, a - project whose containers another directory made: Compose addresses a - project by name alone. -- Use `restart: ${RESTART_POLICY:-unless-stopped}` only for long-running services. - Setup, migration, and deployment jobs retain `restart: "no"`. -- Initially `URL` may be required because every supported mode contains Ghost. Do - not put `:?` guards on optional-service variables such as `PROJECT_DIR`. - Validate requirements by mode before provisioning or startup. Revisit URL's guard - before adding infra-only mode in S13. -- Keep the initial default network naming unchanged. Do not introduce an empty - `name:` as a guessed equivalent of an omitted field. -- `ghost.env` is the only application `env_file` for the initial release. Explicit - Compose environment entries override container-owned keys; the importer - rejects/omits those keys. - -Application configuration stays in `ghost.env`. Replacing it with a mounted -Ghost JSON config file was evaluated, because dotenv cannot hold an arbitrary -value safely: Compose interpolates `env_file` values, so an SMTP password of -`Pa$$w0rd!` reaches Ghost as `Pa$w0rd!`, and `s3cr$t!` reaches it as `s3cr!`, -with no error anywhere. It was rejected — the findings are recorded here so the -question is not reopened from scratch. - -Verified against `ghost:6-alpine` (Ghost 6.61.0, nconf 0.13.0): - -- The image ships `config.production.json` in Ghost's install directory - (`/home/ghost` in the `next` variants, `/var/lib/ghost` in the older layout), - with `config.development.json` symlinked to it. It sets `url`, `server`, - `mail.transport: "Direct"`, `logging.transports`, `process`, `security` and - `paths.contentPath`. -- nconf is first-added-wins, and `loader.js` registers `custom-env` - (`config..json`) *before* `local-env-jsonc` (`config.local.jsonc`). So - `config.local.jsonc` cannot override anything the image ships, including - `mail.transport`, and is unusable as the operator's config file. -- A file mounted over `config..json` is read, and all value shapes survive: - strings, numbers, booleans, nested objects, storage adapters, labs flags. A - `$` in a value survives verbatim. -- Compose `environment` entries outrank every config file, so container-owned - keys stay enforced by construction either way. -- nconf coerces env var types too (`port=465` arrives as a number), so the env - form loses nothing on typing. - -Why it was rejected: - -- Comments are the main reason to prefer a config file over dotenv for - hand-editing, and they require JSONC. `jq` cannot parse JSONC at all, so - validation and every programmatic read would fail on a commented file, and - writes would strip the comments. The format would fight the tooling. -- Strict JSON keeps `jq` working but has no comments, and because the image - already ships `config.production.json`, our file would replace it rather than - layer over it — pinning a hand-maintained copy of the image's defaults that - silently drifts when the image changes them. Layering would need a Ghost - loader change registering a custom-env JSONC file *before* `custom-env`. -- Multi-line values are awkward in both directions: JSON requires `\n` escapes, - dotenv allows literal newlines that the helpers refuse to edit. - -Under the manager CLI the tooling objections weaken, since JavaScript can read -and write JSONC. The layering objection is unchanged and decides the question on -its own. - -The residual risk is accepted: a hand-written `$$` is indistinguishable from a -correctly escaped single `$`, so no linter can catch it. Mitigation is to write -values with `./ghost-docker config set`, which encodes correctly, and -`config validate`, which catches the bare-`$` case. Document that hand-editing a value containing -`$` is unsafe. - -The initial release keeps `ghost.env`, including for imports of older Ghost -versions. A future JSONC format would require a Ghost loader change and an -explicit compatibility/migration design; it is not a dependency of any step here. - -- Add site/mode labels and a real Ghost readiness probe. A running container or - redirect response alone does not establish readiness. -- Cap container logs and make optional-service resource costs visible. - -Before publishing the minimum Docker/Compose versions, run the mode matrix against -that exact minimum and a current version. The installed Compose v5.1.2 accepted -interpolated restart `no` and network `external=true`; that is not verification of -older versions. Include `start_interval` and any env-file features in compatibility -checks. The launcher is the only host code: keep it free of GNU-only behaviour -and runnable by bash 3.2. - -### 2.2 Environment values, metadata, and permissions - -The manager must never source or evaluate an env file. Define a serializer -and parser with round-trip tests through Docker Compose itself, including `$VAR`, -`${VAR}`, `$$`, spaces, quotes, backslashes, newlines, empty strings, and JSON arrays. -Do not assume double-quoted values are literal: Compose interpolates them. - -Keep arbitrary application configuration in `ghost.env`; root DB credentials, -project paths, network settings, and other operator controls stay out of Ghost's -environment. Write credential-bearing files privately, with restrictive umask and -atomic replacement preserving intended ownership/mode. Logs list sensitive key names, -never their values. Add file-based credentials later only for supported Ghost images. - -`.ghost-docker.json` is gitignored and contains a schema version, installation and -last update time, mode, release channel, how the stack was installed (`image` or -`checkout`), the installed stack version and the manager image the site's launcher -is pinned to, and the ones before the last update, project identity, the resolved -Ghost image, and a checksum of every file written from the image (§2.7). Every -field is required, `null` when it is not known. It records no git commit: a -checkout's commit is git's to know, and a backup records the one checked out when -it is taken (§2.5). A site without the file was not made by `install`: commands -that need it refuse and say so, and `info` reports it. Installations of the layout -on `main`, which has no metadata, are S6b's to migrate. - -Backup, restore, Ghost upgrade and stack update take a lock file (`.ghost-docker.lock`) in the site -directory for the length of the operation, so two of them cannot run on one site -at once. A lock left behind by a crashed run names its operation and start time; -`check` reports it and how to remove it, and nothing removes it automatically. -`config set` takes it too, for its read, change and write: atomic replacement -keeps a file whole, but two writers that overlap would each write the file -without the other's change, and an update that fails puts its snapshot back -over a change made meanwhile. A lock that is held refuses the second writer, -naming the first; nothing waits. Install and import write into an empty -directory, so they do not lock. - -### 2.3 Caddy and optional services - -- Track a generic Caddyfile importing `sites/*.caddy` and operator-managed - `custom/*.caddy` and `global/*.caddy`. Ignore the site's routes and operator - files in Git. -- `install` renders the site's routes once, into `sites/site.caddy`, with - explicit upstreams (the site's network aliases), public/admin domains and - optional-service targets, and every import argument; missing arguments may - survive adaptation and fail at runtime. After that the file is the - operator's, as Ghost-CLI's generated nginx file was: no command rewrites it, - and changing routes is editing it and reloading Caddy. Verification (§2.8) - confirms Caddy serves each domain. Most servers run one site and set their - routes once; a validate/install/reload/rollback command for them was built - in N3 and taken out again as not worth its code. Shared infrastructure (S13) - brings generation back where several sites share one Caddy. -- Use explicit reload in production, not `--watch`. Caddy documents watch as a local - development feature. Use `docker compose --project-directory "$DIR" ...`, not `-C`. -- Migrations must preserve custom routes. Do not silently replace a customized - Caddyfile with a generated approximation after printing a warning. -- ActivityPub, its migration job, database grants, storage, and serving URL belong - to the site. Test retrieval of uploaded ActivityPub assets through the site's URL. -- Tinybird credentials/workspace selection and schema deployment belong to the site. - Treat sync and deploy as distinct steps. A Ghost upgrade must run the required - deployment after sync, not merely copy new files into a volume. -- Specify schema compatibility and recovery for analytics before automated upgrades - with analytics are supported. Do not imply a Ghost DB restore undoes remote schema - changes. Unsupported combinations must fail preflight with an actionable message. - -### 2.4 Bundle format and migration - -Reference: `docs/migration-bundle.md` in Ghost-CLI at tag `v1.33.0`, the first -release containing `ghost migrate-export` (PR #2333). The released exporter is the -authority for what a bundle contains, and `docs/bundle-v1.md` in this repository -matches it. - -Bundle kinds: - -| Kind | Source | Database payload | Import path | -| --- | --- | --- | --- | -| `mysql-dump` | MySQL/mysql2 | Schema and data for the selected database | Provision, load, start Ghost | -| `mysql-data` | Local SQLite; the exporter's default | Data-only MySQL `INSERT`s for every table, including migration history; `database.rows` holds per-table counts | Provision, boot Ghost once at the exact source version to create the schema, stop it, load, verify counts, start Ghost | -| `portable` | Local SQLite with `--sqlite-format portable` | Content JSON and members CSV from the Admin API | Provision, place the content, start Ghost on an empty database; the JSON and CSV through Ghost Admin | - -`mysql-data` is the expected route for local SQLite sites and preserves IDs, staff -credentials, members and settings. `portable` is the exporter's fallback for a -source whose data it refuses to write as `mysql-data` (values MySQL would -reject). The manager installs the site and places its content, Ghost creates an -empty database on first start, and the summary names what is left in Ghost -Admin: the owner account, the content JSON, the members CSV and the theme. That -is what Ghost-CLI users do today when they move a site, and its losses are those -of Ghost's own import. - -Before freezing the contract: - -- Define `config` as a map of flattened keys to raw string values, without embedded - dotenv quoting. The importer serializes these values safely for Docker Compose. -- Require `bundleCreatedAt` and `sourceInstallType: local|production`. Infer installation - mode from `sourceInstallType`; validate kind, supported Ghost version, and all paths. -- Update exporter, importer, documentation, and fixtures together before freezing - bundle v1. The unpublished draft format does not need backward compatibility. -- Test actual Compose round trips rather than only comparing exporter strings. -- Record the consistency/cutover behavior: the exporter currently restarts Ghost. - Add a documented final-export mode that leaves the source stopped, with explicit - operator selection, and preserve the current restart behavior for ordinary exports. - Portable exports support local SQLite development sites only: content API export, - then members CSV, then stop Ghost and copy assets. Captures are sequential; users - must avoid editing during export. Do not implement write-freeze machinery or - require Ghost changes. Final export leaves the source stopped for cutover. - -Import sequence: - -1. Validate target state and available space. The target is always a fresh site - directory; refuse merging into an existing database or content tree. -2. Inspect/extract into private staging. Reject path traversal, absolute member paths, - and escaping symlinks/hardlinks, including in directory bundles. Bound expansion - and check space for extracted content, database restore, and recovery copies; - compressed archive size times 1.5 is not a sufficient estimate. -3. Read/validate the manifest from the staged copy against the manifest schema - (see "Reading the bundle" in `docs/bundle-v1.md`). This cannot depend on an - already-valid site `.env` or already-running Ghost. Importing a bundle means - trusting it, so the guarantees are that it cannot write outside the site - directory and that its SQL runs as the site's own database user. -4. Resolve the exact source Ghost image and check architecture/availability before - changing the target. Import at the source version; upgrading is a separate step. -5. Generate `.env` and `ghost.env`; retain operator URL/mode overrides. Omit - container-owned config including `url`, `admin__url`, database, server, paths, - process, logging, and upgrade-adapter controls. Map public/admin URLs deliberately, - preserving supported path/port semantics or rejecting unsupported URLs clearly. -6. Stage content including hidden files and establish ownership appropriate to the - selected Docker mode. Do not blindly apply host uid 1000 under rootless/userns. -7. Restore the selected database only, with explicit connection and database name. - `mysql-dump` does not contain CREATE DATABASE/users/grants. Provision first, then - restore before Ghost is started; propagate pipeline failures. `mysql-data` - contains no schema at all: provision an empty utf8mb4 database, start Ghost - once at exactly `ghost.version` with no ingress so it creates its own schema - and fixtures, stop it, then load `database.sql` with the `mysql` client and - compare per-table `COUNT(*)` with `database.rows`. Never synthesize DDL from - the bundle. -8. Verify the expected content and records, the active theme and assets, redirects, - URLs and configuration. A failed import removes what it created, so it is - re-run rather than resumed. -9. Cutover is the operator's, documented rather than automated: take a final - export with `--leave-stopped`, so the source cannot take writes the copy will - not have; import; then point DNS at the new host, or on the same server stop - the old proxy and start the new site's Caddy. The source stays intact until - the operator removes it. - -A copied database sends real email, newsletters and webhooks once it runs, so the -documentation says to stop the source before the destination starts serving, and -to remove mail settings from a copy started elsewhere as a rehearsal. - -#### Local imports - -A local import targets a fresh site directory that has never served traffic, so -it does not depend on the S4 recovery runtime. The failure contract is simpler: nothing -outside the new site directory is modified, and a failed import removes what it created -(containers, the data it wrote into directories it had verified empty, -configuration, staging) so the same command can be run again in the same -directory. While an import is in progress the directory is marked incomplete and -`.env` selects no Compose service, so an import interrupted before it could clean -up cannot be started; the next import clears it first. There is no ingress to -switch (step 9). - -A production import differs only in step 9 and in its URLs: separate admin URLs, -and an existing proxy holding 80 and 443 on the same server. - -#### Moving a local site - -There is no `--migrate` command. Moving a local Ghost-CLI site to Docker is -documented as three steps in `docs/install.md`, "Moving a site to Docker": - -1. `ghost stop` in the source. The exporter never starts a source that was - stopped, so the bundle is the source's final state and nothing writes to - the source afterwards. No `--leave-stopped` is needed. -2. `ghost migrate-export --output PATH`, with PATH outside the installation - (the exporter refuses one inside it). -3. `install --import PATH --port PORT` in a new directory beside the source, - with the source's own `server.port`, now free. If it fails, it removes - what it created; `ghost start` in the source puts the operator back where - they began. - -A launcher wrapper was built for S5c and dropped before it merged (PR #344). -Over these three commands it added a confirmation, a default directory and -port, and a restart on failure that was needed only because it stopped the -source itself. It cost about 280 lines of host bash in a launcher that is -meant to hold no logic, and it parsed Ghost-CLI's output (the version line, -the exporter's messages, `.ghostpid`). Ghost-CLI's own exporter is the right -place to say what to run next, since it knows the site's port; it will print -the next steps once more of the plan has landed. - -### 2.5 Backup, upgrade, and recovery - -Ghost-CLI's level, made reliable. Ghost-CLI's `ghost update` installed the new -version beside the old one and could switch back; `ghost backup` exported the -content. This stack does the same with a real database backup, and states what -it does: - -- a backup is taken and checked before an upgrade or a stack update changes - anything; -- a failure restores that backup and the previous image and configuration; -- the outcome is reported truthfully: done, restored, or needs the operator, - with what to do; -- two operations cannot run on one site at once (the lock in §2.2). - -Not built: journals that resume an operation killed at an arbitrary point, -maintenance ingress, retention policies, and a fault-injection matrix. A crashed -operation leaves its lock and its backup; `check` says so, and the operator -restores or re-runs. Each is added only when a real failure shows it is needed, -in the step that needs it. The supervisor (§2.6) follows the same rule: work it -finds interrupted is reported, never resumed. - -**Backup** is a directory under `backups/` in the site: a `mysqldump` of each of -the site's databases (Ghost's, and ActivityPub's when that profile is on) taken -as the site's user, a tarball of the content directory, `.env`, `ghost.env`, -the site's Caddy files, `compose.override.yml`, the metadata and, in image -mode, the stack's files and launcher, and a manifest naming the exact image of -each service, every table's row count and every file's checksum. Checked means -every dump loads into a scratch MySQL of the site's own image and the archive -lists; until then it is `backups/..partial`, and a failure removes it. It -is written private, and kept until the operator removes it. State outside the -site (a Tinybird workspace), Caddy's certificates and Mailpit's inbox are named -in the manifest as not included. - -A backup is **live** by default, as Ghost-CLI's was: each database one -snapshot, but captured at a different moment from the others and from the -content. `--consistent` stops Ghost and ActivityPub, the services that write -them, while they are captured and starts them again before the check, so -they are one moment of the site at the cost of a brief outage; stack updates -take a consistent one. The manifest records which. - -**The writers' pause** (`manager/src/writers.ts`). An operation that may load -its backup back over the site owns the pause of Ghost and ActivityPub, not the -backup: they are stopped before the checkpoint is taken and stay stopped -until the services the operation starts are its own, or the site is put -back. A failure before any service changed puts the files back, then starts -the same containers again: nothing was written after the checkpoint, and the -site is restored. Once the operation has started the services, Ghost may -accept writes (and the services may change the data) before a later step -fails, and the operation cannot tell. Loading the checkpoint then would -discard them, so it is never done automatically: the operation stops the -services, puts the files back, and the operator chooses between the -checkpoint and the data as it is. -Whatever can be done before the pause is: a stack update pulls the release's -images while the site still runs. A standalone `backup --consistent` still -resumes the writers as soon as the capture is done. Stack updates, Ghost -upgrades and the supervisor's jobs share this primitive; it is not a -framework for resuming an operation. - -**Restore** works over the backup's own site or into a new, empty directory. -It reads the backup whole and checks every checksum first, then takes the lock -and pulls the recorded images. Over a site it stops the site and sets its -files and data aside in `.ghost-docker-restore/`. It writes the backup's files -with the directory's own path, requires Compose to resolve exactly the -recorded images, unpacks the content, starts MySQL on an empty data directory -and loads each dump as the site's user, checking every table's rows. Then -`up --wait`, and it verifies as `check` does. The outcome is done, or needs -the operator with what to do; the set-aside site is removed only once the -restore is verified. - -**Recovery** is one module (`manager/src/recovery.ts`) that restore, stack -updates and, when they land, Ghost upgrades and the supervisor share: services -stopped and their state observed through Compose, never assumed; data and -files set aside one boundary at a time, so instructions name only copies that -exist and never direct a removal on the strength of a step that did not -complete; and a backup's content and databases loaded into the site. It -resumes nothing. - -**Ghost upgrade:** - -1. Take the lock. Resolve the target to one exact image of the same major and - pull it before anything stops. Refuse other majors and downgrades. -2. Pause the writers, and back up; they stay paused (above) until the - target starts or the site is put back. -3. Change the pin (and its metadata) and `up --wait`. Ghost runs its own - migrations at boot and rolls back one that fails. -4. Verify as `check` does. On a failure, put the previous pin back and restore the - backup, then verify again: `restored`, or `needs the operator` if that fails - too, with the backup's path. - -Switching images back is not reversing migrations, and a rollback after traffic -has resumed would discard newer writes: going back to an older version later is -a restore, chosen deliberately, never a side effect of asking for an old tag. - -### 2.6 Supervisor and Ghost integration - -The supervisor is a command of the manager image (§2.10), enabled per site; it is -not a second image. The Docker socket is -host-privileged; non-root process execution does not remove that authority. The host -operator explicitly enables self-upgrades and controls policy. Ghost owner/admin -authorization permits requests only within that host-defined policy. - -The supervisor sees the site directory at the same absolute host path for Compose -bind resolution. Define supported local Docker contexts and socket paths; reject remote -daemons or unsupported rootless setups clearly. Avoid broad writable mounts beyond -what execution requires. Supervisor behavior follows §2.5 rather than inventing a -second upgrade/recovery algorithm. - -**Minimum scope** (decided 2026-10-09, PLA-519). The supervisor is the host +| Tagged single-site production | S6b legacy-layout migration, S5e production import, S12 qualification | Existing image self-update, local import, backup and restore | +| Several sites behind shared Caddy | S13 | Existing backup/restore; S6b for converting existing sites | +| Admin-driven Ghost upgrades | S7 host upgrade, S8 supervisor, S9 core adapter/API, S10 Admin | S7 before supervisor execution; S8 protocol before S9; S9 before S10; S13 integration if shipped | +| Later extensions | S11 file secrets, S14 service references, S15 nightlies, S16 Redis | See each step | + +Qualify production on Linux with rootful Docker Engine. Local qualification also covers +Docker Desktop, OrbStack and WSL2. Rootless Docker is best effort, not part of the +supported qualification matrix. Daily start, stop and logs stay with Compose. Stack +self-update remains image-only; checkout operators use Git and Compose and retain +backup/restore. Automatic major Ghost/MySQL upgrades and arbitrary downgrades are +outside the planned scope. + +### S5e — Production import and cutover + +Repo: ghost-docker. Depends on the existing local importer and [bundle +contract](bundle-v1.md), including its minimum source version and exact-version import. +Support `sourceInstallType: production`, separate admin URLs, and the same-server case +with an existing proxy on 80/443. + +Document cutover: stop the source before its final export (or explicitly use +`--leave-stopped`), import into a fresh destination, then point DNS at the new host or +switch the proxy on the same server. Keep the source stopped and intact until the +destination is accepted. Never stop an existing proxy automatically; it may serve other +applications. A rehearsal with copied configuration must not send real mail, newsletters +or webhooks. + +Retain staging/path validation and database-user isolation. Bound expansion and check +space for extracted content, database restore and recovery copies; compressed archive +size times 1.5 is insufficient. Verify the source version's image on every supported +architecture before provisioning. Preserve supported URL path/port semantics or reject +them clearly. Do not guess ownership as uid 1000 under rootless/user namespaces. + +Acceptance: same-server migration with an existing proxy on 80/443 and cross-host +migration following the documented cutover; exact source version, staff sign-in, +records, active theme, assets, redirects and configuration preserved. This replaces +`main`'s `scripts/migrate.sh`; S12 must not merge `next-docker` into `main` before these +scenarios pass. + +### S6b — Migration from the released main layout + +Repo: ghost-docker. Depends on image self-update and backup/restore. This migration +remains required regardless of the development-format [compatibility +policy](architecture.md#compatibility), and gates merging into `main`. + +Implement ordered release migration scripts, recording completion in metadata when the +first migration needs that field. `0001-compose-profiles` must: + +- Handle both absent profiles and existing `analytics,activitypub`, adding + `production`; preserve credentials and project identity. +- Split Ghost application configuration into `ghost.env`, keeping operator + settings in `.env`; add `SITE_MODE`, `URL`, `PROJECT_DIR` and an exact pin for + the currently running Ghost version. +- Preserve the existing untracked Caddyfile before managed files are written. + Custom routes must still work with snippets that take explicit upstream and + domain arguments. Preserve supported customizations automatically, or stop + before changing the live site and explain what must be resolved. + +Existing installations have no launcher or metadata. The entry point is the served +launcher from inside the installation (`curl -fsSL https://docker.ghost.org/install.sh | +bash -s -- self-update`), not a raw `git pull` across the breaking change. This is a +migration of the released layout, distinct from self-update of a current `source: +checkout` site. + +Acceptance: absent metadata, an untagged starting commit, existing optional profiles, +custom Caddy routes and Compose overrides, skipped releases, repeated invocation and +failed hooks. Failures during migration, pulling or startup must follow the [recovery +boundary](architecture.md#recovery) and report the observed state accurately, retaining +any writes after startup. + +### S7 — Host-driven Ghost upgrades + +Repo: ghost-docker. Depends on backup/restore and release compatibility. Implement +`./ghost-docker update [version|latest]` following the [recovery +invariants](architecture.md#recovery), initially without a supervisor. Specify the +reusable execution interface so the supervisor cannot diverge from backup/recovery +behavior. Keep supported majors/downgrades constrained and feature compatibility +explicit. + +Take the site lock, resolve and pull one exact target image of the same major before +stopping the site, pause writers and capture a checked backup. Update the Ghost pin and +metadata together. Start with `up --wait` and verify ingress; Ghost runs its own +migrations at boot. Recovery follows the startup boundary linked above: switching an +image back does not reverse migrations. Going back to an older version later requires a +deliberate restore. + +Acceptance: upgrade across a real database migration, with the analytics sync and deploy +run; a target that fails after startup leaves every accepted write intact and reports +`needs-operator`, with its backup and recovery choices; an early failure restores the +previous files and resumes the paused writers; a concurrent second request is refused by +the lock; another major and a downgrade are refused. + +### S8 — Supervisor command, protocol, and installer integration + +Repo: ghost-docker. Deps: S7, S9; S13 if it has shipped. Write +`docs/upgrade-supervisor.md` with the exact schemas, transitions, ownership, policy, and +recovery rules below, then implement the supervisor as a long-running command of the +manager image. Reuse S7 behavior. Wire `--with supervisor` and request submission/status +tooling. Scope: one active job, durable status, `interrupted` for work found unfinished, +a 20-job history; no queue and no resuming. + +Acceptance: handwritten requests work before Ghost gains an adapter; duplicate and +malformed requests, permission violations, and stale status behave correctly; a request +while a job is active, or while the CLI holds the lock, is refused; a supervisor killed +mid-job reports that job `interrupted` on its next start, with its backup, and leaves +the lock for the operator; history stays within its bound. Verify actual exchange +permissions as Ghost's runtime uid. Installer must not enable an incompatible Ghost +adapter. + +#### Supervisor protocol requirements + +The supervisor is a command of the manager image, enabled per site; it is not a second +image. The Docker socket is host-privileged; non-root process execution does not remove +that authority. The host operator explicitly enables self-upgrades and controls policy. +Ghost owner/admin authorization permits requests only within that host-defined policy. + +The supervisor sees the site directory at the same absolute host path for Compose bind +resolution. Define supported local Docker contexts and socket paths; reject remote +daemons or unsupported rootless setups clearly. Avoid broad writable mounts beyond what +execution requires. Supervisor behavior follows [recovery +invariants](architecture.md#recovery) rather than inventing a second upgrade/recovery +algorithm. + +**Execution.** The supervisor is the host command run on Ghost's behalf, not a job system: the same executor as -`./ghost-docker update` (S7) and the recovery of §2.5, with a file exchange in +`./ghost-docker update` (S7) and the shared recovery rules, with a file exchange in front of it. So it has: -- **One active job.** A request is claimed only when no job is active and the +- **One active job.** A job starts only when no job is active and the site lock is free. A request that arrives while one is active, or while the CLI holds the lock, is claimed and refused at once (`refused`, naming what holds the site), as the CLI refuses a second operation. There is no queue: @@ -531,11 +158,12 @@ front of it. So it has: kept, so Admin can show the last outcome after a restart; older job files are removed when a new request is claimed. That is the whole retention rule. -Later, and not needed for M4: queues, more than one job, resuming or retrying -interrupted work, scheduled or unattended upgrades, and configurable -retention. Each would need a requirement the above cannot meet. +Outside this scope: queues, more than one job, resuming or retrying interrupted work, +scheduled or unattended upgrades, and configurable retention. Each would need a +requirement the above cannot meet. -Write the protocol document before implementing either side. It must include: +The protocol document must replace these design requirements before either side is +implemented. It must include: - Versioned JSON schemas for status, requests, and jobs; exact version/image fields; timestamps/heartbeat; supported capabilities; bounded error details. @@ -550,8 +178,7 @@ Write the protocol document before implementing either side. It must include: forbids, or one that needs a newer stack, is manual too and says why. - Job states and their legal transitions: the executor's stages (backing-up, pulling, restarting, verifying, restoring), then one final state: `done`, - `restored`, `needs-operator` (recovery failed; the backup's path and what to - do), `refused` (policy, lock, another active job, or a malformed request) or + `restored`, `needs-operator` (automatic recovery is unsafe or failed; the backup's path and what to do), `refused` (policy, lock, another active job, or a malformed request) or `interrupted` (found unfinished on start). No queued state. - UUID validation, bounded file sizes, no symlink following, exclusive request claiming (an atomic rename, so one request is claimed once and a duplicate @@ -560,1187 +187,16 @@ Write the protocol document before implementing either side. It must include: - Separate request-write and status/job-read permissions for Ghost. A shared writable parent directory must not let Ghost replace supervisor-owned status or job files. Define initialization/uid ownership and mount layout explicitly. -- Atomic publication of every status and job file, each stage recorded before its - side effect (the durable status above; no separate journal). A POST can return +- A POST can return an accepted job ID before the supervisor claims it; distinguish pending, unknown, expired, stale supervisor, and protocol mismatch rather than treating all 404s as a restart indefinitely. - Strict target allowlisting, argument-array subprocess invocation, no shell input, host-enforced major/backup policy, and the shared site operation lock. -Ghost adapter type: `upgrade`. Canonical implementation names: -`NoopUpgradeAdapter` and `FileDropUpgradeAdapter`; use these exact names in config, -code, docs, and tests. Enable FileDrop only for a compatible Ghost version and an -initialized exchange. Noop remains the default. Absence of a supervisor is a -supported state, not a Ghost startup failure. - -Admin API: status, create request, and fetch job. Owner/admin only, rate-limited POST, -with the normal Admin auth/permission conventions. Admin feature-detects both absent -endpoints on older cores and `supported: false`. Polling survives restart with a -bounded reconnect period and useful stalled, `needs-operator` and `interrupted` states. UI backup -promises must match the actual enforced policy. - Version discovery must handle registry pagination, rate limits, stale cache, semver -ordering, image architecture, and compatibility requirements. A valid Docker tag -alone is not evidence that an upgrade path is supported. - -### 2.7 Releases, distribution and updates - -A release is an image. `ghcr.io/tryghost/ghost-docker` is built from this -repository and carries the CLI and the **release payload**: every file -`compose.yml` needs beside it in order to run every profile. - -| Payload | Why | -| --- | --- | -| `compose.yml`, `compose.ipv6.yml` | The stack | -| `caddy/Caddyfile`, `caddy/snippets/` | Mounted by the `caddy` service | -| `mysql-init/` | Mounted by `db`; creates the ActivityPub database | -| `tinybird/` | Build context of the Tinybird helper services in the `analytics` profile | -| `.env.example`, `ghost.env.example` | Reference | - -The payload is defined by what `compose.yml` references, not by this table: a -test resolves every bind mount and build context in the Compose file and fails -when one is not in the image. A site installed from the image with no checkout -must be able to start both optional profiles. Building the Tinybird helpers at -install time is a cost of the current stack; publishing them as images instead -belongs with service images (S14). Tags: - -| Tag | Meaning | -| --- | --- | -| `vX.Y.Z`, `vX.Y.Z-beta.N` | A release. Immutable. | -| `stable`, `beta` | The newest release on that channel. Moving. Used only to *resolve* a release. | -| `edge` | Built from the development branch on every push. Not a release. | - -Releases are cut as Ghost's and Ghost-CLI's are: a Release workflow, run by -hand, works out the next version from the squash commits since the last -release (a ✨ feature makes it a minor; anything else, dependency updates -included, a patch), tags `next-docker`, and publishes the image, its moving -tags, the GitHub release (notes from the commits that carry a release-note -emoji) and the served launcher. release-please was planned and not used: it -reads only Conventional Commits, and this repository's titles are past-tense -sentences, so it would have found nothing to release. Version selection is -numeric and tested, never lexical. Until the legacy migration exists (S6b) -every release is a beta, and `main` keeps the pre-`next-docker` layout that -existing installations update with `git pull`. - -Rules: - -- **A site is pinned to a digest.** Installation resolves a channel or tag to - one image digest, records the version and digest in `.ghost-docker.json`, and - writes a launcher into the site directory that runs exactly that digest. A - moving tag is never what a site runs. -- **The image writes the files.** The release payload in the site directory is - a copy of the image's, written by `install` and replaced by `self-update`. They are not - edited by operators; operator-owned files are `.env`, `ghost.env`, - `caddy/sites/site.caddy` (written once by `install`), `caddy/custom/` and - `caddy/global/`. A release that changes a snippet's arguments must say how - to change the site's routes, or carry a migration that edits them; it may - not overwrite them. The manager records a checksum of each - file it wrote, so `self-update` can tell an untouched file from an edited one. An - untouched file is replaced. An edited one is kept, the release's version is - written beside it as `.new`, and `self-update` names both; it never asks. -- **Clone mode.** A launcher that finds itself in a checkout of this repository - builds the image locally from that checkout and uses the files in place, - writing nothing over them. Metadata records `source: checkout`, and no - commit. This is how the stack is developed, and how someone who wants to - read everything first installs it. - - Such a site is updated by its operator with git and Compose: back up, check - a newer ref out, `config validate`, `docker compose pull` and `up -d - --wait`, `check`. `self-update` updates only image-mode sites, and refuses a - checkout before it changes anything, with those steps. The manager keeps no - record of the commits a checkout has been at, takes no part in moving it - between them, and the launcher keeps no image per commit to go back to. - Backup and restore work in a checkout as in image mode, with the commit - checked out when the backup was taken in its manifest (§2.5). -- **The launcher is served** by GitHub Pages, deployed from GitHub Actions, - with the custom domain `docker.ghost.org`: `https://docker.ghost.org/install.sh`. - It is the repository's own `ghost-docker`, published by a workflow on release - and never by hand. Test the served file rather than the checkout's copy. - -`./ghost-docker self-update [--check] [--channel stable|beta] [--to vX.Y.Z]` updates -the stack, not Ghost. Preserve the exact Ghost pin; if a stack release requires a -newer Ghost, stop with the required upgrade sequence. Initially reject stack -downgrades unless the relevant migrations explicitly support them. - -The names follow Ghost-CLI, where `ghost update` updates Ghost: `update` is -Ghost's (S7), and ghost-docker's own update is `self-update`. A bare `update` -is an unknown command until S7 lands; there is no alias to the stack's. - -The updater is the *target* release's image, started by the site's launcher. It -runs entirely outside the files it replaces, which is what makes this tractable: -there is no script rewriting itself mid-run. - -Flow: - -1. Refuse a checkout (clone mode, above). Take the lock. -2. Resolve the release, refuse a downgrade, and record the previous version and - digest. -3. Back up (§2.5). A snapshot in `.ghost-docker-update/` keeps the operator's - files and, in image mode, the payload being replaced; the backup keeps the - databases and content, which a release's services may migrate even when - Ghost's pin does not change. -4. Write the managed files, run the release's migration scripts in order (each - recorded in metadata when it completes), validate Compose, pull images, `up - --wait`, and verify as `check` does. -5. On a failure, put the previous payload and configuration back. Before - services changed, start the writers again: restored. Once they had, stop - them first, load nothing, and report that the operator is needed, with the - `restore` command for the backup (which discards what was written since) - and the alternative of starting the previous release on the data as it - is. Never report success because `up -d` returned zero. -6. On success, rewrite the site's launcher to pin the new digest. The launcher - holds the pin, so it is replaced even when edited; an edited copy is kept as - `ghost-docker.edited`. - -Migration `0001-compose-profiles` moves an installation made from `main` before -this layout: it must handle both an absent profile setting and existing -`analytics,activitypub` values, adding `production` in either case. Split -application config into `ghost.env`, preserve credentials and project identity, add -URL/PROJECT_DIR and an exact pin for the currently running Ghost version. Move an -existing untracked Caddyfile aside before the managed one is written. Preserve -customizations automatically when supported; otherwise stop before changing the -live setup and present the required configuration resolution. Test custom routes, -legacy Compose overrides, skipped releases, repeat invocation, and failed hooks. - -Existing installations have no launcher. Their way in is the served launcher run -from inside the checkout (`curl -fsSL https://docker.ghost.org/install.sh | bash --s -- self-update`); do not tell them to use raw `git pull` to cross the breaking -change. That is S6b's migration of the released layout on `main`, which has no -metadata; it is not clone mode, whose sites record `source: checkout` and are -refused. - -#### Compatibility - -Until the first stable release, everything `next-docker` has made is a -development format: `.ghost-docker.json`, the backup manifest, and the contract -between the launcher and the manager (the `GD_*` environment, §2.10) may change -in any release, and the manager reads only the current shape. A document in an -earlier shape is refused with a sentence naming its fields, and saying that it -is damaged or from a development release; nothing is read with guessed defaults, -and no migration is written to carry development installations or backups -forward. Their operators reinstall, or import. - -The first stable release is the baseline. It records, in this section, the -versions it supports: metadata `schemaVersion`, backup `format`/`version`, the -launcher contract, and bundle v1 (which already has its own, §2.4). From then on -a change to any of them is backward compatible, or comes with a new version and -a migration or a reader for the old one, and a site's pinned launcher must -still be able to start the newer managers `self-update` runs. - -None of this touches the layout on `main`, which is released: S6b's migration of -its installations stays required. - -### 2.8 The launcher and its commands - -```text -ghost-docker install [--local | --domain example.com [--admin-domain admin.example.com] - [--email ops@example.com]] - [--dir PATH] [--port 2368] [--version 6.3.1] - [--channel stable|beta] [--release vX.Y.Z] - [--with analytics,activitypub,supervisor] - [--import BUNDLE] [--no-start] -ghost-docker check | info | list -ghost-docker config get|set|validate ... -ghost-docker self-update | backup | restore | update (as their steps land) -``` - -First use is the served launcher: - -```sh -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --domain example.com -``` - -On Windows that command runs in a WSL2 terminal. - -Installation writes a copy of the launcher into the site directory, pinned to -the image digest that installed it. Every later command is `./ghost-docker ...` -from there. - -Unknown options fail clearly with exit `2`. An option whose step has not landed -does not exist until it does, so it is an unknown option like any other. Use the -terminal for interactive input even when the launcher itself was piped from -`curl`; `--no-prompt` must not silently accept destructive choices, and every -prompt has a flag or environment-variable equivalent. - -Preflight is split by what has to work when Docker is broken: - -- **In the launcher**, before any image is pulled: Docker is installed, the - daemon answers within a deadline, the Compose plugin is present. Test daemon - access by asking the daemon, never from `docker` group membership. These are - the only checks that cannot run in a container. -- **In the manager:** Docker and Compose versions, platform and architecture - (from `docker info`), a writable site directory, disk, memory, - optional-service credentials, URL/DNS, and Compose/Caddy validation. - -**Ports are not probed.** The manager cannot see the host's ports from inside a -container, and the first plan for this branch worked around that by having the -daemon publish each port on a throwaway container. That was dropped: Docker -already refuses to start a service whose port is taken, and says which port. -So: - -- A port held by another container is known without probing, from what Docker - reports its containers publish. The default local port skips those. -- A port held by anything else is discovered when the services start. The - installer catches that failure, names the port and the option that changes - it (`--port`), and states that nothing already running was stopped. -- Because the conflict now surfaces after configuration has been written, **a - failed installation removes what it created**, so the same command can be run - again in the same directory. Installation needs that for a failed pull or a - service that never becomes healthy as well. - -Supported platforms are as in §1: production on Linux with rootful Docker -Engine, local sites also on Docker Desktop, OrbStack and WSL2. Rootless Docker -is best effort and not claimed as supported; do not infer it from linger alone. - -**Reaching a site in order to verify it.** `127.0.0.1` inside the manager is the -manager, not the host, so the first implementation's probes of host loopback -cannot be ported as they were. Each service is judged by its own Compose -health check, which `up --wait` already requires, and the manager repeats none -of them. Only what no health check can answer is asked from the site's own -network (§2.10, "Reaching a site's services"). Nothing is described as more -than it is: - -- **Ghost**: its health check, in which the Admin API answers inside the - container. -- **Caddy**: its health check, in which its admin API answers with its - configuration loaded. That says Caddy is up, not that the generated routes - serve each name: Caddy 2.10 redirects any name to HTTPS, served or not (the - integration tests found this), so a redirect from port 80 would prove no - more. Proving routing is PLA-517's. -- **Published ports are reported, not verified.** A container cannot reach - the host's loopback interface on every platform (Docker Desktop and OrbStack - run the daemon in a VM, rootless Docker in a user namespace), and whether a - `--network host` container lands on the host cannot always be told from - `docker info`. So the manager lists the ports Docker - says it published (`docker compose ps --format json`) and says they were not - checked from the host; the installer prints the URL, and opening it is that - check. An earlier revision of N3 probed them from the host's namespace on - Linux with Docker Engine; it was dropped as not worth its code. -- **HTTPS, as Ghost's answer through Caddy.** Caddy obtains a public - certificate for a public domain name in the background, retrying with - backoff for up to thirty days, and keeps it in its data volume; it does not - substitute its internal CA when issuance fails. So before DNS points at the - host there is no certificate, and no probe can show more. The manager makes - an HTTPS request to Caddy's container on the site network, with the domain - (and the admin domain, separately) as SNI and Host, for Ghost's - `/ghost/api/admin/site/`, and reports *serving* only when this site's Ghost - answered through Caddy, naming the certificate's issuer and expiry. Ghost - reports its canonical site URL by whichever name it is reached, so the - answer must name the site's `URL`, on the admin domain too; another URL is - another site's Ghost, a route to the wrong upstream (PLA-524). A - certificate browsers would not trust is a warning, and an out-of-date one, - an answer that is not Ghost's (a broken route) or another site's an error. It reports *pending* when - the handshake fails or the certificate does not name the domain: Ghost - could not be asked through Caddy yet, and the message says so, that Caddy - obtains a certificate once the domain's DNS reaches this host, that - `./ghost-docker check` reports the change, and that `docker compose logs - caddy` shows each attempt (PLA-517). Caddy's internal CA, through - `local_certs` in `caddy/global/`, is how the tests exercise the whole - route; it is a test fixture, not a fallback. Telling an issuance error that DNS will not cure - (a CAA record, a rejected ACME account, a rate limit) apart from the errors - expected before DNS exists was built and dropped: Caddy's log already says - which, and the operator is pointed at it. *Pending* is not a failure of - installation. Temporary internal TLS before DNS is deliberately not offered: - it would be a second TLS state to transition out of, and a self-signed - certificate on a public name is a browser warning to click through, which is - the habit this setup should not teach. Operators who want internal TLS for a - private name put `tls internal` in `caddy/custom/`, as today. - -A production site before its DNS points at the host therefore has Caddy -healthy, its ports reported as published, and HTTPS shown as pending. That is the -expected state of a fresh production installation, and the output says so in -those words. - -Keep nginx/apache running until cutover; a server may proxy other applications, -so replacing its whole service requires an explicit operator choice. Installation -never stops or reconfigures anything already running: a port in use is an error -naming the port, and the container holding it when Docker knows one. Bringing your own proxy is a documented manual edit of -`compose.yml`, not a supported mode; see §2.1. - -Installation writes configuration, renders routing, initializes permissions and -metadata, then verifies readiness before publishing the Admin URL. `--no-start` -must not start application services. Imported sites follow the isolated flow in -§2.4. - -`list` includes stopped containers (`docker ps -a` with labels) and states what -cannot be discovered without a registry. `check` works for single-site installs -and validates actual DB connectivity, configuration, readiness, and recovery -state. - -### 2.9 Final-phase image distribution, nightly builds, and Redis - -These extensions follow single-site qualification and do not block the initial -release. Their numbering is a delivery sequence, not a requirement to ship shared -infra before service image references or Redis. Each extends the acceptance -matrix and operator documentation when it ships. - -Future installer interface (add only as the relevant steps land): - -```text ---ghost-channel stable|nightly ---with redis -``` - -Keep `--channel stable|beta` for the ghost-docker stack release. Persist stack channel, -Ghost channel and resolved image identities separately. - -**Service images are full references** (decided 2026-10-09, PLA-519). There is no -`--image-registry` selector: one would cover only the images someone dual-publishes, -need its own state, and still have to be resolved to exact references. Instead every -service's image in `compose.yml` is a registry-qualified reference with its digest, -pinned by the release (`ghcr.io/tryghost/activitypub:1.2.14@sha256:...`), behind a -variable `.env` can set, as `GHOST_IMAGE_REF` already is for Ghost. An operator who -needs another registry or a mirror sets that service's reference with -`./ghost-docker config set`. The manager never rewrites a reference to another -registry and never falls back to one: an unavailable registry or a missing -architecture fails clearly, naming the reference. - -Exact identity and provenance are kept as they are today: the backup manifest -records the reference Compose resolved for every service, and the metadata records -each image the manager pulled with the digest it resolved. A reference without a -digest is resolved to one when it is pulled, and that digest is what is recorded. -A different registry is a different reference whose digest is checked, never assumed -equal to the first. Changing a service's reference is a configuration change, not -an upgrade: `check` reports the version the image declares, and an override that -moves a service to another version is the operator's choice, made visible, never -the manager's. Stack updates keep a site's overrides (`.env` is the operator's), and -backup, restore and the supervisor use whatever references the site resolves. -Public pulls must work without credentials; dual publishing upstream is welcome but -is not a ghost-docker requirement. - -Nightly is an explicitly selected Ghost channel, not a stack prerelease channel and -not an automatic-update schedule. Publish to the agreed Ghost GHCR repository from -an identified source commit, with immutable build tags/metadata and an optional moving -nightly discovery tag. Installation/upgrade resolves discovery to one exact build and -digest; never persist only a moving tag. Use the same Ghost image for tinybird-sync. -Record the Ghost-reported version plus commit/build identity: two nightly builds may -report the same semver. Build discovery and job schemas must handle that deliberately, -without relaxing trusted image allowlists to arbitrary user-supplied references. - -Only nightly sites discover nightly updates. Enabling the channel does not bypass -host major-version policy, backups, write freezing, compatibility checks, or recovery. -Stable-to-nightly and nightly-to-stable are explicit compatibility-checked transitions; -returning to stable may require waiting for a compatible release or restoring a -checkpoint because schema migrations cannot be undone by changing a tag. Retain exact -recovery images/build metadata even if registry retention removes old nightly tags. -Show the channel and build identity in CLI/status/Admin where applicable. - -**Redis is opt-in** (decided 2026-10-09, PLA-519): `install --with redis`, or the -`redis` profile added to an existing site, when S16 lands. Ghost's in-memory cache -remains the default. A default service costs every site another long-running -container, its memory and its failure modes; on a local install, a theme -developer's laptop, that buys nothing, and on a single small production site the -in-memory cache is adequate. Redis becomes a default only when measurements on -supported Ghost versions show a real benefit for typical sites, and then for -production first; local installs would still default to without it unless the -measurements cover them too. The original §2.1 profile table describes the initial -release; S16 extends it as follows: - -| Service | Profiles | Lifecycle | Installation default | -| --- | --- | --- | --- | -| `redis` | `redis` | Long-running, per-site | Off; `--with redis` or the profile enables it | - -Use a pinned supported Redis image, private site networking with no published host -port, healthchecks, bounded memory, defined eviction/persistence settings, and -credentials handled through the established secret/config interfaces. Configure the -actual cache features and adapter schema supported by the selected Ghost image; -review `docs/codebase/internal-caching.md`, the built-in Redis adapter, and -[Ghost's cache documentation](https://docs.ghost.org/config#cache-adapters). Do not -assume every older supported image accepts the same cache configuration. Incompatible -images must use a documented supported configuration or fail preflight with the opt-out -path, rather than producing a broken default install. - -For existing sites, document enabling it: add the profile, which the manager wires -into Ghost's cache configuration, preserving operator cache overrides and exact image -pins. Routine stack updates must not unexpectedly switch an existing cache backend. Disabling Redis must also remove/revert generated -cache configuration; do not stop it while leaving Ghost pointed at it. Define/test -startup ordering, runtime outage behavior, reconnects, cache invalidation after -upgrade/restore, and the effect of opting out. Do not promise automatic runtime -fallback unless the selected Ghost implementation actually provides it. - -Initial Redis use is rebuildable Ghost cache data. Explicitly document whether it is -persisted for warm restarts and whether backups exclude/rebuild it. Potential later -traffic-analytics/ActivityPub use is an extension point, not a claim of current Redis -support. Before wiring any such consumer, verify its released configuration contract -and distinguish disposable cache from durable queues/counters/salts/other state. -Durable state needs appropriate persistence, eviction, isolation, backup/restore, and -upgrade policy; separate instances when policies differ. Key prefixes or Redis logical -DBs alone do not isolate memory/eviction/durability policies. Redis remains per-site -if a shared Caddy (S13) is in use. - -### 2.10 Where the tooling runs - -Two layers. The boundary is set by one question: does this have to work before -an image can run? - -**The launcher.** `ghost-docker` (bash), the only code that runs on the host. -Keep it to a few hundred lines and to exactly these jobs: - -- Check that Docker is installed, the daemon answers, and Compose is present, and - say what to do when they are not. -- Decide which image to run: the digest pinned in the site's own launcher; or a - release resolved from `--channel`/`--release`; or, in a checkout of this - repository, an image built from it, tagged `ghost-docker:checkout` and - nothing else. -- Start it: `docker run` with the Docker socket, the site directory, the caller's - identity and a terminal, following the contract below. -- Pass the manager's exit status through unchanged. - -Anything that could be a manager command is one; the launcher gains no logic -that the manager could hold. - -**The manager image.** A TypeScript CLI on pinned Node and Alpine, with its npm -dependencies, the Compose binary, and the stack's files. -The manager speaks to the daemon over the Engine API on the mounted socket, -with each endpoint it uses typed by a zod schema; it does not parse the -`docker` CLI's output. Compose has no API and is run as a program. -One image, several commands; the upgrade supervisor (§2.6) is one of them, not a -second image. Every operation that reads or changes a site lives here: install, -configuration, Caddy rendering, import, diagnosis, update, backup and restore, -upgrades. - -Rationale: §2.6 already requires a privileged image published from this -repository, and requires that it "follows §2.5 rather than inventing a second -upgrade/recovery algorithm". One implementation in that image, used by every -command, is the only arrangement in which that holds. It also removes the host -as a variable: no bash 3.2 compatibility, no GNU-versus-BSD utilities, no list -of required host tools, and the same code path on Linux, macOS and WSL2. - -Contract for every manager invocation: - -- **The site directory is mounted at its own absolute host path.** Compose bind - sources are resolved by the daemon against the host filesystem, so a site - mounted anywhere else silently binds a different host path. Verified: a site - mounted at `/site` renders `source: /site/data/ghost`, which the daemon then - creates on the host. The launcher mounts `DIR` at `DIR` and sets it as the - working directory; the manager refuses to run when `PROJECT_DIR` and its - working directory disagree. -- **Identity.** The launcher passes the caller's uid and gid, on hosts that - have them. The entrypoint - starts as root, reads the group that owns the mounted Docker socket, and drops - to the caller's uid and gid with that group added, so everything written into - the site directory belongs to the caller and no ownership is fixed up - afterwards. Three cases it must recognise: - - *Rootless Docker:* root in the container already is the caller. Do not drop. - - *Docker Desktop and OrbStack:* file sharing maps ownership to the caller - whatever the container user is. Dropping is harmless and still done. - - *Files owned by a service's user,* such as MySQL's data directory once it - has run. Removing or archiving those needs root, in a short-lived step that - does only that. -- **The Docker socket** is host-privileged and is mounted for every command. - On Linux, support local daemons and the default and rootless socket paths: - resolve the socket from the active Docker context, and reject a remote daemon - clearly, because bind mounts would then refer to another machine's - filesystem. On Docker Desktop and OrbStack the daemon is in a VM and the - socket to mount is the VM's own, at the default path, whatever the host-side - endpoint is. -- **Terminal.** Prompts need the launcher to attach a terminal even when it was - itself piped from `curl`. Every prompt has a flag equivalent. -- **Bundles and other inputs** outside the site directory are mounted read-only - at their own absolute path, by the launcher, from the options it was given. -- **Exit codes and structured errors** propagate through the launcher - unchanged. A wrapper that collapses failures into "docker run failed" is not - acceptable. -- **Compose is run by the manager**, with its own client against the host - daemon: `--project-directory`, an explicit `-f`, and no inherited - `COMPOSE_FILE`. `docker compose config` is how configuration is resolved; do - not reimplement Compose interpolation. - -**Reaching a site's services.** The manager does not run programs inside a -site's containers to ask it questions. It joins the site's network and speaks -to each service itself: `mysql2` for the database (connectivity, table and row -counts, migration history), and a TLS handshake for the certificate Caddy -presents (`src/network.ts`, `src/clients.ts`). Ghost, Caddy and Mailpit are -otherwise judged by their own health checks; that Ghost's mail reaches -Mailpit is the install e2e's to prove. - -- The network is discovered, never guessed: the one the running containers - Compose lists for the site share, as the daemon reports them, so a network - an override renames or makes external is found the same way. -- Each service is addressed by its per-site alias (`db-`), which stays - unambiguous on a network several sites share, and otherwise by its address - on that network. -- The manager joins only a network that exists, because a container on it is - running, and leaves it however the work ends, before anything can take the - site down: `compose down` cannot remove a network with a container still on - it. Phases that overlap share one attachment, and the last to finish leaves; - a network the manager was already on is not taken from it. The supervisor - (§2.6) repeats these phases on the same code. -- A connection is closed in a bounded time, so leaving the network and - releasing the site lock never wait on the database. Each query has the - client's own deadline; a connection whose query timed out, or is still - outstanding, is destroyed at once, since mysql2 would queue the goodbye - behind an answer that may never come. One with nothing outstanding says - goodbye, and is destroyed after a second whether the server has closed its - side or not. -- Dumps are still made and loaded by the `mysqldump` and `mysql` of the db - container's own version, through `compose exec`: the clients ask questions, - they do not move data. A direct check proves a service answers on the - site's network; it does not replace the ingress checks of §2.8. - -What this rests on, that the daemon resolves the per-site aliases for a -container attached after it started, the network comes down once the manager -has left, and the clients really talk to the stack's MySQL and Caddy, -is what `manager/test/integration` tests, against the real daemon, from a -container of the manager Dockerfile's `integration` stage. - -**Engine API client: kept, not Dockerode.** Dockerode was evaluated (PLA-513) -and not adopted. Its 5.x release brings `@grpc/grpc-js`, `protobufjs` and -`tar-fs` for BuildKit sessions and build contexts, and `ssh2` through -`docker-modem` for remote daemons, none of which the manager uses (it refuses -a remote daemon); about 16 MB and 53 packages with the optional native -dependencies left out. Its answers are untyped, so every endpoint would still -be wrapped in the zod schema this section requires, and its tests would need -a fake HTTP server or mocks of its methods in place of the one transport -function they fake now, which is no smaller. Of the client's 536 lines of -code it would replace about 150: the socket transport, the request and -error helpers, scanning a pull's progress stream, demultiplexing a -one-shot container's log stream, and part of the create, start, wait and -remove sequence. About 250 if the zod schemas went too, leaving the -daemon's answers checked by nothing. The rest is the manager's own -policy, which stays either way: rootless detection, the ports of stopped -sites, label filters, a one-shot container's deadline, kill and cleanup, -and joining and leaving networks. - -**The Docker CLI is not in the image.** The image carries Compose's -standalone binary, not the `docker` CLI. Adding the CLI would let the manager -run `docker network connect` and the like, at the cost of a second client of -the daemon whose output would have to be parsed; everything it would do is -one Engine API request, typed. - -**Windows is WSL2.** Docker Desktop's recommended backend on Windows is WSL2, -and its WSL integration puts `docker` and the daemon socket inside the distro, -so the launcher runs there exactly as on Linux: a Unix socket to mount, a uid -and gid to pass, and a site directory that can be mounted at its own path. -Nothing above is special-cased for it. A migration from a Ghost-CLI install on -native Windows still works, in two steps: `ghost migrate-export` in the -Windows shell, then `install --import` from WSL2 with the bundle under -`/mnt/c/`. - -A native Windows launcher was built during N2 and removed before it merged. -It had to assume three things nobody had verified: that the Docker Desktop -VM's socket can stand in for the Windows named pipe, that files written by the -image's default user are usable from Windows, and that `C:\Users\me\site` -is `/run/desktop/mnt/host/c/Users/me/site` to the daemon. Every Windows -PowerShell 5.1 difference cost a CI round trip against a stand-in Docker that -could prove none of it. If native Windows is ever wanted, the design is not a -translating launcher. The manager can `docker inspect` its own container and -read the daemon-side `Source` of the site mount, so the launcher shrinks to a -few lines of `.cmd` with `-v "%CD%:/site"` and the manager learns the daemon's -path at runtime, verified rather than assumed. What that still needs working -out is how Compose, running inside the manager, reads the site's files at -that daemon-side path: a symlink inside the manager, if Compose does not -resolve it away. That is the open question recorded in Linear, and it is not -on the path to any milestone. - -**Qualifying a platform** is one path exercised end to end, which `doctor` -performs everywhere: the manager writes a file into the site directory, then -asks the daemon to start a **sibling container that bind-mounts the site -directory by the path the manager was given** and reads the file back. That -fails if the daemon cannot be reached, if the manager's path means a different -directory to the daemon, or if the file is not where the daemon looks. -`version`, or a `doctor` that only inspects the manager's own view, qualifies -nothing: all of it can be wrong while those succeed. - -What this does **not** solve, and should not be claimed to: Compose's dotenv -interpolation. Anything writing `.env` still encodes a literal `$` as `$$` -regardless of implementation language, and §2.1 records why the alternative -config format was rejected. - -What it costs, deliberately accepted: - -- Nothing can be diagnosed by the manager when the image cannot be pulled or - Docker is down. The launcher's own checks cover exactly that case and must - give an actionable message for each. -- Every command pays a container start. -- An image has to be published before anything can be installed, so image - publishing is part of the first step that ships a command (N2). - -Rules carried over from the first implementation, which hold whatever the -language: - -- Release discovery accepts only `vX.Y.Z` and `vX.Y.Z-beta.N`, ordered - numerically. -- Pull Ghost once, require a repository digest, and persist `GHOST_IMAGE_REF` as - `repository@sha256:...`. Ghost and Tinybird sync both execute this reference. - `GHOST_IMAGE`/`GHOST_VERSION` retain the requested repository/tag as - provenance. Changing those fields alone never changes an installed site's pin. - Upgrades, imports and restore set the complete reference deliberately and keep - metadata in sync. -- `GHOST_CONTENT_PATH` and `GHOST_TINYBIRD_PATH` come from the resolved image's - own `GHOST_CONTENT` and `GHOST_INSTALL`, so the layout and the configuration - cannot disagree. -- Generate a fresh `.env` in one atomic write through the one encoder. -- Use Compose `up --wait --wait-timeout` with the existing health checks and - one-shot dependency conditions, and keep a separate ingress check: the wait - establishes readiness, not reachability. -- HTTP and HTTPS probes use deadlines, bypass proxy configuration and preserve - Host/SNI. Routing, published ports and HTTPS issuance are the three separate - results of "Reaching a site in order to verify it" (§2.8); a production - install before DNS reports HTTPS as pending, not as a failure. - -## 3. Implementation steps - -Steps are work packages: one pull request each, against `next-docker`, stacked -on the steps they depend on. A step is done when its acceptance items are -demonstrated, not when its code exists. Mark a step's status in its section when -its pull request lands, and amend the affected contract in §2 in the same pull -request rather than afterwards. - -Step names: `N1`–`N3` are new with this architecture. `S`-numbered steps keep -the names they had in the first implementation, so existing pull requests, -issues and documents still resolve; the letter suffixes are parts of one step. - -"Reference" lines name files on the frozen `next` branch. They show behaviour -that was built, tested and reviewed there. Port the behaviour and its tests' -intent, not the bash. - -### Delivery order - -Dates agreed on 2026-10-08, after `v0.1.0-beta.1` shipped. Engineering dates -are kept in Linear; these are the targets they were set from. - -| Milestone | Outcome | Steps | Target | -| --- | --- | --- | --- | -| M0 Foundation | A manager image and launchers exist, and `install` works for local and production sites. | N1, N2, N3 | done | -| M1 Local sites | A theme developer or migration-tool author moves each local Ghost-CLI site to Docker with the documented steps. Local mode runs Ghost and MySQL with no Caddy. | S5b, S5c | done | -| M2 Tagged single-site production | Production installs from a tagged release at `docker.ghost.org`, updates between releases, has backup/restore, imports a production Ghost-CLI site, and migrates the pre-`next-docker` layout. | S6a, S4, S6b, S5e; S12 | S4 13 Oct, S6b 16 Oct, S5e 22 Oct; S12 18 Dec | -| M3 Multi-site | Several sites on one host behind one shared Caddy, each with its own MySQL. | S13 | 28 Oct | -| M4 One-click Admin updates | Ghost Admin requests an upgrade that the host executes and recovers. | S7, S8, S9, S10 | S7 21 Oct, S9 merged 21 Oct, S8 27 Oct, S10 merged 28 Oct | - -S11 and S14-S16 follow M4. - -October runs three tracks in parallel: migration (S4, S6b, S5e), multi-site -(S13) and one-click updates (S7-S10). A beta is cut before the pause, on -29 October, and active work pauses from **30 October**: November is a feedback -period on that beta. December is for what the feedback finds, then S12: the -first stable release, and `next-docker` merged into `main`, by **18 -December**. - -If October overruns, work slips in this order: S10 moves to a November Ghost -release; then converting existing sites into a shared Caddy (PLA-504) moves to -December; then the pause moves to 6 November. S4, S6b and S5e do not slip. - -```text -N1 foundation: stack files, contracts, plan done -N2 manager image, launchers, publishing done -N3 install, config, caddy, check done - -M1 -S5b local import done -S5c moving a local site, documented done - -M2 -S6a releases, served launcher, update done -S4 backup/restore, lock done -S6b legacy-layout migration needs S4, S6a -S5e production import and cutover needs S5b -S12 release qualification needs the rest of M2 - -M3 -S13 shared Caddy needs S4; converting existing sites (PLA-504) needs S6b - -M4 -S7 host Ghost upgrade needs S4, S6a -S8 supervisor needs S7, S9; S13 if shipped -S9 Ghost adapter/API Ghost PR #31277 -S10 Admin UI needs S9 - -Later -S11 file-based secrets needs S4-S8 credential consumers -S14 service images as full references needs S12 -S15 Ghost nightly channel needs S14 -S16 opt-in Redis needs S12 -``` - -S13 keeps MySQL per site, so a new member site needs only S4: backup, restore, -upgrade and import are the same for it as for a site alone. Converting a site -that already runs its own Caddy into a member is separate work (PLA-504), -which needs S6b. - -S7 depends only on S4 and S6a, and runs in October alongside the supervisor -that reuses its execution interface. Until it ships, the documented Ghost -upgrade is editing the exact version pin and running `docker compose up -d` -after `./ghost-docker backup`. - -Work that exists outside this branch: - -- `next` (frozen): the bash implementation of S1, S2 and S5b, with its tests. -- PR #300 (draft, against `next`): S4 as bash dispatcher plus a TypeScript - manager. Its `manager/` directory was the starting point for N2 and S4, - which have both landed; it is reference only. -- `codex/update-supervisor` (local branch): an S8 prototype stacked on PR #300. - Parked until the adapter contract in Ghost PR #31277 settles. - -### N1 — Foundation: stack files, contracts and plan - -Repo: ghost-docker. Deps: none. Bring to `next-docker`, from `next`, everything -that is not tooling: `compose.yml` with its profiles, health checks and labels; -the tracked `caddy/Caddyfile`, snippets and the `custom/`, `global/` and -`sites/` layout; `.env.example` and `ghost.env.example`; the contract documents -(`docs/bundle-v1.md`, `docs/configuration.md`, `docs/caddy.md`); the manifest -fixtures; and this plan. Image pins follow `main`, which has moved on since -`next` branched. No scripts and no tests of scripts. - -Acceptance: `docker compose config` resolves in local mode, production mode, and -production with both optional profiles, from a hand-written `.env`. The -documents describe commands as `./ghost-docker ...` and say that they do not -exist yet. - -Status: implemented by the pull request that introduced this revision of the -plan. After it the branch is not installable by any tool: a hand-written `.env` -is the only way to stand a site up until N3. The legacy `scripts/migrate.sh` -was deliberately not brought across: it sets no site mode and cannot generate -routes, so on this layout it produces a site that does not start. - -### N2 — Manager image, launchers and publishing - -Repo: ghost-docker. Deps: N1. The skeleton every later step fills in. Implement -§2.10 and the image half of §2.7. - -- `manager/`: a TypeScript CLI on a pinned Node image, with a real dependency - set (`package.json`, lockfile, install at build), a command dispatcher, - structured errors with the exit codes of §2.8, and the commands `version` and - `doctor`. `doctor` reports what the manager can see: Docker and Compose - versions, platform, the site directory, its ownership, and whether - `PROJECT_DIR` matches. PR #300's `manager/` (Dockerfile with Docker client and - Compose, type-stripped TypeScript, lint and type-check setup) is the starting - point. -- The image carries the release payload of §2.7 at a known path, and reports - its own version. A test fails when `compose.yml` references a bind mount or - build context that the image does not contain. -- The entrypoint implements the identity rules of §2.10, including the rootless - case. -- `ghost-docker` (bash), implementing the launcher contract of §2.10: Docker - checks with actionable messages, image selection (pinned digest, channel/ref, - or build from checkout), the `docker run` invocation, terminal attachment - when piped, exit-status passthrough. -- A workflow that builds the image for `linux/amd64` and `linux/arm64` and - publishes `edge` on pushes to `next-docker` and the version tag on release - tags, and CI that builds the image and runs the tests on Linux, plus the - launcher on macOS. - -A PowerShell launcher for native Windows was built in this step and removed -before merging; §2.10 says why and what to do instead if one is wanted. - -Acceptance: from a clone, `./ghost-docker version` and `./ghost-docker doctor` -build the image and run on Linux and macOS; a file the manager writes into the -site directory is owned by the caller under rootful and rootless Docker; the -launcher's refusals (no Docker, daemon down, no Compose) are tested without a -daemon; the manager's exit status and a usage error reach the caller unchanged; -the published `edge` image runs the same commands without a checkout. `doctor` -includes the sibling-container check of §2.10 and passes on Linux and macOS. - -### N3 — install, config, caddy, check - -Repo: ghost-docker. Deps: N2. The first commands: `install` for local and -production sites, `config get|set|unset|validate`, `caddy -render|apply|validate|reload`, and `check`, `info`, `list`. Implement §2.1-§2.3 -and §2.8. - -Port the behaviour of S1 and S2, which is recorded in `next`'s documents and -tests rather than restated here: preflight; stable project identity; generated -credentials; exact Ghost image resolution and digest pin; `.env` and `ghost.env` -written through one encoder with Compose round-trip tests; Caddy rendering, -validation, atomic installation, reload and verification; `.ghost-docker.json`; -readiness and ingress verification; never stopping or reconfiguring anything -already running. In image mode `install` writes the managed files of §2.7 and a -pinned launcher into the site directory; in clone mode it writes neither. - -New in this step, not on `next`: `--email`, the ACME account email. Caddy -needs none to issue, but Let's Encrypt sends expiry and incident notices to -it. It is a flag only, never a prompt: omitted means none. It is -rendered into the site's routes as `tls ` only when given, so it lives -with the site that uses it and `caddy/global/` stays operator owned; it is -validated as an address before it reaches the Caddyfile. It is not kept in -`.env`: the routes are the operator's file after install, and changing the -address is editing its `tls` line. - -Reference: `install.sh`, `scripts/lib/{env,config,compose,caddy,meta,preflight,install}.sh`, -`docs/install.md`, and `tests/{env,env-compose,config,caddy,compose-matrix,ingress,install,install-e2e,meta}.test.mjs` -on `next`. The test files are the most complete statement of what must hold. - -Acceptance: `tests/e2e/install.sh` installs a local and a production site from -the image and from a clone, on Linux and macOS, and checks what -`install-e2e.test.mjs` checked: the pin, file modes and ownership, a port -conflict as an error naming the port, after which the directory is as it was -and the same command succeeds on a free port, an existing proxy on 80/443 left -running, two local sites side by side, `--no-start` starting nothing, and -local ingress verified on the loopback port, and production ingress verified -by "Reaching a site in order to verify it" (§2.8) before DNS and a public -certificate exist: routing passes, the published ports are reported, and -HTTPS is reported as pending. A site -installed into an empty directory from the published image alone, with no -checkout, starts with `--with activitypub`, and its `analytics` helper images -build from the payload written there. The dotenv encoder passes the round trip through real containers for -`$VAR`, `${VAR}`, `$$`, spaces, both quote types, backslashes, newlines, empty -strings and JSON arrays. - -Status: implemented. `tests/e2e/install.sh` passes against Docker Engine 28 and -29 on Linux and against OrbStack on macOS; [install.md](install.md) documents the -commands. Decisions made while building it, which later steps rely on: - -- **Resolution.** The requested Ghost tag is pulled and the pin is the - repository digest the pulled image carries. Resolving the digest first - through the registry and pulling that would close the window in which the - tag moves between the pull and the inspect; it was built and taken out - again as not worth its code. -- **Verification** runs inside the site's own containers (`docker compose - exec`), not in a probe container of its own. A site that starts but fails - it is a failed installation and is removed. HTTPS is *serving* or *pending* - only; see §2.8 for what was dropped and why. -- **No `caddy` command.** `install` writes `caddy/sites/site.caddy` once and - the file is the operator's from then on (§2.3); `docs/caddy.md` lists the - edits people make. The `CADDY_*_DIR` placeholders, which existed so a staged - candidate could be validated with the tracked Caddyfile, are gone from the - Caddyfile and compose.yml. -- **A failed installation** is undone by `docker compose down --volumes` with - every profile enabled, then the data directories it created, removed as root - in a short-lived container of the manager image (MySQL owns its files by - then), then the files it wrote. Directories that existed before are kept. -- **Ports.** The only ports the manager can know are taken are the ones - containers publish; it refuses those before writing anything. A program - outside Docker holding a port is found at `up`. OrbStack publishes the port - over such a program without an error, so on macOS with OrbStack that conflict - is not detected at all; the e2e fails there, or records it as skipped with - `GD_E2E_ALLOW_SKIP=1`. The e2e itself - requests the host's ports with curl, which is the check the manager cannot - make. A failed `up` is reported in Compose's own words, which name the port - (their wording differs between Docker 28 and 29, so it is quoted, not - parsed), followed by `--port` and that nothing running was stopped. -- **The Tinybird path** is under the image's declared `GHOST_INSTALL`, as the - `next` variants lay Ghost out. An image that declares `GHOST_CLI_INSTALL` - (the older `-alpine` layout, with Ghost under `current/`) is refused, by - install and import alike. No container is started to look. -- **The oldest importable source** is Ghost 6.61.0, the first release - published as a `next` image (amd64 and arm64). An import runs at exactly - the bundle's version, in `VERSION-next-alpine` and nothing else, so an - older bundle is refused before anything is provisioned, whichever - Ghost-CLI wrote it; `ghost migrate-export` refuses older sources too. -- **`--channel` and `--release`** arrived with S6a. Choosing a release is the - launcher's job; the manager records the channel it was given (or the one - `--release` implies) and otherwise derives it from the version the manager image - carries (`vX.Y.Z` stable, `-beta.N` beta, `edge-…` edge). -- **Prompts.** At a terminal, `install` asks for the site mode and a - production site's domain when no option gave them (`@inquirer/select` and - `@inquirer/input`); `--no-prompt`, or no terminal, makes a missing answer a - usage error naming its option. `--with analytics` is a usage error that says - how to add analytics to the installed site: its `tinybird-login` job is an - interactive browser login and Ghost waits for the Tinybird jobs, so `up - --wait` cannot succeed before it. The final-phase options (`--ghost-channel`, - and `redis` for `--with`) are not accepted until their steps land; as - unknown values they exit 2. -- **The launcher passes `GD_COMPOSE_OVERRIDES`** into the manager when it is - set, so the opt-in to an override file in docs/configuration.md reaches the - manager's Compose runs. It is the one setting passed through, besides the - launcher's own contract (§2.10). -- **Operator keys** in the wrong file are derived from `compose.yml`'s - interpolations, the settings `.env.example` documents, `COMPOSE_*` and the - keys `.env` holds, so `GHOST_PORT` in `ghost.env` is caught on a local site too. -- **No `DOMAIN` setting.** Nothing in Compose or Caddy reads one since the - routes became a file, so the domain is the host of `URL` (and the admin - domain the host of `ADMIN_URL`); a second copy only needed a check that the - two agreed. -- **Metadata** gained `source`, `stack.image` and `payload` (the checksums of - §2.7); `schemaVersion` stays 1, since nothing has been released with it. -- **Fewer commands than listed above.** `caddy render|apply|validate|reload` - and `config unset` were cut before merging: the routes are an operator file - (above), and removing a line from an env file carries no encoding risk, so - `unset` bought nothing over an editor. -### S3 — Ghost-CLI export command - -Status: implemented and released in Ghost-CLI 1.33.0 (PR #2333). -`docs/bundle-v1.md` is in step with it. - -### S5 — Bundle import and migration cutover - -Repo: ghost-docker. Implement §2.4 as `install --import`, and document -moving a site with it. Three parts; each is its own pull request. (S5a, syncing the contract -with the released exporter, is done.) - -**S5b — Local import.** Deps: N3. Not S4; see "Local imports" in §2.4. Import -`sourceInstallType: local` bundles of kind `mysql-dump` and `mysql-data`. - -Status: implemented (`install --import`). `manager/src/bundle/manifest.ts` -is the zod schema, written to depend on nothing but zod so the exporter can -use it; `manager/src/bundle/stage.ts` unpacks with node-tar and yauzl; -`manager/src/import.ts` holds the steps. `tests/e2e/import.sh` is the -acceptance test. - -- Unpack with a tar library and an entry filter as in "Reading the bundle" in - `docs/bundle-v1.md`; directory bundles and zip archives too. -- Validate the manifest with a zod schema that encodes `docs/bundle-v1.md`, - written so that it can be published and used by the exporter. -- The exact source Ghost image; raw config values into `ghost.env`, dropping - container-owned keys; content placed before any container mounts it. -- `mysql-data`: Ghost boots once to create the schema, rows are loaded, and - per-table counts are compared with `database.rows`. -- `mysql-dump`: loaded as the site's own database user, with `DEFINER=` clauses - dropped from mysqldump's version-comment lines so that is possible. -- A failed import removes what it created; an interrupted one cannot be started - and is cleared by the next import. -- A `portable` bundle is installed with its content and no database load; the - summary names the Ghost Admin steps (§2.4). A `production` bundle is refused - until S5e. - -Reference: `scripts/lib/import.sh`, the import blocks of `install.sh`, -`tests/import.test.mjs` and `tests/e2e/import.sh` on `next`. That e2e script is -implementation-independent and is brought across with the launcher's command -names substituted; it is the acceptance test. - -Acceptance: `tests/e2e/import.sh` passes on Linux and macOS with real bundles -from Ghost-CLI 1.33.0 for a SQLite and a MySQL source: staff sign in with the -source password; post, asset and dotfile fidelity; a config value with `$` and -both quote types reaches Ghost byte for byte; the bundle is unmodified; a broken -dump and tampered row counts each leave the directory as it was; a re-run in the -same directory succeeds; `--no-start` leaves nothing running. Unit tests cover -every refusal in `tests/import.test.mjs`. - -**S5c — Moving a local site, documented.** Deps: S5b. "Moving a local -site" in §2.4: stop, export, import on the source's port, written up in -`docs/install.md`. No command. - -Status: done. `tests/e2e/import.sh` follows the documented steps for its real -SQLite and MySQL sources: the stopped source is not started by the export, -and the Docker site answers on the source's port. A launcher `--migrate` was -built and dropped (§2.4 says why). Ghost-CLI printing these steps after an -export is a later change in Ghost-CLI. - -**S5e — Production import and cutover.** Deps: S5b. Production bundles -(`sourceInstallType: production`): separate admin URLs, the same-server case -with an existing proxy on 80/443, and the documented cutover of §2.4 step 9. -Documents the production move the same way, with the source stopped -before the final export. - -Acceptance: a same-server migration with an existing proxy on 80/443, and a -cross-host migration following the documented cutover. This part is what -replaces the legacy `scripts/migrate.sh` on `main`: S12 must not merge this -branch into `main` before it passes. - -### S6 — Releases, the served launcher, and updates - -Repo: ghost-docker. Implement §2.7 in two parts. - -**S6a — Releases, served launcher, and `self-update`.** Deps: N3. A release workflow on -`next-docker` producing beta tags and dependency-only patch releases; -image tags `vX.Y.Z[-beta.N]`, `stable` and `beta` published from release tags; -tested release resolution; the workflow serving the launcher at -`docker.ghost.org`; managed-file checksums; and `self-update` between releases of -this layout as described in §2.7, without the legacy migration. - -Acceptance: the served launchers install the newest beta and an explicit -`--release`; version selection is tested against prerelease ordering rather than -lexical sort; a dependency-only change produces a release; `self-update` moves a site -between two releases with the Ghost pin unchanged and the launcher re-pinned, -refuses a downgrade, keeps a hand-edited managed file and writes the release's -beside it, and restores the previous files when validation fails before -services change. -Clone mode's `self-update` was built to this step's first acceptance (a failed -update between refs put the previous commit back; a dirty tree was refused) and -then removed (PLA-525): a checkout's update is git's and Compose's, and -`self-update` refuses it before it changes anything. - -Status: implemented. `tests/e2e/self-update.sh` covers `self-update` against -releases built locally, including a release whose Ghost never becomes healthy -and is stopped for the operator, who restores the backup it names, and a -checkout's refusal; `launcher.yml` -installs the newest beta and an explicit `--release` with the served launcher -after each release. Decisions made -while building it: - -- **No release-please** (§2.7). The Release workflow and - `scripts/release.ts` cut releases as Ghost and Ghost-CLI do, and - the commit skill gained their release-note emojis. A tag pushed with - `GITHUB_TOKEN` starts no workflow, so the release workflow calls the image - and launcher workflows itself; a release tag pushed by hand still publishes - through `image.yml`. Versions start at `v0.1.0-beta.1`; a bump is applied to - the newest release that is not a beta, and betas of a newer version count - up toward it. -- **Moving tags** go only to a release that is the newest on their channel, so - a patch to an older line never moves `beta` backwards. A release tag is - refused if the image already exists. The launcher is served only from the - newest release. -- **The release tooling is its own package**, `scripts/`, outside the - manager and its image: only tag validation and ordering are the - manager's (`manager/src/release.ts`). Both order releases with semver, - behind a check that accepts only the published formats. -- **GitHub Pages deploys from Actions**, not a `gh-pages` branch: each deploy - is the whole site built from the release, and there is no branch anyone - could push to by hand. The custom domain lives in the repository's Pages - settings, and the `github-pages` environment allows `next-docker`. -- **`--release`**, not the planned `--ref`: it names a release tag, never a - git ref, and `--version` is already the Ghost version. -- **The launcher's default** is the `beta` channel, which includes releases. - It resolves `--channel` and `--release`/`--to` itself and passes them on. A - pinned site's `self-update` runs the newest release on the channel recorded in - the launcher (`GD_PINNED_CHANNEL`), because the updater is the target. -- **Downgrades** are told by release number. A site on `edge` may update to - anything; a build that is not a release cannot update a site that runs one. -- **Ghost compatibility** is `MINIMUM.ghost` in `versions.ts`. A release that - raises it stops the update of an older site before anything changes, with - the upgrade sequence. -- **The lock** (§2.2) is `.ghost-docker.lock`, taken by `self-update`; S4 uses the - same module. An interrupted update's snapshot also blocks the next one - until the operator removes it. -- **Validation** requires Compose to resolve the project, which plain - `config validate` only warns about. -- **Metadata** gained `updatedAt` and `stack.previous`, required and `null` - until the first update; `stack.commit` and the unused `migrations` were - removed (PLA-525, PLA-526). `schemaVersion` stays 1 until the first stable - release fixes it (§2.7, "Compatibility"). -- **Backup-backed recovery** (PLA-512, ahead of S6b): `self-update` takes a - backup after its snapshot, and keeps it either way. A site whose `.env` - moves its data is refused, as `backup` refuses it. It first loaded the - backup automatically after any failure; once the release's services have - started, that would discard what Ghost accepted meanwhile, so a failure - after that point now stops them, puts the files back, and names the - backup to `restore` for the operator to choose. - -**S6b — Legacy-layout migration and transactional updates.** Deps: S4, S6a. The -release migration scripts (run in order, recorded in metadata, in a field this -step adds with the first migration that uses it), migration -`0001-compose-profiles`, the way in for installations that have no launcher, and -backup-backed recovery (§2.5). Gates -merging `next-docker` into `main`. - -Acceptance: update from the layout on `main`, including existing optional -profiles, custom Caddy routes/overrides, absent metadata, and an untagged -starting commit. A failure during a migration, the pull or startup ends restored, -or reports that the operator is needed, accurately. - -### S4 — Backup, restore and the lock - -Repo: ghost-docker. Deps: N3. `./ghost-docker backup` and `restore` as described -in §2.5, and the lock of §2.2 on backup, restore and (when they land) upgrade -and update. Backups are their own format, not migration bundles. - -PR #300 implemented a much larger version against the bash layout. Its -TypeScript (`manager/storage.ts`, `probes.ts`, `process.ts`) and its tests are -material to draw on; its journals, maintenance handling and bash dispatcher are -not carried over. - -Acceptance: back up a site with ActivityPub, restore it into the same directory -and into a fresh one, and verify the database (staff sign in), assets, theme and -configuration; a second operation is refused while one holds the lock; a stale -lock is reported by `check`; a dump that fails is an error, not a backup. - -Status: implemented. `tests/e2e/backup.sh` covers every acceptance item against -real containers; `docs/install.md` describes both commands. Decisions made -while building it: - -- **Live by default, consistent on request** (PLA-515): `backup` keeps the - site running, as Ghost-CLI's did, with the weaker guarantee documented. - `backup --consistent`, and the backup `self-update` takes, stop Ghost and - ActivityPub for the capture (dumps, content, site files) and start them - again, not recreated, before the scratch check; they end as they began on - success and failure. The manifest records `consistency`, as it records - `site.overrides` and `running`: required, with no default for a manifest - without them (§2.7, "Compatibility"). The backup version stays 1. `tests/e2e/backup.sh` seeds - ActivityPub records, changes them after the backup, and checks their exact - values after both restores. -- **One dump per database**, `database/.sql`, as the site's user: - `--single-transaction --no-tablespaces --set-gtid-purged=OFF`, no routines - or events, which need privileges the site's user does not have and Ghost - does not use. A dump that does not end with mysqldump's completion line is - refused as cut short. -- **"The dump loads" is checked in a scratch MySQL**, a one-shot container of - the site's own db image with the backup mounted read-only and no network, - never the site's own server. Its row counts go into the manifest, and the - number of tables must equal the live database's; Ghost's must have a - migration history. -- **The stack's files travel with an image-mode backup**, with the launcher, - so a restore anywhere runs exactly the images the site ran, whichever - manager restores it. A clone's backup records the commit checked out when - it is taken, asked of git then rather than read from metadata, and restores - into a clone checked out at it. -- **The manifest holds a SHA-256 of every file**, and restore checks them all, - and lists the archive, before it changes anything. -- **Restore always starts MySQL on an empty data directory** and loads the - dumps into it, over a site as into a new directory: no tables left over - from a newer schema, and the root password always matches the restored - `.env`. -- **Over a site, `restore` asks** (`--yes` answers; with no terminal it is - required), and sets the site aside in `.ghost-docker-restore/`, each data - directory moved as root by its own one-shot container because MySQL owns its - data. A site that cannot be stopped is left in place; a set-aside that fails - is moved back before anything is written. Once writing, a failure stops the - services and reports "needs the operator", with the services' observed - state and the steps to put it back, naming only copies that exist; it does - not put it back itself. `check` reports the directory and another restore is - refused while it is there. -- **Into a new directory**, the site keeps its project name and ports, so - containers of the same project and busy ports are refused, named, never - stopped. `PROJECT_DIR` and the metadata's `site.dir` are rewritten to the - new directory. -- **The backup is a positional argument**, `restore `. The launcher - mounts it read-only at its own path when it is outside the site, as it does - `--import`'s bundle. -- **A site whose `.env` moves its data** (`UPLOAD_LOCATION`, - `MYSQL_DATA_LOCATION`) is refused rather than half backed up. -- **One inventory of a site's files** (PLA-522): `siteFiles` in `meta.ts`, - with the overrides `GD_COMPOSE_OVERRIDES` adds, is what a backup copies, an - update snapshots and a restore sets aside, together with the backup's own - files, so a restore that fails after writing an override has the site's - copy of it. Each copy set aside is compared with its original before the - original is removed. -- **A backup restores the images its site ran** (PLA-522). Every stack image - is pinned by digest, so the only way a configured image and a running one - differ is configuration not yet applied, such as a clone checked out at a - commit that bumps MySQL. Of the contracts weighed (restore the configured - images and say so; take the backup and refuse it at restore; or restore - what ran through an override the manager would write), backup refuses such - a site before capturing anything, as `self-update` does before its - snapshot: every backup is then one whose configuration and running images - are the same, and the operator resolves the difference while it can still - be resolved. A stopped container counts as much as a running one: a - backup that started a database whose configuration names another image - would upgrade its data before anything was captured. A restore checks it - again before it changes anything: Compose resolves, in the destination, - exactly the inputs the restore will write (the backup's files where it - holds them, with a clone's own `compose.yml`, the overrides the manifest - records, and the `.env` with the destination as `PROJECT_DIR`), which - must name the recorded images and mount the data where restore loads it; - and each pulled image must be, by ID or registry digest, the one that - ran. Validation and the restore read the same inputs, so an absolute - override path or data path means the same to both. -- **No metadata changes**: `schemaVersion` stays 1. - -### S7 — Host-driven Ghost upgrades - -Repo: ghost-docker. Deps: S4 and S6a compatibility rules. Implement `./ghost-docker -update [version|latest]` following §2.5, initially without a supervisor. Specify the reusable -execution interface so the supervisor cannot diverge from backup/recovery behavior. -Keep supported majors/downgrades constrained and feature compatibility explicit. - -Acceptance: upgrade across a real database migration, with the analytics sync -and deploy run; a target that fails to start after migrating is restored from -the backup and reported `restored`, with every write Ghost accepted during the -upgrade still there (the writers' pause of §2.5); a concurrent second request -is refused by the lock; another major and a downgrade are refused. - -### S8 — Supervisor command, protocol, and installer integration - -Repo: ghost-docker. Deps: S7, S9; S13 if it has shipped. A -prototype exists on `codex/update-supervisor`; rework it against the adapter -contract merged from Ghost PR #31277 rather than starting over. Write -`docs/upgrade-supervisor.md` with the exact §2.6 schemas, transitions, ownership, -policy, and recovery rules, then implement the supervisor as a long-running -command of the manager image. Reuse S7 behavior. Wire `--with supervisor` and -request submission/status tooling. Scope is §2.6's minimum: one active job, -durable status, `interrupted` for work found unfinished, a 20-job history; no -queue and no resuming. - -The status lists available updates as one-click and manual (§2.6): Ghost -upgrades the policy allows, and stack updates and Ghost upgrades it does not, the -latter with the reason and the host command. - -Acceptance: handwritten requests work before Ghost gains an adapter; duplicate and -malformed requests, permission violations, and stale status behave correctly; a -request while a job is active, or while the CLI holds the lock, is refused; a -supervisor killed mid-job reports that job `interrupted` on its next start, with -its backup, and leaves the lock for the operator; history stays within its bound. Verify actual exchange permissions as -Ghost's runtime uid. Installer must not enable an incompatible Ghost adapter. +ordering, image architecture, and compatibility requirements. A valid Docker tag alone +is not evidence that an upgrade path is supported. ### S9 — Ghost upgrade adapter and Admin API @@ -1750,163 +206,211 @@ request/job APIs, rate limiting, capability reporting, and update-notification m Noop is default; missing/stale supervisor and malformed protocol data produce useful status without making unrelated Ghost startup depend on supervisor availability. -Acceptance: adapter/controller tests, API permission tests, tmp-exchange protocol -tests, version compatibility, and initialization ordering. Keep Admin UI separate. +Acceptance: adapter/controller tests, API permission tests, tmp-exchange protocol tests, +version compatibility, and initialization ordering. Keep Admin UI separate. + +Use adapter type `upgrade` and enable FileDrop only for a compatible Ghost version and +an initialized exchange. API endpoints are status, create request and fetch job; +owner/admin only, with a rate-limited POST and normal Admin auth. ### S10 — Admin update experience Repo: Ghost Admin. Deps: S9. Add the current-version/available-update panel using the repository's current React/Shade and API conventions. Feature-detect older backends, show host capabilities, confirm downtime/backup behavior, and display durable job -progress with bounded reconnection and recovery guidance. Wire notification links. -Show the status's two update lists (§2.6): one-click updates with an action, and -manual ones as a notice with their reason and host steps, such as a stack update -to run with `./ghost-docker self-update`. +progress with bounded reconnection and recovery guidance. Wire notification links. Show +the status's two update lists ([protocol +requirements](#supervisor-protocol-requirements)): one-click updates with an action, and +manual ones as a notice with their reason and host steps, such as a stack update to run +with `./ghost-docker self-update`. Acceptance: older backend, unsupported adapter, owner/admin permissions, successful -restart/reconnect, a refused request while another job runs, stale supervisor, -failed restore (`needs-operator`), and `interrupted` states. Include an integration test with the real supervisor after mocked UI tests. +restart/reconnect, a refused request while another job runs, stale supervisor, recovery +requiring the operator (`needs-operator`), and `interrupted` states. Include an +integration test with the real supervisor after mocked UI tests. + +Feature-detect both absent endpoints on older cores and `supported: false`. UI backup +promises must match the actual enforced policy. ### S11 — Optional file-based secrets -Repo: ghost-docker. Deps: N3 and the credential consumers in S4-S8. Add Compose secret -files and `_FILE` wiring only for Ghost versions known to support it. Importing older -Ghost 6 images must still work via their supported credential mechanism. Migrate -without changing existing initialized MySQL credentials accidentally. +Repo: ghost-docker. Deps: the credential consumers in backup, import, restore and S7-S8. +Add Compose secret files and `_FILE` wiring only for Ghost versions known to support it. +Importing older Ghost 6 images must still work via their supported credential mechanism. +Migrate without changing existing initialized MySQL credentials accidentally. -Update MySQL init scripts, healthchecks, ActivityPub, backup/import/restore helpers, -and supervisor consumers together. Do not assume ActivityPub supports MySQL image -`_FILE` conventions. Set file ownership/readability for actual container users; -host mode 0600 alone does not guarantee container access. +Update MySQL init scripts, healthchecks, ActivityPub, backup/import/restore helpers, and +supervisor consumers together. Do not assume ActivityPub supports MySQL image `_FILE` +conventions. Set file ownership/readability for actual container users; host mode 0600 +alone does not guarantee container access. Acceptance: root credentials remain absent from Ghost regardless of this feature; -file-enabled supported services do not expose their secret values in environment; -legacy environment-based installs, older imports, restart, and restore still work. +file-enabled supported services do not expose their secret values in environment; legacy +environment-based installs, older imports, restart, and restore still work. ### S12 — Single-site release qualification and documentation -Repo: ghost-docker, with cross-repo fixtures. Deps: M2 (S4, S5, S6) at minimum; qualify -S13, S7-S10 and S11 when they have shipped. Gates the first stable tag and merging -`next-docker` into `main`. Include the launcher on Linux, macOS and WSL2. -Consolidate CI and qualify the actual minimum supported tools and image versions. -Run fresh local/production install, optional-service variants, CLI migration, -legacy stack update, Ghost upgrade/recovery, supervisor/Admin, and restore scenarios. -Include Linux runtime tests and macOS-compatible shell/configuration checks. -Record the compatibility baseline (§2.7, "Compatibility"): the exact metadata +Repo: ghost-docker, with cross-repo fixtures. Deps: S5e and S6b, plus existing install, +backup/restore and self-update; qualify S13, S7-S10 and S11 when they have shipped. +Gates the first stable tag and merging `next-docker` into `main`. Include the launcher +on Linux, macOS and WSL2. Consolidate CI and qualify the actual minimum supported tools +and image versions. Run fresh local/production install, optional-service variants, CLI +migration, legacy stack update, Ghost upgrade/recovery, supervisor/Admin, and restore +scenarios. Include Linux runtime tests and macOS-compatible shell/configuration checks. +Record the [compatibility baseline](architecture.md#compatibility): the exact metadata schema, backup format, launcher contract and bundle versions the stable release supports, and the obligations that hold for them from then on. README/help include quick starts, prerequisites, version/compatibility policy, backup/restore, migration losses and cutover, custom proxy configuration, diagnostics, and uninstall. Document deletion of bind-mounted data separately from `down -v`, with -explicit recovery consequences. Explain command equivalences without claiming full -CLI parity for unsupported features. Describe shared infra according to whether -S13 has shipped. +explicit recovery consequences. Explain command equivalences without claiming full CLI +parity for unsupported features. Describe shared infra according to whether S13 has +shipped. ### S13 — Optional shared Caddy -Repo: ghost-docker. Deps: S4 for new member sites; converting an existing -site into a member (PLA-504) also needs S6b. Several sites on one server, as Ghost-CLI ran -several sites behind one nginx. 80 and 443 can belong to one Caddy only, so that -Caddy is shared; everything else stays per site, MySQL included, so backup, +Repo: ghost-docker. Depends on backup/restore for new member sites; converting an +existing site into a member (PLA-504) also needs S6b. Several sites on one server, as +Ghost-CLI ran several sites behind one nginx. 80 and 443 can belong to one Caddy only, +so that Caddy is shared; everything else stays per site, MySQL included, so backup, restore, upgrade and import are unchanged. - `install --infra` sets up the shared Caddy once: a Compose project of its own, with an external network each member site's Ghost (and optional services) also - joins. `install --domain` on a server that has it adds the site as a member + joins. Infra-only configuration must resolve without a Ghost `URL`; review + required-variable guards and the profile validation when adding this mode. `install --domain` on a server that has it adds the site as a member instead of starting a Caddy of its own. - Each member's routes are a file in the shared Caddy's `sites/` directory, - written once by its install and then the operator's, as in N3. Upstreams are - the member's unique aliases, which N1 already requires. + written once by its install and then the operator's, as for a single-site install. Upstreams are + the member's unique aliases, described in [configuration.md](configuration.md#service-names-on-the-network). - `list` shows the members; removing a site removes its routes file. - Ghost publishes on loopback as before, so a member can still be reached without Caddy. -Acceptance: a shared Caddy with two members on different Ghost versions, each -with its own optional services; upgrading, restoring or removing one leaves the -other untouched; a duplicate domain is refused; a Caddy restart brings both back. +Acceptance: a shared Caddy with two members on different Ghost versions, each with its +own optional services; upgrading, restoring or removing one leaves the other untouched; +a duplicate domain is refused; a Caddy restart brings both back. ### S14 — Service images as full references -Repo: ghost-docker. Deps: S12; independent of S13. Follow §2.9. Give every service -image in `compose.yml` an `.env` variable holding a full, registry-qualified -reference, defaulting to the release's pinned reference with its digest, as -`GHOST_IMAGE_REF` does for Ghost. Document overriding one with `config set` to use a -mirror or another registry. Record each resolved reference and digest in the -metadata as images are pulled; the backup manifest already records them. `check` -names any overridden service and the version its image declares. No -`--image-registry` flag and no registry state: the reference is the choice. +Repo: ghost-docker. Deps: S12; independent of S13. Give every service a full, +registry-qualified reference behind an `.env` variable, pinned by the release with its +digest. `config set` selects a mirror or another registry. There is no registry selector +or separate registry state: the reference is the choice. -Publishing the Tinybird helpers as images (§2.7) belongs here, as references like -the rest. +Publishing the Tinybird helpers as images belongs here, as references like the rest. Acceptance: a site with ActivityPub and its migration image overridden to another -registry installs, updates its stack (keeping the overrides), backs up and restores -with the overridden references recorded exactly; an unreachable registry or a -missing architecture fails naming the reference, with no fallback; a reference -without a digest is recorded with the one it resolved to; and an override whose -image declares another version is reported by `check`. ActivityPub's app and -migration references stay aligned, or `check` says they are not. +registry installs, updates its stack (keeping the overrides), backs up and restores with +the overridden references recorded exactly; an unreachable registry or a missing +architecture fails naming the reference, with no fallback; a reference without a digest +is recorded with the one it resolved to; and an override whose image declares another +version is reported by `check`. ActivityPub's app and migration references stay aligned, +or `check` says they are not. + +The manager never rewrites a reference to another registry or falls back to one. +Unavailable registries and missing architectures fail clearly, naming the reference. + +Extend metadata to record every pulled reference and resolved digest; the backup +manifest already records each service's resolved reference and running image identity. A +reference without a digest is resolved to one when it is pulled, and that digest is what +is recorded. A different registry is a different reference whose digest is checked, +never assumed equal to the first. Changing a service's reference is a configuration +change, not an upgrade: `check` reports the version the image declares, and an override +that moves a service to another version is the operator's choice, made visible, never +the manager's. Stack updates keep a site's overrides (`.env` is the operator's), and +backup, restore and the supervisor use whatever references the site resolves. Public +pulls must work without credentials; dual publishing upstream is welcome but is not a +ghost-docker requirement. ### S15 — Opt-in Ghost nightly channel on GHCR -Repos: Ghost/image publishing workflow and ghost-docker; Ghost Admin/API if channel -or build metadata requires extending the existing upgrade interface. Deps: S14 image -references and existing S7-S10 upgrade integration. Follow §2.9. Add +Repos: Ghost/image publishing workflow and ghost-docker; Ghost Admin/API if channel or +build metadata requires extending the existing upgrade interface. Deps: S14 image +references and existing S7-S10 upgrade integration. Follow the requirements below. Add `--ghost-channel stable|nightly`, with stable as default and nightly explicitly opted -in. Nightly images are published to GHCR with immutable source/build identities. -Keep the stack `--channel` independent and do not equate nightly selection with -unattended upgrades. +in. Nightly images are published to GHCR with immutable source/build identities. Keep +the stack `--channel` independent and do not equate nightly selection with unattended +upgrades. -Acceptance: stable installs never select nightlies; opt-in resolves an exact GHCR -build on supported architectures; successive builds with identical Ghost semver are +Acceptance: stable installs never select nightlies; opt-in resolves an exact GHCR build +on supported architectures; successive builds with identical Ghost semver are distinguishable; tinybird-sync uses the selected Ghost artifact. Exercise discovery failure, missing images, host major-policy enforcement, backup/recovery, and explicit -channel transitions. Nightly-to-stable must refuse unsafe schema transitions rather -than pretending that image selection rolls back the database. +channel transitions. Nightly-to-stable must refuse unsafe schema transitions rather than +pretending that image selection rolls back the database. + +Publish from an identified source commit, with immutable build tags/metadata and an +optional moving nightly discovery tag. Installation/upgrade resolves discovery to one +exact build and digest; never persist only a moving tag. Use the same Ghost image for +tinybird-sync. Record the Ghost-reported version plus commit/build identity: two nightly +builds may report the same semver. Build discovery and job schemas must handle that +deliberately, without relaxing trusted image allowlists to arbitrary user-supplied +references. + +Only nightly sites discover nightly updates. Enabling the channel does not bypass host +major-version policy, backups, writer pauses, compatibility checks, or recovery. +Stable-to-nightly and nightly-to-stable are explicit compatibility-checked transitions; +returning to stable may require waiting for a compatible release or restoring a +checkpoint because schema migrations cannot be undone by changing a tag. Retain exact +recovery images/build metadata even if registry retention removes old nightly tags. Show +the channel and build identity in CLI/status/Admin where applicable. ### S16 — Opt-in per-site Redis caching -Repo: ghost-docker, verifying behavior against supported Ghost versions. Deps: S12 -and existing configuration, installer, backup, upgrade, and secret interfaces; -independent of S13-S15. Follow §2.9. Add `install --with redis` and the `redis` -profile for existing sites, with the service, version-aware Ghost cache wiring, -private network, healthchecks, credentials, resource policy, diagnostics, and -enable/disable steps. Keep Redis per-site even when shared infra exists. Default -installs are unchanged. - -Measure before proposing a default: memory and start-up cost of the extra service, -and Ghost's response times with and without it, on a local install and a small -production site, on supported Ghost versions. Record the results in this plan; a -default for production, and separately for local installs, is a later decision -made from them. - -Acceptance: `--with redis` installs, local and production, use Redis; default -installs start no Redis and behave as before; enabling and disabling it on an -existing site keeps operator overrides. Verify real cache reads/writes and -invalidation, resource limits, restart/outage/reconnect, upgrade/restore behavior, -secret handling, and supported Ghost version coverage. If S13 has shipped, verify -two sites do not share cache data or expose Redis through the shared ingress -network. Specify cache rebuilding/persistence behavior explicitly. Do not wire -speculative traffic-analytics/ActivityPub consumers until their released -interfaces exist and their cache-versus-durable-state requirements are established. - -## 4. Reference notes from review - -- Compose interpolates inactive services. `env_file` does not supply Compose's own - `${...}` interpolation. Double-quoted dotenv values can interpolate dollar signs. -- A Compose profile named in the environment enables nothing unless a service lists - it. Profiles do not change a service's fields or combine as logical AND conditions. -- `COMPOSE_FILE` affects override auto-loading; explicit `-f` replaces the selected - list. Every helper, supervisor invocation, and IPv6 example must use one contract. -- Host and container bind paths must agree for a container invoking host Docker. - Docker context and user namespace differences also affect paths/ownership. -- Restart policy is not migration orchestration or readiness. One-shot jobs must - remain one-shot, and failed schema migrations need database-aware recovery. -- The existing MySQL init script reads root credentials from environment; update it - as well as the healthcheck when introducing secret files. -- Git checkout cannot overwrite an untracked file with a tracked file. Pre-checkout - migration backups and recovery must include configuration, not just a Git ref. -- Authoritative references: [Compose profiles](https://docs.docker.com/compose/how-tos/profiles/), - [dotenv interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) - and [Caddy commands](https://caddyserver.com/docs/command-line). +Repo: ghost-docker, verifying behavior against supported Ghost versions. Deps: S12 and +existing configuration, installer, backup, upgrade, and secret interfaces; independent +of S13-S15. Follow the requirements below. Add `install --with redis` and the `redis` +profile for existing sites, with the service, version-aware Ghost cache wiring, private +network, healthchecks, credentials, resource policy, diagnostics, and enable/disable +steps. Keep Redis per-site even when shared infra exists. Default installs are +unchanged. + +Measure before proposing a default: memory and start-up cost of the extra service, and +Ghost's response times with and without it, on a local install and a small production +site, on supported Ghost versions. Link the measurements from the proposal; a default +for production, and separately for local installs, is a later decision made from them. + +Acceptance: `--with redis` installs, local and production, use Redis; default installs +start no Redis and behave as before; enabling and disabling it on an existing site keeps +operator overrides. Verify real cache reads/writes and invalidation, resource limits, +restart/outage/reconnect, upgrade/restore behavior, secret handling, and supported Ghost +version coverage. If S13 has shipped, verify two sites do not share cache data or expose +Redis through the shared ingress network. Specify cache rebuilding/persistence behavior +explicitly. Do not wire speculative traffic-analytics/ActivityPub consumers until their +released interfaces exist and their cache-versus-durable-state requirements are +established. + +Ghost's in-memory cache remains the default. A default Redis would cost every site an +extra container; separate evidence is needed for production and local defaults, as +described above. + +Use a pinned supported Redis image, private site networking with no published host port, +healthchecks, bounded memory, defined eviction/persistence settings, and credentials +handled through the established secret/config interfaces. Configure the actual cache +features and adapter schema supported by the selected Ghost image; review +`docs/codebase/internal-caching.md`, the built-in Redis adapter, and [Ghost's cache +documentation](https://docs.ghost.org/config#cache-adapters). Do not assume every older +supported image accepts the same cache configuration. Incompatible images must use a +documented supported configuration or fail preflight with the opt-out path, rather than +producing a broken default install. + +For existing sites, document enabling it: add the profile, which the manager wires into +Ghost's cache configuration, preserving operator cache overrides and exact image pins. +Routine stack updates must not unexpectedly switch an existing cache backend. Disabling +Redis must also remove/revert generated cache configuration; do not stop it while +leaving Ghost pointed at it. Define/test startup ordering, runtime outage behavior, +reconnects, cache invalidation after upgrade/restore, and the effect of opting out. Do +not promise automatic runtime fallback unless the selected Ghost implementation actually +provides it. + +Initial Redis use is rebuildable Ghost cache data. Explicitly document whether it is +persisted for warm restarts and whether backups exclude/rebuild it. Potential later +traffic-analytics/ActivityPub use is an extension point, not a claim of current Redis +support. Before wiring any such consumer, verify its released configuration contract and +distinguish disposable cache from durable queues/counters/salts/other state. Durable +state needs appropriate persistence, eviction, isolation, backup/restore, and upgrade +policy; separate instances when policies differ. Key prefixes or Redis logical DBs alone +do not isolate memory/eviction/durability policies. Redis remains per-site if a shared +Caddy (S13) is in use. diff --git a/docs/install.md b/docs/install.md index a8fd035f..fb45cef5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -160,7 +160,7 @@ with it before that login. Add it to an installed site: set the tokens with the site's domains, its network aliases and every snippet argument. It is yours from then on; see [caddy.md](caddy.md). 8. **Metadata**: `.ghost-docker.json`, described in - [configuration.md](configuration.md#installation-metadata). + [architecture.md](architecture.md#installation-metadata). 9. **Start and verify**, unless `--no-start`: `docker compose up --wait`, so MySQL and Ghost must pass their own health checks, then the site is reached through its own ingress (next section). A running container is not @@ -227,7 +227,7 @@ What to expect: run `ghost update` in the source, check the site, and export again. - **A new address.** The site is served at `http://localhost:PORT`, on the first free port at or above 2368 unless `--port` says - otherwise. An ordinary export leaves the source running, so the two sit side + otherwise. An ordinary export preserves the source's original running state, so the two sit side by side until you run `ghost stop` in the source directory. They are separate copies from the moment of export. - **Nothing is merged.** The directory must not already hold a site, and @@ -286,7 +286,7 @@ Refused, each with a message that says so: - `--import` with `--with`: import the site first, then enable optional services. -See the [plan](ghost-cli-replacement.md) for where each lands, and +See the [roadmap](ghost-cli-replacement.md) for remaining work, and [bundle-v1.md](bundle-v1.md) for the bundle contract. ### Moving a site to Docker @@ -436,7 +436,8 @@ Moves a site installed from the image to a newer release of ghost-docker: the stack's files and the manager image. A clone of the repository is updated with git and Compose instead ([Updating a clone](#updating-a-clone)). **It never changes Ghost.** `.env`, and the exact Ghost image `GHOST_IMAGE_REF` pins, are left as they are; updating Ghost is a separate -command, `update` (S7). When a release needs a newer Ghost than the site runs, +command planned in [S7](ghost-cli-replacement.md#s7--host-driven-ghost-upgrades). +When a release needs a newer Ghost than the site runs, `self-update` stops before changing anything and says to upgrade Ghost first. Without options it updates to the newest release on the channel the site @@ -451,51 +452,31 @@ site's launcher starts that image, not the one it is pinned to. | `--channel CHANNEL` | The newest release on `stable` or `beta`, which the site then follows. | | `--to vX.Y.Z` | Exactly that release. | -In order: - -1. **Refusals.** An older release than the site runs is refused, as is a site - with no `.ghost-docker.json`, a clone, another operation holding the site's lock, - and a snapshot left by an update that did not finish. Nothing has changed. -2. **The lock.** `.ghost-docker.lock` names the operation and when it started. - An update that is killed leaves it behind; `check` reports it, and it is - removed by hand once the site is known to be right. Nothing removes it - automatically. -3. **The release's images** are pulled while the site keeps running, so the - slowest step is not part of the outage. An image that cannot be pulled - yet is pulled again in step 6. -4. **A snapshot** of `.env`, `ghost.env`, the metadata, `compose.override.yml`, - `caddy/sites/`, `caddy/custom/`, `caddy/global/` and every file the update - writes, in `.ghost-docker-update/`. -5. **Ghost and ActivityPub are stopped, and a backup taken**, a consistent one - as `backup --consistent` takes, in `backups/`: a release's services can - migrate their databases (ActivityPub's, for one) even though Ghost does - not change. It is kept until you remove it, and `self-update` names it. - Unlike `backup`, the update does not start them again after the capture: - they stay stopped until the release starts, or the site is put back, so - nothing they would accept can be lost by loading the backup back. The - site is unavailable from here until the release is healthy. -6. **The stack's files.** A file the manager wrote and nobody has edited is - replaced. An edited one (its checksum is not the one recorded when it was - written) is kept, the release's version is written beside it as - `.new`, and `self-update` names both; compare them and merge what you - need. It never asks. A file the release no longer has is removed when it - is untouched, and kept when it was edited. -7. **Validate, pull, start, verify.** Compose must resolve the project and the - configuration must validate; the release's images are pulled; `up --wait` - brings the services up healthy; the site is verified through its ingress as - `check` does. -8. **The launcher** is replaced, pinned to the new release's digest. It is - replaced even when it was edited, because it holds the pin: an edited copy - is kept as `ghost-docker.edited`. The metadata records the release, the - one before it, and the files' new checksums. The snapshot is removed. - -When a step after the snapshot fails before the release's services have -started, the snapshot is put back and Ghost and ActivityPub are started -again, the same containers, on the files as they were: **restored**. Nothing -was written meanwhile, since they were stopped from before the backup. - -Once the release's services have started, Ghost may accept writes, and the -release may change the databases and content, before a later step fails. +The update refuses downgrades, incompatible Ghost versions, absent metadata, +checkouts, another operation's lock and an unfinished update snapshot. It pulls +images before the outage where possible, keeps a file snapshot in +`.ghost-docker-update/`, then pauses Ghost and ActivityPub and takes a consistent +backup. The site is unavailable until the release starts and verifies. + +Untouched managed files are replaced. Edited files stay beside the release's +`.new`; compare and merge them. Removed managed files are deleted only if +untouched. On success, the launcher is pinned to the new release, metadata is +updated and the snapshot is removed. An edited launcher is kept as +`ghost-docker.edited` because its pin must be replaced. + +The backup stays in `backups/` until you remove it. The lock names the operation +and start time; a killed update leaves it for you to inspect and remove only +after checking the site. The [architecture](architecture.md#recovery) describes +the recovery invariants. + +When an update fails after the snapshot but before attempting service startup, +the snapshot is put back and any paused writers resume in the same containers, +on the files as they were: **restored**. A failure to restore files or resume +writers is reported as needing the operator. + +Once startup has been attempted, even if `up --wait` fails, Ghost may accept +writes and the release may change the databases and content before a later +step fails. Loading the backup would discard those, so the update never does it by itself. It stops the services, puts the files back (the launcher still runs the previous image), leaves the data as it is, and says **the site needs diff --git a/ghost-docker b/ghost-docker index 62267d48..044864e8 100755 --- a/ghost-docker +++ b/ghost-docker @@ -29,7 +29,7 @@ # # Requires bash and Docker with the Compose plugin. Nothing else. On Windows, # run it inside WSL2. The contract this implements is -# docs/ghost-cli-replacement.md §2.10. +# docs/architecture.md#launcher-and-manager. set -euo pipefail readonly GD_REGISTRY_IMAGE="ghcr.io/tryghost/ghost-docker" diff --git a/ghost.env.example b/ghost.env.example index 129944ee..678ffa67 100644 --- a/ghost.env.example +++ b/ghost.env.example @@ -5,7 +5,7 @@ # (https://ghost.org/docs/config/). # # Do NOT put operator or infrastructure settings here: COMPOSE_*, PROJECT_DIR, -# DOMAIN, ports, data locations and DATABASE_ROOT_PASSWORD belong in `.env`. +# ports, data locations and DATABASE_ROOT_PASSWORD belong in `.env`. # # The following keys are owned by the container and set as explicit Compose # `environment` entries, which override this file. Setting them here has no diff --git a/help b/help index 420fc0b8..76536336 100755 --- a/help +++ b/help @@ -1,82 +1,41 @@ #!/usr/bin/env bash cat << 'HELPEOF' -════════════════════════════════════════════════════════════════════ - GHOST DOCKER HELP & COMMANDS -════════════════════════════════════════════════════════════════════ - -INSTALLATION (see docs/install.md): - ./ghost-docker install --domain example.com [--email ops@example.com] - ./ghost-docker install --local - ./ghost-docker check # diagnose this site - ./ghost-docker list # every ghost-docker container on this host - ./ghost-docker info # recorded installation metadata - - Planned: install --import BUNDLE (a local Ghost-CLI site), update, backup. - -SITE MODES (exactly one, selected in COMPOSE_PROFILES in .env): - local ghost + db, published on 127.0.0.1:${GHOST_PORT} - production ghost + db + caddy, HTTPS on ${HTTP_PORT}/${HTTPS_PORT} - - Optional, additive, per-site profiles: analytics, activitypub - -COMMON COMMANDS: - docker compose up -d # Start the services for the selected mode - docker compose down # Stop all services - docker compose ps # Check service status - docker compose logs -f ghost # View real-time Ghost logs - docker compose logs -f caddy # View Caddy webserver logs - docker compose restart ghost # Restart Ghost container - -CONFIGURATION: - .env Compose and operator settings, including the MySQL root - password. Never passed into the Ghost container. - ghost.env Ghost application settings only (section__key form). - - ./ghost-docker config validate # Check both files by mode - ./ghost-docker config set ghost.env KEY VALUE # Write safely and atomically - - Restart Ghost after changes: docker compose up -d - -ROUTING (production): - This site's routes: caddy/sites/site.caddy (written by install; yours to edit) - Other sites: caddy/custom/*.caddy - Global options: caddy/global/*.caddy - - After editing: docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile - -TROUBLESHOOTING: - docker compose exec ghost sh # Access Ghost container shell - docker compose logs --tail=100 # View last 100 log lines - docker stats # Monitor resource usage - -DATABASE ACCESS: +GHOST DOCKER + +MANAGER COMMANDS: + ./ghost-docker help # Supported commands and options + ./ghost-docker install --help # Installation and local bundle import + ./ghost-docker check # Diagnose this site + +DAILY OPERATION (Docker Compose, from the site directory): + docker compose up -d + docker compose down + docker compose ps + docker compose logs -f ghost + docker compose logs -f caddy + docker compose exec ghost sh docker compose exec db mysql -u root -p - # Use the DATABASE_ROOT_PASSWORD from your .env file - -UPGRADES: - Ghost is pinned to an exact image (GHOST_IMAGE_REF in .env), so - docker compose pull does not change it. Scripted upgrades land in S7; - until then, back up, change the pin, and run docker compose up -d. + # Database root password: DATABASE_ROOT_PASSWORD in .env - For major upgrades, always: - 1. Backup your data first - 2. Read Ghost's release notes - 3. Test in a staging environment +CONFIGURATION: + .env Compose and operator settings; never passed wholesale to Ghost. + ghost.env Ghost application settings. Use config set to encode values safely: + ./ghost-docker config set ghost.env KEY VALUE + ./ghost-docker config validate + docker compose up -d -USEFUL PATHS: - Content: ${UPLOAD_LOCATION:-./data/ghost} - Database: ${MYSQL_DATA_LOCATION:-./data/mysql} - Metadata: ./.ghost-docker.json - Logs: docker compose logs + Edit routes in caddy/sites/site.caddy, then reload: + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile DOCUMENTATION: - docs/install.md Installation, ports, optional services, doctor - docs/configuration.md Configuration split, profiles, lifecycle, metadata - docs/caddy.md The site's routes, and how to change them - docs/bundle-v1.md Migration bundle contract + docs/install.md Usage, updates, backup/restore and recovery + docs/configuration.md Env files, profiles, overrides and image pins + docs/caddy.md Route edits + docs/bundle-v1.md Migration bundle contract + docs/architecture.md Manager invariants and state ownership + docs/ghost-cli-replacement.md Remaining work and release gates -MORE HELP: Ghost Docs: https://ghost.org/docs/ Ghost Community: https://forum.ghost.org/ HELPEOF diff --git a/manager/Dockerfile b/manager/Dockerfile index 2b5d5977..76815bd4 100644 --- a/manager/Dockerfile +++ b/manager/Dockerfile @@ -6,7 +6,7 @@ # # It holds the CLI, Compose, and the stack's own files. # The launcher (ghost-docker) is the supported way to run it; see -# docs/ghost-cli-replacement.md §2.10 for the contract between them. +# docs/architecture.md#launcher-and-manager for the contract between them. FROM docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d AS docker diff --git a/manager/entrypoint.sh b/manager/entrypoint.sh index e94c3410..c1f95a20 100755 --- a/manager/entrypoint.sh +++ b/manager/entrypoint.sh @@ -7,7 +7,7 @@ # root or to a docker group. So: read the socket's group, then drop to the # caller's uid and gid with that group added. Nothing is chowned afterwards. # -# The cases in which there is nothing to drop (plan §2.10): +# The cases in which there is nothing to drop (docs/architecture.md#host-boundary): # - the container was started with --user: it is already someone else # - rootless Docker: root in here already is the caller on the host # - no identity was given: nothing to drop to, and the manager then refuses diff --git a/manager/package.json b/manager/package.json index aeba9ca6..d6ca8885 100644 --- a/manager/package.json +++ b/manager/package.json @@ -1,7 +1,7 @@ { "name": "ghost-docker-manager", "private": true, - "description": "The ghost-docker CLI. Runs inside the manager image; see docs/ghost-cli-replacement.md §2.10.", + "description": "The ghost-docker CLI. Runs inside the manager image; see docs/architecture.md#launcher-and-manager.", "type": "module", "scripts": { "format": "oxfmt --write src test package.json tsconfig.json", diff --git a/manager/src/asroot.ts b/manager/src/asroot.ts index df1286ab..1a09fb94 100644 --- a/manager/src/asroot.ts +++ b/manager/src/asroot.ts @@ -1,7 +1,7 @@ // Files in the site directory that only root may change: MySQL's data // directory belongs to MySQL's user once it has run. Each change is made in a // short-lived container of the manager's own image that does only that, with -// the site mounted at /site and no network (plan §2.10). +// the site mounted at /site and no network (docs/configuration.md0). import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { runOnce, type RunResult } from './docker/client.ts'; diff --git a/manager/src/backup.ts b/manager/src/backup.ts index 91a7c527..5a7a0694 100644 --- a/manager/src/backup.ts +++ b/manager/src/backup.ts @@ -1,35 +1,5 @@ -// Taking a backup of a site, and reading one back (plan §2.5). -// -// A backup is a directory under backups/ in the site: -// -// manifest.json what it holds, the exact images, a checksum of each file -// database/.sql a mysqldump of each of the site's databases, as its own user -// content.tar.gz the content directory -// site/... .env, ghost.env, the metadata, the Caddy files, the -// Compose overrides and, in image mode, the stack files -// and the launcher the site ran -// -// What it records of the site is what Compose resolves from every file the -// site runs with, and what the daemon runs (resolved.ts): the data mounts an -// override may have moved, the overrides GD_COMPOSE_OVERRIDES adds, and the -// exact image each running service runs, beside the one configured. -// -// It is written as backups/..partial and renamed into place only once it -// has been checked: every dump loaded into a scratch MySQL, and the archive -// listed. A backup directory without `.partial` is a checked one; a failure -// removes the partial, so a dump that fails is an error, not a backup. -// -// Consistency (docs/install.md, "What a backup captures"). By default a -// backup is live, as Ghost-CLI's was: the site keeps running, each dump is -// one consistent snapshot of its database, but the databases and the content -// are captured at different moments, and the manifest says so. A consistent -// backup stops the services that write them (writers.ts) while they are -// captured, so the dumps and the archive are one moment of the site, and -// starts them again before the slower check: the site is down for the -// capture alone. Writers that were not running are not started, and those -// that were run again whether the backup succeeds or fails, unless the -// caller owns the pause: an update keeps them stopped until the site it -// leaves running is verified. +// Checked backups, published by renaming a private .partial directory only after +// scratch MySQL loads and archive validation pass. See docs/architecture.md#recovery. import { createHash } from 'node:crypto'; import { chmodSync, @@ -57,7 +27,7 @@ import { SITE_FILES_DIR, type BackupManifest, } from './backup/manifest.ts'; -import { compose, composeError, composePs, composeUp } from './compose.ts'; +import { compose, composeError, composeOverrides, composePs, composeUp } from './compose.ts'; import { runOnce } from './docker/client.ts'; import { CliError } from './errors.ts'; import { WriterPause } from './writers.ts'; @@ -111,16 +81,8 @@ for db in "$@"; do done `; -/** - * The services that write the site's databases and content: stopped while a - * consistent backup captures them. Caddy and MySQL itself keep running. - */ /** `2026-10-09T14-03-22Z`: sortable, and a valid file name everywhere. */ -export const backupId = (now: Date): string => - now - .toISOString() - .replace(/\.\d{3}Z$/, 'Z') - .replaceAll(':', '-'); +export const backupId = (now: Date): string => isoSeconds(now).replaceAll(':', '-'); /** The databases a site has: Ghost's, and ActivityPub's when that profile is on. */ export function siteDatabases(site: SiteFacts): string[] { @@ -195,7 +157,7 @@ export async function refuseDrift(io: Io, resolved: ResolvedSite): Promise * bring it back. */ export function siteOverrides(resolved: ResolvedSite): string[] { - return resolved.overrides.map((file) => { + return composeOverrides(resolved.dir, resolved.files).map((file) => { const inside = insideSite(resolved.dir, file); if (inside === null) { throw new CliError( diff --git a/manager/src/backup/manifest.ts b/manager/src/backup/manifest.ts index 3b3f0f5e..e329cb97 100644 --- a/manager/src/backup/manifest.ts +++ b/manager/src/backup/manifest.ts @@ -1,9 +1,5 @@ -// A backup's manifest: what it holds, which images the site ran, and a -// checksum of every file, so that a restore can tell the backup is whole -// before it changes anything. Plan §2.5; the layout is docs/install.md -// ("Backing up and restoring a site"). Every field is required: backups made -// by development releases before the first stable one, in an earlier shape, -// are refused rather than read with guesses (plan §2.7, "Compatibility"). +// Strict backup format: checksums, captured state and exact images. +// See docs/architecture.md#file-inventory-and-backups and #compatibility. import { join } from 'node:path'; import { z } from 'zod'; import { readIfExists } from '../fs.ts'; diff --git a/manager/src/cli.ts b/manager/src/cli.ts index bd8f522f..e1376e9e 100644 --- a/manager/src/cli.ts +++ b/manager/src/cli.ts @@ -59,7 +59,7 @@ const DESCRIPTION = `Self-hosted Ghost with Docker Compose: the manager. Day-to-day operation is plain Docker Compose, from the site directory: docker compose ps | logs -f ghost | up -d | down -The plan is docs/ghost-cli-replacement.md in the repository.`; +Usage and recovery: docs/install.md in the repository.`; export async function run(argv: readonly string[], io: Io): Promise { const [first = 'help', ...others] = argv; diff --git a/manager/src/commands/backup.ts b/manager/src/commands/backup.ts index 91a3f5b0..ea6107b0 100644 --- a/manager/src/commands/backup.ts +++ b/manager/src/commands/backup.ts @@ -1,4 +1,4 @@ -// `backup` and `restore` (plan §2.5): the site's databases, content and +// `backup` and `restore` (docs/architecture.md#recovery): the site's databases, content and // configuration, and putting them back, into the same directory or a new one. // What they do is backup.ts and restore.ts; this is the command line and the // lock. diff --git a/manager/src/commands/common.ts b/manager/src/commands/common.ts index d5c1331f..6fa76fc1 100644 --- a/manager/src/commands/common.ts +++ b/manager/src/commands/common.ts @@ -55,7 +55,7 @@ export function installedSite(io: Io): InstalledSite { /** * The release the command line asked for. The launcher has already chosen the - * image from these options (plan §2.10); the manager checks them again, so a + * image from these options (docs/configuration.md0); the manager checks them again, so a * manager started any other way reads them the same, and records them. */ export interface Requested { diff --git a/manager/src/commands/doctor.ts b/manager/src/commands/doctor.ts index 699c6005..fba0d1ab 100644 --- a/manager/src/commands/doctor.ts +++ b/manager/src/commands/doctor.ts @@ -351,7 +351,7 @@ function writeProbe(dir: string, keep: boolean): { check: Check; token: string | * a sibling container with the site directory mounted by the path the manager * was given, and read back the file the manager just wrote. It fails when the * daemon cannot start containers, when the path means another directory to the - * daemon, and when the file is not where the daemon looks (plan §2.10). + * daemon, and when the file is not where the daemon looks (docs/configuration.md0). */ async function bindMountCheck(context: Context, io: Io, token: string): Promise { const label = 'bind mounts'; diff --git a/manager/src/commands/self-update.ts b/manager/src/commands/self-update.ts index bb68f98e..cda812a8 100644 --- a/manager/src/commands/self-update.ts +++ b/manager/src/commands/self-update.ts @@ -1,38 +1,6 @@ -// `self-update`: move a site to this manager's release of the stack (plan §2.7). -// -// The updater is the target release. The site's launcher starts the image -// that --to or --channel names, or by default the newest release on the -// channel the site follows, and this is that image: it runs outside the files -// it replaces. It updates only a site installed from the image: a checkout of -// the repository is the operator's to update with git and Compose, and is -// refused with the steps. -// -// It updates the stack, never Ghost: `.env`, and the exact Ghost image it -// pins, are not touched. `run` takes these parts in order: -// -// 1. Refusals that change nothing: a site without metadata, a checkout, a -// downgrade, a Ghost older than this release runs. Then the lock. -// 2. The release's images pulled while the site keeps running, where -// Compose can resolve them before the release is written; a pull that -// cannot is done again in step 4. -// 3. A snapshot of the operator's files, the metadata and every managed -// file this update writes, in UPDATE_DIR. Then Ghost and ActivityPub -// are stopped (writers.ts), and a checked backup taken (backup.ts), -// because a release's services may migrate their databases, -// ActivityPub's among them, whether or not Ghost changes. They stay -// stopped until the release starts. -// 4. The managed files: an untouched one is replaced, an edited one is -// kept and the release's is written beside it as `.new`. It -// never asks. Validate, pull, `up --wait`, verify as `check` does. -// 5. On a failure, the snapshot is put back. Before the services had -// been changed, the writers are then started again as they were, and -// the site is restored. Once they had, Ghost may have accepted writes -// since the backup, so the backup is never loaded automatically: the -// release is stopped first, and the operator is told how to restore -// the backup or start the previous release on the data as it is. -// Never success because `up` returned zero. -// 6. On success, the site's launcher is pinned to this image, the metadata -// records the release, and the snapshot is removed. +// Image-only stack update. The target manager runs outside the files it replaces. +// Recovery must not load the checkpoint after service startup was attempted: +// even a failed start may have accepted writes. See docs/architecture.md#recovery. import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { refuseDrift, takeBackup } from '../backup.ts'; @@ -40,7 +8,6 @@ import { compose, composeConfig, composeError, upAndWait } from '../compose.ts'; import { z } from 'zod'; import { defineCommand, flag } from '../command.ts'; import { findingErrors, validate } from '../config.ts'; -import type { Context } from '../context.ts'; import { CliError, describeError, EXIT } from '../errors.ts'; import { atomicWrite, copyPresent } from '../fs.ts'; import type { Io } from '../io.ts'; @@ -316,7 +283,6 @@ function refuseOldGhost(metadata: Metadata, from: Stack): void { interface Update { readonly io: Io; - readonly context: Context; readonly site: SiteFacts; readonly metadata: Metadata; readonly from: Stack; @@ -367,7 +333,7 @@ export const selfUpdateCommand = defineCommand({ const stack = stackDir(io.env); const payload = planPayload(dir, stack, metadata.payload); - const update: Update = { io, context, site, metadata, from, to, release, stack, payload }; + const update: Update = { io, site, metadata, from, to, release, stack, payload }; if (flags.check) { return report(update, decided); @@ -443,9 +409,6 @@ function report({ io, from, to, payload, metadata }: Update, direction: Directio return EXIT.ok; } -/** The stage a failure happened in decides how much has to be put back. */ -type Stage = 'backup' | 'write' | 'validate' | 'pull' | 'start' | 'verify' | 'record'; - async function apply(update: Update): Promise { const { io, site, from, to, stack, payload } = update; const dir = site.dir; @@ -484,7 +447,7 @@ async function apply(update: Update): Promise { // Owned by the update, not the backup: the writers stay stopped from // before the backup until the release starts, or the site is put back. const pause = new WriterPause(io, dir, 'the update'); - let stage: Stage = 'backup'; + let servicesChanged = false; let backup: string | null = null; try { heading(io, 'Backing up the site'); @@ -497,11 +460,9 @@ async function apply(update: Update): Promise { }); ok(io, 'backup', `${relative(dir, backup)}, checked`); - stage = 'write'; heading(io, 'Writing the stack'); writePayloadChanges(io, dir, stack, payload); - stage = 'validate'; // validate() only warns when Compose cannot resolve the project; here // that is the release failing. const resolved = await io.busy('Resolving the Compose project', () => @@ -519,7 +480,6 @@ async function apply(update: Update): Promise { } ok(io, 'configuration', 'valid with this release'); - stage = 'pull'; heading(io, 'Starting the services'); // Quick when the early pull got them; whatever it could not, now. const pull = await io.busy( @@ -530,19 +490,17 @@ async function apply(update: Update): Promise { throw new CliError(`the images could not be pulled: ${composeError(pull)}`); } - stage = 'start'; - // The services are the release's from here: a recovery stops them all. + // Set before attempting up: even a failed start may migrate data or accept writes. + servicesChanged = true; pause.end(); await upAndWait(io, dir, 'Starting the services and waiting for them to be healthy'); ok(io, 'services', 'healthy, by their own health checks'); - stage = 'verify'; await verifySite(io, dir); - stage = 'record'; record(update); } catch (error) { - return recover(update, snapshot, pause, backup, stage, error); + return recover(update, snapshot, pause, backup, servicesChanged, error); } snapshot.remove(); summarize(update, backup); @@ -585,31 +543,17 @@ function record({ io, site, metadata, from, to, release, payload }: Update): voi ok(io, META_FILE, `records ${describeStack(to)}`); } -/** - * Puts the site back as it was before the update, as far as it can, and - * says which: restored, or needing the operator. - * - * Before the release's services start, nothing but files has changed and - * the writers have been stopped since before the backup: the files are put - * back and the writers started again, and the site is restored. Once they - * have started, Ghost may have accepted writes, and Ghost and the release's - * other services may have changed the data, and the update cannot tell - * which. Loading the backup then would discard whatever was written since - * it was taken, so it is never done automatically: the release is stopped, - * its files are put back, and the operator chooses between the backup and - * the data as it is. - */ +/** Restore files and resume only before startup; afterwards preserve data for the operator. */ async function recover( { io, site, from, to }: Update, snapshot: Snapshot, pause: WriterPause, backup: string | null, - stage: Stage, + servicesChanged: boolean, error: unknown, ): Promise { const dir = site.dir; io.stderr(`\n${describeError(error)}\n`); - const servicesChanged = stage === 'start' || stage === 'verify' || stage === 'record'; io.stderr( `\nThe update to ${describeStack(to)} did not complete. Putting ${describeStack(from)}'s files back\n`, ); diff --git a/manager/src/compose.ts b/manager/src/compose.ts index 5e0c5b81..7c65c812 100644 --- a/manager/src/compose.ts +++ b/manager/src/compose.ts @@ -1,17 +1,6 @@ -// Compose has no API: it is a program that talks to the daemon itself. The -// manager runs the client that is in this image, as a template tag: -// -// await compose(io, { dir, profiles })`up --detach --wait db` -// -// which runs `docker-compose --project-directory DIR -f DIR/compose.yml ...`. -// -// `--project-directory`, never `-C`, and an explicit `-f`. COMPOSE_FILE is not -// inherited, because it changes override auto-loading; nor are the other -// COMPOSE_* settings, which belong to the site's own `.env`, nor anything else -// of the manager's own environment, which Compose would interpolate over -// `.env`. The site's compose.override.yml is used when it exists, as plain -// `docker compose` uses it, and other overrides are opted into with -// GD_COMPOSE_OVERRIDES (docs/configuration.md). +// Compose has no API. Invoke its client with explicit project inputs and a clean +// environment so manager settings cannot override the site's .env. +// See docs/configuration.md#the-compose-invocation-contract. import { existsSync } from 'node:fs'; import { dirname, isAbsolute, join } from 'node:path'; import type { Readable } from 'node:stream'; @@ -109,6 +98,11 @@ export function composeFileList(dir: string, overrides = ''): string[] { return files; } +/** Additional overrides from the ordered file list; only the site's root override is implicit. */ +export function composeOverrides(dir: string, files: readonly string[]): string[] { + return files.slice(1).filter((file) => file !== join(dir, COMPOSE_OVERRIDE_FILE)); +} + /** * What Compose needs of our environment to find itself and the daemon. * Nothing else is passed on: Compose interpolates the shell's variables in diff --git a/manager/src/config.ts b/manager/src/config.ts index 82e7233d..f4a433b3 100644 --- a/manager/src/config.ts +++ b/manager/src/config.ts @@ -1,20 +1,6 @@ -// Validating the configuration split (docs/configuration.md). -// -// .env Compose and operator settings, including infrastructure -// credentials. Read by Compose for interpolation; never passed -// into the Ghost container. -// ghost.env Ghost application settings only; the ghost service's only -// env_file. -// -// Only what nothing else catches is checked. A missing URL or -// DATABASE_PASSWORD is left to compose.yml's own `:?` guards, which report it -// at the point of use. Requirements are by mode rather than `:?` guards on -// optional-service variables, because Compose interpolates inactive services -// too. -// -// Two lists are derived rather than written down, so they cannot drift: the -// keys the container owns (what `docker compose config` says Ghost receives) -// and the operator settings (what Compose interpolates). +// Validate the configuration split (docs/configuration.md). Derive operator keys +// and container-owned values from Compose instead of maintaining parallel lists. +// Compose interpolates inactive services too; optional requirements are mode-specific. import { join } from 'node:path'; import { composeConfig, composeVariables } from './compose.ts'; import { inspectImage } from './docker/client.ts'; diff --git a/manager/src/context.ts b/manager/src/context.ts index 40222630..730bcba1 100644 --- a/manager/src/context.ts +++ b/manager/src/context.ts @@ -3,7 +3,7 @@ // The launcher is the only thing that can know these: it runs on the host, the // manager does not. They arrive as GD_* environment variables and are checked // here, once, so that a manager started by hand without them fails with a -// sentence instead of misbehaving later. The contract is plan §2.10. +// sentence instead of misbehaving later. The contract is docs/configuration.md0. import { z } from 'zod'; import { CliError } from './errors.ts'; diff --git a/manager/src/errors.ts b/manager/src/errors.ts index 021ead98..b26c9a90 100644 --- a/manager/src/errors.ts +++ b/manager/src/errors.ts @@ -1,7 +1,7 @@ // Exit statuses, and the errors that map to them. // // The launcher passes the manager's exit status through unchanged, so these -// are the statuses a caller of `./ghost-docker` sees (plan §2.8). +// are the statuses a caller of `./ghost-docker` sees (docs/install.md). export const EXIT = { ok: 0, diff --git a/manager/src/import.ts b/manager/src/import.ts index 80cf0a73..641e1dda 100644 --- a/manager/src/import.ts +++ b/manager/src/import.ts @@ -2,7 +2,7 @@ // and its database. A portable bundle has none to load: Ghost creates an empty // one, and its content JSON and members CSV are Ghost Admin's to import. The order and the policy are install's; `Importing` is // what install calls, at its points, when --import is given. The contract is -// docs/bundle-v1.md and the sequence §2.4 of docs/ghost-cli-replacement.md. +// docs/bundle-v1.md and docs/architecture.md#installation-and-import. // // The pieces that print nothing and decide no order are in import/: // config.ts, what ghost.env receives; database.ts, the client, the dump diff --git a/manager/src/lock.ts b/manager/src/lock.ts index 8f0cb41b..88a8c5c1 100644 --- a/manager/src/lock.ts +++ b/manager/src/lock.ts @@ -1,4 +1,4 @@ -// The site lock (plan §2.2): one operation that changes a running site at a +// The site lock (docs/architecture.md#recovery): one operation that changes a running site at a // time. self-update, backup, restore and `config set` take it; Ghost upgrades // will when they land. // diff --git a/manager/src/meta.ts b/manager/src/meta.ts index da0926a1..30b35bed 100644 --- a/manager/src/meta.ts +++ b/manager/src/meta.ts @@ -1,15 +1,5 @@ -// `.ghost-docker.json`: what an operation needs to know about a site that its -// configuration does not say. When it was installed, from which stack release -// and how, the exact Ghost image that was resolved, and the files the manager -// wrote. -// -// Machine generated, gitignored, private. The manager is its only reader and -// writer: it writes atomically, refuses a document without the right -// schemaVersion, and refuses to read one from a newer schema rather than -// misreading it. Every field is required; one nobody knows is null. Formats -// written by development releases before the first stable one are not read -// (plan §2.7, "Compatibility"). A site without the file was not made by -// `install`: commands that need it say so. +// Strict, private installation metadata. Live configuration comes from Compose. +// See docs/architecture.md#installation-metadata and #compatibility. import { join } from 'node:path'; import { z } from 'zod'; import { atomicWrite, PRIVATE, readIfExists } from './fs.ts'; @@ -59,7 +49,7 @@ export const metadataSchema = z.strictObject({ /** * SHA-256 of every file the manager wrote from the image's payload, by * path relative to the site. An update replaces an untouched file and - * never silently replaces an edited one (plan §2.7). Empty in clone mode. + * never silently replaces an edited one (docs/architecture.md#releases-and-compatibility). Empty in clone mode. */ payload: z.record(z.string(), z.string().regex(/^[0-9a-f]{64}$/)), }); diff --git a/manager/src/payload.ts b/manager/src/payload.ts index 4d86b806..d3fde782 100644 --- a/manager/src/payload.ts +++ b/manager/src/payload.ts @@ -1,5 +1,5 @@ // The release payload: the files compose.yml needs beside it, which the -// manager image carries and writes into a site directory (plan §2.7). +// manager image carries and writes into a site directory (docs/architecture.md#releases-and-compatibility). // // In image mode `install` writes them, with a checksum of each recorded in the // metadata so that `self-update` can tell an untouched file from an edited one, and diff --git a/manager/src/recovery.ts b/manager/src/recovery.ts index ced241ab..0128e355 100644 --- a/manager/src/recovery.ts +++ b/manager/src/recovery.ts @@ -1,16 +1,6 @@ -// What an operation that replaces a site's data needs when it fails, shared by -// restore, self-update and (when they land) Ghost upgrades and the supervisor -// (plan §2.5): -// -// - the services stopped, so nothing writes to data about to be put back, -// and their state as Compose observes it, never as assumed; -// - the site's data and files set aside one boundary at a time, so what the -// operator is told refers only to copies that exist, and nothing is ever -// removed on the strength of a step that did not complete; -// - a backup's content and databases loaded into the site. -// -// Each caller decides what its outcome is; none of this resumes an operation -// that was killed. +// Shared recovery primitives: observed shutdown, verified set-aside copies and +// database/content loading. Callers own outcome policy; nothing resumes a crash. +// See docs/architecture.md#recovery. import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import * as tar from 'tar'; diff --git a/manager/src/release.ts b/manager/src/release.ts index c5dd41ab..e70c67a1 100644 --- a/manager/src/release.ts +++ b/manager/src/release.ts @@ -1,5 +1,5 @@ // Releases of the stack, as the manager sees them: which tags are releases, -// how they are ordered, and which channel a version belongs to (plan §2.7). +// how they are ordered, and which channel a version belongs to (docs/architecture.md#releases-and-compatibility). // // Only `vX.Y.Z` and `vX.Y.Z-beta.N` are releases, and they are ordered by // semver: v1.10.0 follows v1.9.0, beta.10 follows beta.2, and a release diff --git a/manager/src/resolved.ts b/manager/src/resolved.ts index fda06b36..c1b95207 100644 --- a/manager/src/resolved.ts +++ b/manager/src/resolved.ts @@ -6,13 +6,12 @@ // with (`docker compose config`), and what is actually running is what the // daemon says of the project's containers. Commands that change a site, or // record it, ask here rather than read `.env` and guess. -import { basename, isAbsolute, relative } from 'node:path'; +import { isAbsolute, relative } from 'node:path'; import { composeConfig, composeFileList, type ComposeInputs } from './compose.ts'; import { inspectImage, listContainers } from './docker/client.ts'; import { CliError } from './errors.ts'; import type { Io } from './io.ts'; import { PROJECT_LABEL, refuseForeignProject, WORKING_DIR_LABEL } from './project.ts'; -import { COMPOSE_OVERRIDE_FILE } from './site.ts'; /** The label Compose gives every container with the service it is of. */ const SERVICE_LABEL = 'com.docker.compose.service'; @@ -52,8 +51,6 @@ export interface ResolvedSite { readonly project: string; /** Every Compose file, in the order Compose merges them. */ readonly files: readonly string[]; - /** The overrides GD_COMPOSE_OVERRIDES adds, beyond compose.yml and compose.override.yml. */ - readonly overrides: readonly string[]; /** The services the site's profiles select. */ readonly services: Readonly>; } @@ -106,11 +103,6 @@ export async function resolveConfig( dir, project: project.name, files, - // Past compose.yml and, second when there is one, compose.override.yml. - overrides: files.filter( - (file, index) => - index > 0 && !(index === 1 && basename(file) === COMPOSE_OVERRIDE_FILE), - ), services, }; return site; diff --git a/manager/src/restore.ts b/manager/src/restore.ts index 69c0f3b9..981df284 100644 --- a/manager/src/restore.ts +++ b/manager/src/restore.ts @@ -1,38 +1,12 @@ -// Restoring a backup into its own site directory or a new, empty one (plan -// §2.5). `restoreSite` takes these parts in order: -// -// 1. Refusals that change nothing: the backup is read whole and every file -// checked against its checksum; the directory is this backup's own -// site, or empty; in a new directory, nothing on the daemon already -// uses the site's project name or ports, and over the site, no other -// directory's; the overrides in effect are the ones the backup was -// taken with; Compose, in this directory, resolves the files and -// `.env` the restore will write, read where the backup holds them, to -// exactly the images the backup records, with the data mounted where -// backup and restore handle it. Then the lock, and the recorded images -// are pulled before anything stops, and each must be, by its immutable -// identity, the image the site ran when it was backed up. -// 2. Over the site itself: the site is stopped, and its data and every file -// the restore writes or replaces (siteFiles, with the overrides, and the -// backup's own) are moved aside into RESTORE_DIR, one at a time -// (recovery.ts), each copy checked against its original, which is -// kept until the restore has been verified. A failure here, before -// anything is written, moves back what had been moved. -// 3. The backup's files are written, with the site's own path, and Compose -// must resolve exactly the recorded images. The content is unpacked, -// a fresh MySQL is started, and each dump loaded as the site's user and -// its rows counted against the manifest. -// 4. `up --wait`, then verify as `check` does. -// -// The outcome is done, or needs the operator with what to do: a restore that -// fails once it has written stops the services and does not put the old site -// back by itself. What it says to do names only copies that exist. +// Restore validates the inputs it will write before replacing the site. +// After writing begins, failure needs the operator; recovery instructions must +// name only copies that exist. See docs/architecture.md#restore. import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { backupSiteFiles, readBackup, refuseMovedData } from './backup.ts'; import { SITE_FILES_DIR, type BackupManifest } from './backup/manifest.ts'; -import { composeFileList, upAndWait } from './compose.ts'; +import { composeFileList, composeOverrides, upAndWait } from './compose.ts'; import type { Context } from './context.ts'; import { DaemonError, @@ -239,11 +213,9 @@ function classify(context: Context, dir: string, manifest: BackupManifest): Targ * the restored site as another. */ function refuseOtherOverrides(io: Io, dir: string, manifest: BackupManifest): void { - const now = composeFileList(dir, io.env.GD_COMPOSE_OVERRIDES) - .filter( - (file) => file !== join(dir, COMPOSE_FILE) && file !== join(dir, COMPOSE_OVERRIDE_FILE), - ) - .map((file) => insideSite(dir, file) ?? file); + const now = composeOverrides(dir, composeFileList(dir, io.env.GD_COMPOSE_OVERRIDES)).map( + (file) => insideSite(dir, file) ?? file, + ); const recorded = manifest.site.overrides; if (now.join(',') !== recorded.join(',')) { throw new CliError( @@ -547,8 +519,9 @@ function writeSiteFiles(io: Io, root: string, dir: string, manifest: BackupManif if (text === undefined) { throw new CliError(`the backup holds no ${ENV_FILE}`); } - if (restoredEnv(text, dir) !== text) { - atomicWrite(envPath, restoredEnv(text, dir)); + const restored = restoredEnv(text, dir); + if (restored !== text) { + atomicWrite(envPath, restored); } const metadata = readMetadata(dir); if (metadata.state === 'present' && metadata.metadata.site.dir !== dir) { diff --git a/manager/src/site.ts b/manager/src/site.ts index 6b041f4d..635f713c 100644 --- a/manager/src/site.ts +++ b/manager/src/site.ts @@ -20,7 +20,7 @@ export const BACKUPS_DIR = 'backups'; export const RESTORE_DIR = '.ghost-docker-restore'; /** * Files and directories that belong to the operator: an update keeps a copy - * of them, and a checkout of another ref never touches them (plan §2.7). + * of them, and a checkout of another ref never touches them (docs/architecture.md#releases-and-compatibility). */ export const OPERATOR_FILES = [ ENV_FILE, diff --git a/manager/src/verify.ts b/manager/src/verify.ts index e9cb435f..573d0d9e 100644 --- a/manager/src/verify.ts +++ b/manager/src/verify.ts @@ -1,27 +1,6 @@ -// Reaching a site in order to verify it (plan §2.8). -// -// Each service is judged by its own Compose health check, which `up --wait` -// already required, and nothing is described as more than it is: -// -// ghost its health check: the Admin API answers inside the container -// caddy its health check: its admin API answers, so it is up with -// its configuration loaded. Not that it routes each name -// https Ghost's answer through Caddy, for the domain and the admin -// domain: an HTTPS request to Caddy, with that name as its -// SNI and Host, for Ghost's Admin API site endpoint. Serving -// only when this site's Ghost answers it, by the canonical -// URL it reports, which is the site's URL whichever of the -// two names it was reached by; and the certificate is judged -// (its name, its dates, its issuer). Pending while Caddy has -// no certificate for the name, which it obtains once DNS -// reaches this host: until then the route cannot be tried. -// Asked from the site's own network (network.ts), because -// 127.0.0.1 in the manager is the manager, not the host -// mailpit with that profile: its health check. That Ghost's mail -// reaches it is tests/e2e/install.sh's to prove -// published ports what Docker says it published, not verified from the host: -// the HTTPS request above goes to Caddy's container, not to -// the host's ports 80 and 443 +// Health checks establish readiness; an HTTPS request through Caddy must also +// identify this Ghost site by its canonical URL. Probe the site network, since +// manager loopback is not host loopback. See docs/architecture.md#verification-and-service-access. import { ServiceUnreachable, type HttpsAnswer } from './clients.ts'; import { composePs, type ServiceState } from './compose.ts'; import type { Io } from './io.ts'; diff --git a/manager/src/versions.ts b/manager/src/versions.ts index 8adb2449..e33863c8 100644 --- a/manager/src/versions.ts +++ b/manager/src/versions.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { coerce, gte } from 'semver'; import { z } from 'zod'; -/** Declared minimums. Verified in CI against this exact minimum and a current release. */ +/** Declared minimums; qualification against exact minimum versions is a release gate. */ export const MINIMUM = { // `healthcheck.start_interval` needs Docker Engine 25.0. dockerEngine: '25.0.0', @@ -11,7 +11,7 @@ export const MINIMUM = { compose: '2.24.0', // The oldest Ghost this release of the stack runs. `self-update` never changes // a site's Ghost, so a release that raises this stops an update of a site - // below it and says to upgrade Ghost first (plan §2.7). + // below it and says to upgrade Ghost first (docs/architecture.md#releases-and-compatibility). ghost: '6.0.0', } as const; @@ -19,7 +19,7 @@ export const MINIMUM = { * Is `version` at least `minimum`? Versions are coerced first: a leading `v` * and anything after the numbers (`-beta.1`, `+ce`, `-desktop.1`) are * dropped, which is what a minimum-version check needs. Release ordering for - * the stack itself has its own rules (plan §2.10) and is not this. + * the stack itself has its own rules (docs/configuration.md0) and is not this. */ export const atLeast = (version: string, minimum: string): boolean => gte(coerce(version) ?? '0.0.0', minimum); diff --git a/manager/src/writers.ts b/manager/src/writers.ts index 47c85aad..0ffb2c93 100644 --- a/manager/src/writers.ts +++ b/manager/src/writers.ts @@ -1,21 +1,6 @@ -// The services that write a site's data, and pausing them (plan §2.5). -// -// A consistent backup pauses them for its capture alone, and resumes them -// at once. An operation that may load that backup back over the site -// (self-update now; Ghost updates and the supervisor when they land) owns -// the pause instead, from before the checkpoint until it starts the -// services itself, so nothing a writer accepts after the checkpoint can be -// lost by loading it: -// -// - the writers are paused before the checkpoint is taken; -// - a failure before any service changed puts the files back, then -// resumes the writers as they were; -// - once the operation starts the services, they are the new site's, and -// the pause is over: Ghost may accept writes, so a recovery stops -// everything and never loads the checkpoint by itself; the operator -// chooses. -// -// Writers that were not running are never started by resuming. +// Pause only writers that were running. A standalone backup resumes after capture; +// an update owns the pause until it attempts startup or restores the old files. +// Once startup is attempted, recovery must leave newer writes intact. import { compose, composeError, READY_SECONDS } from './compose.ts'; import { CliError } from './errors.ts'; import type { Io } from './io.ts'; diff --git a/manager/test/backup.test.ts b/manager/test/backup.test.ts index af8204c4..d9ecf272 100644 --- a/manager/test/backup.test.ts +++ b/manager/test/backup.test.ts @@ -487,15 +487,37 @@ describe('backup records what the site runs', () => { assert.deepEqual(backups(), []); }); - test('the overrides GD_COMPOSE_OVERRIDES adds are in the backup, and recorded', async () => { - writeFileSync(join(h.dir, 'compose.ipv6.yml'), 'services: {}\n'); - h.env.GD_COMPOSE_OVERRIDES = 'compose.ipv6.yml'; - const root = await backUp(); - const read = readBackupManifest(root); - const manifest = read.state === 'present' ? read.manifest : null!; - assert.deepEqual(manifest.site.overrides, ['compose.ipv6.yml']); - assert.ok('site/compose.ipv6.yml' in manifest.files); - }); + for (const rootOverride of [false, true]) { + for (const file of [ + 'compose.ipv6.yml', + 'overrides/compose.override.yml', + 'overrides/compose.yml', + ]) { + test(`backs up and restores ${file}, root override ${rootOverride ? 'present' : 'absent'}`, async () => { + const original = 'services: {}\n'; + rmSync(join(h.dir, 'compose.override.yml'), { force: true }); + if (rootOverride) { + writeFileSync(join(h.dir, 'compose.override.yml'), original); + } + mkdirSync(join(h.dir, 'overrides'), { recursive: true }); + writeFileSync(join(h.dir, file), original); + h.env.GD_COMPOSE_OVERRIDES = file; + const root = await backUp(); + const read = readBackupManifest(root); + assert.equal(read.state, 'present'); + const manifest = read.state === 'present' ? read.manifest : null!; + assert.deepEqual(manifest.site.overrides, [file]); + assert.ok(`site/${file}` in manifest.files); + assert.equal(readFileSync(join(root, 'site', file), 'utf8'), original); + + writeFileSync(join(h.dir, file), '# changed after backup\nservices: {}\n'); + const result = await h.run('restore', '--yes', root); + assert.equal(result.code, 0, result.stderr); + assert.equal(readSite(file), original); + assert.equal(existsSync(join(h.dir, 'compose.override.yml')), rootOverride); + }); + } + } test('an override outside the site is refused', async () => { h.env.GD_COMPOSE_OVERRIDES = '/etc/ghost/compose.extra.yml'; diff --git a/manager/test/integration/compose.test.ts b/manager/test/integration/compose.test.ts index 2eb1103c..c586251d 100644 --- a/manager/test/integration/compose.test.ts +++ b/manager/test/integration/compose.test.ts @@ -1,10 +1,12 @@ // What the manager leaves to the image's own Compose, against that Compose. import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { ALL_PROFILES, compose, composeConfig, composeVersion } from '../../src/compose.ts'; +import { siteOverrides } from '../../src/backup.ts'; +import { resolveConfig } from '../../src/resolved.ts'; import { operatorKeyTest } from '../../src/config.ts'; import * as env from '../../src/env.ts'; import { atLeast, MINIMUM } from '../../src/versions.ts'; @@ -76,3 +78,35 @@ test('every profile is `*` to Compose, so undo finds whatever a failed install s assert.ok(services.includes(service), `${service} not in ${services.join(', ')}`); } }); + +for (const rootOverride of [false, true]) { + for (const absolute of [false, true]) { + test(`nested compose.override.yml is effective and inventoried (root ${rootOverride}, absolute ${absolute})`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'gd-overrides-')); + try { + mkdirSync(join(dir, 'overrides')); + writeFileSync(join(dir, '.env'), 'COMPOSE_PROJECT_NAME=gd-overrides\n'); + writeFileSync( + join(dir, 'compose.yml'), + 'services:\n reader:\n image: alpine:base\n', + ); + if (rootOverride) { + writeFileSync( + join(dir, 'compose.override.yml'), + 'services:\n reader:\n image: alpine:root\n', + ); + } + const file = 'overrides/compose.override.yml'; + writeFileSync(join(dir, file), 'services:\n reader:\n image: alpine:nested\n'); + const overrideIo = realIo({ + env: { ...io.env, GD_COMPOSE_OVERRIDES: absolute ? join(dir, file) : file }, + }); + const site = await resolveConfig(overrideIo, dir); + assert.equal(site.services.reader?.image, 'alpine:nested'); + assert.deepEqual(siteOverrides(site), [file]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + } +} diff --git a/manager/test/integration/resolved.test.ts b/manager/test/integration/resolved.test.ts index 4aaf7ba1..542693a5 100644 --- a/manager/test/integration/resolved.test.ts +++ b/manager/test/integration/resolved.test.ts @@ -39,7 +39,6 @@ after(async () => { test('Compose resolves the data mounts, the network and the images', async () => { const resolved = await resolveSite(io, owner.dir); assert.equal(resolved.project, owner.project); - assert.deepEqual(resolved.overrides, []); assert.ok( resolved.services.db!.mounts.some( (mount) => diff --git a/manager/test/services.test.ts b/manager/test/services.test.ts index 63e961f5..f48b8503 100644 --- a/manager/test/services.test.ts +++ b/manager/test/services.test.ts @@ -11,7 +11,6 @@ const configured = (services: Record): ResolvedSite => ({ dir: '/srv/site', project: 'site', files: ['/srv/site/compose.yml'], - overrides: [], services: Object.fromEntries( Object.entries(services).map(([name, lifecycle]) => [ name, diff --git a/pages/index.html b/pages/index.html index 43831563..c272e5f4 100644 --- a/pages/index.html +++ b/pages/index.html @@ -105,7 +105,7 @@

Moving a Ghost-CLI site

Afterwards

Installation writes a launcher into the site directory. Every later command runs from there:

./ghost-docker check     # diagnose the site
-./ghost-docker update    # a newer release of the stack; Ghost is unchanged
+./ghost-docker self-update  # a newer stack release; Ghost is unchanged
 docker compose logs -f ghost

More

diff --git a/scripts/lib/release.ts b/scripts/lib/release.ts index c411313f..3d841e7b 100644 --- a/scripts/lib/release.ts +++ b/scripts/lib/release.ts @@ -1,4 +1,4 @@ -// Cutting releases of the stack, for the release workflows (plan §2.7). +// Cutting releases of the stack, for the release workflows (docs/architecture.md#releases-and-compatibility). // // Only `vX.Y.Z` and `vX.Y.Z-beta.N` are releases, ordered by semver: v1.10.0 // follows v1.9.0, beta.10 follows beta.2, and a release follows every beta of diff --git a/tests/e2e/self-update.sh b/tests/e2e/self-update.sh index bfce7740..b0a8e69e 100755 --- a/tests/e2e/self-update.sh +++ b/tests/e2e/self-update.sh @@ -8,12 +8,10 @@ # changes two stack files, a third whose compose.yml does not resolve, and a # fourth that migrates the database and the content, then never becomes # healthy. A local site installed from the first is updated to the second -# with one of those files edited, then refused a downgrade, then updated to -# the third and the fourth, each of which fails and is put back. -# -# Through the fourth, a client keeps writing posts: every post Ghost accepts -# is still there once the site is put back. Then the backup that update took -# is restored, with its records, content, configuration and images. +# with one file edited, then refused a downgrade. A failure before startup +# restores the files; a failure after startup stops for operator recovery. +# A concurrent writer proves accepted posts survive until the operator chooses +# to restore the named backup. # # A checkout: a copy of this checkout, as a git repository, is refused # self-update, which is git's and Compose's there. Its backup records the