docs: capture local Grav core upgrade + refresh version-authority docs
New learning: docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md — the local Grav core is baked into the Docker image (only ./user is bind-mounted), so it upgrades by a Dockerfile URL bump + image rebuild + `docker rm -f` recreate, not the `gpm self-upgrade` the servers use (non-durable in-container). Refreshed three docs this exposed as stale/incomplete: - local-setup.md: rewrote the stale "newer Grav RC" section with the durable rebuild procedure (recreate gotcha, verify, plugin refresh, non-durability note). - deploy-cycle.md: Phase 0 now upgrades the local core; state-model notes the image as a fourth surface beyond the three server layers. - stale-grav-version-blocks-api-plugin-install.md: version-authority surfaces 3 -> 4 (hardcoded Dockerfile URL); clarified .env* GRAV_VERSION governs fresh remote installs only, never the local Docker core. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
This commit is contained in:
@@ -34,6 +34,11 @@ Referenced gotcha docs:
|
|||||||
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — gitignore is the sync boundary; env-tree leak.
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — gitignore is the sync boundary; env-tree leak.
|
||||||
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — plugin config must live in the tracked override.
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — plugin config must live in the tracked override.
|
||||||
|
|
||||||
|
These three layers describe the **servers**. Locally there is a fourth: the Grav
|
||||||
|
**core** is baked into the Docker **image** (`Dockerfile`), not in any layer above —
|
||||||
|
so the local core upgrades by an image rebuild, never by the `gpm self-upgrade` the
|
||||||
|
servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 0 — Local (author + prove the change)
|
## Phase 0 — Local (author + prove the change)
|
||||||
@@ -42,7 +47,8 @@ Referenced gotcha docs:
|
|||||||
- GPM channel: `gpm.releases: stable` in `user/config/system.yaml` (authoritative; reaches servers via content pull, so it must be right **before** any server GPM op).
|
- GPM channel: `gpm.releases: stable` in `user/config/system.yaml` (authoritative; reaches servers via content pull, so it must be right **before** any server GPM op).
|
||||||
- `plugins.txt` — the GPM-managed set only. **Never** add `git-sync` (it is remote-only).
|
- `plugins.txt` — the GPM-managed set only. **Never** add `git-sync` (it is remote-only).
|
||||||
- Prod-only overrides (Twig cache/debug, `debugger.shutdown.close_connection: false`) in `deploy/env/prod/system.yaml` — **never** commit prod values into `user/config/system.yaml`.
|
- Prod-only overrides (Twig cache/debug, `debugger.shutdown.close_connection: false`) in `deploy/env/prod/system.yaml` — **never** commit prod values into `user/config/system.yaml`.
|
||||||
- **Bump `GRAV_VERSION` in `.env.test` and `.env.prod`** to the target version. A stale value here installs the wrong core (an rc), which then blocks the `api` plugin and 404s admin.
|
- **Bump `GRAV_VERSION` in `.env.test` and `.env.prod`** to the target version. A stale value here installs the wrong core (an rc), which then blocks the `api` plugin and 404s admin. This governs fresh **remote** installs only.
|
||||||
|
- **If the core version is changing, upgrade the local dev core too** so you prove the change against the target version — bump the hardcoded `grav-admin-v<ver>.zip` URL in `Dockerfile`, `docker compose build grav`, then `docker rm -f intotheeast_grav && docker compose up -d grav`. The local core is baked into the image, so `.env GRAV_VERSION` does *not* touch it and an in-container `gpm self-upgrade` is non-durable. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
2. `make build-assets` if you touched `js/src/*` (never hand-edit the bundled `js/*.js`).
|
2. `make build-assets` if you touched `js/src/*` (never hand-edit the bundled `js/*.js`).
|
||||||
3. Run the dev server (`docker compose … up`) and the Playwright suite.
|
3. Run the dev server (`docker compose … up`) and the Playwright suite.
|
||||||
4. Pre-flight assertions:
|
4. Pre-flight assertions:
|
||||||
|
|||||||
@@ -44,13 +44,34 @@ This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000, and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Upgrading to a newer Grav RC
|
## Upgrading the Grav core
|
||||||
|
|
||||||
Grav 2.0 is baked into the custom Docker image via `Dockerfile`. The base `getgrav/grav` image ships 1.7 — the `Dockerfile` downloads the 2.0 RC bundle from GitHub and overwrites the core files at build time.
|
The Grav core is baked into the custom Docker image via `Dockerfile`. The base
|
||||||
|
`getgrav/grav` image ships 1.7 — the `Dockerfile` downloads the pinned stable bundle
|
||||||
|
(`grav-admin-v<version>.zip`) from GitHub and overwrites the core files at build time.
|
||||||
|
`docker-compose.yml` volume-mounts only `./user`, so the core lives in the **image
|
||||||
|
layer**. That means you upgrade the core by rebuilding the image, **not** by running
|
||||||
|
`gpm self-upgrade` inside the container — an in-container self-upgrade is lost on the
|
||||||
|
next rebuild. (The servers are the opposite: no image, so they self-upgrade in place.)
|
||||||
|
|
||||||
To upgrade:
|
To upgrade:
|
||||||
1. Update the bundle URL in `Dockerfile`
|
1. Bump **both** occurrences of the version in the `Dockerfile` release URL (the
|
||||||
2. Run `make setup` — Docker rebuilds the image layer automatically
|
`/download/<ver>/` path and the `grav-admin-v<ver>.zip` filename). Note: the
|
||||||
|
`GRAV_VERSION` in `.env*` does **not** drive this build — it only pins fresh
|
||||||
|
*remote* installs.
|
||||||
|
2. Rebuild: `docker compose build grav`.
|
||||||
|
3. Recreate the container. `docker compose up -d` won't replace an already-running
|
||||||
|
container with a fixed `container_name` (it errors `Conflict … name … already in
|
||||||
|
use`), so remove it first — safe because `./user` is a bind mount:
|
||||||
|
```bash
|
||||||
|
docker rm -f intotheeast_grav && docker compose up -d grav
|
||||||
|
```
|
||||||
|
4. Verify: `docker exec -w /var/www/html intotheeast_grav php bin/grav --version`.
|
||||||
|
5. Refresh plugins and clear cache: `make install-plugins` then
|
||||||
|
`docker exec -w /var/www/html intotheeast_grav php bin/grav cache`.
|
||||||
|
|
||||||
|
Full rationale and the server-vs-local contrast:
|
||||||
|
`docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ GPM (Grav Package Manager) only offers a plugin version whose declared Grav requ
|
|||||||
|
|
||||||
## Prevention
|
## Prevention
|
||||||
|
|
||||||
- **Keep `.env.<env>` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-<env>` bakes in; a stale value silently installs an old core. After any core upgrade, bump the env file too — self-upgrade only moves the running server. Note this is a *third* version-authority surface alongside `user/config/system.yaml` `gpm.releases` (channel) and `plugins.txt` — they must stay in sync.
|
- **Keep `.env.<env>` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-<env>` bakes in; a stale value silently installs an old core. After any core upgrade, bump the env file too — self-upgrade only moves the running server. Note this is a *third* version-authority surface alongside `user/config/system.yaml` `gpm.releases` (channel) and `plugins.txt` — they must stay in sync. A **fourth** surface governs the *local Docker* core: the hardcoded `grav-admin-v<ver>.zip` URL in `Dockerfile`. `.env.<env> GRAV_VERSION` governs fresh **remote** installs only — it never touches the local Docker core (which upgrades by an image rebuild, not self-upgrade). See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
- **When GPM says "package not found" for a package you know is on your channel, check the target's Grav core version first** (`php bin/grav --version` on the server, or `make remote-diag-<env>`). GPM filters by core compatibility; "not found" often means "no version compatible with your core," not "missing from the index." `gpm index -f` will not help.
|
- **When GPM says "package not found" for a package you know is on your channel, check the target's Grav core version first** (`php bin/grav --version` on the server, or `make remote-diag-<env>`). GPM filters by core compatibility; "not found" often means "no version compatible with your core," not "missing from the index." `gpm index -f` will not help.
|
||||||
- **Don't trust a top-level install "Success" to mean dependencies installed.** A fresh install can leave a plugin's declared dependency unsatisfied (here `admin2` installed but its `api` dependency didn't). Verify with `ls user/plugins/<dependency>`. The same `ls` guards a *second*, distinct way a plugin ends up non-functional: its **code folder can be missing while its config persists** (e.g. in the per-host env tree), so it looks configured but never loads. Checking `ls user/plugins/<name>` catches both the missing-dependency and the config-without-code cases — see `grav-plugin-config-without-code-wont-enable.md`.
|
- **Don't trust a top-level install "Success" to mean dependencies installed.** A fresh install can leave a plugin's declared dependency unsatisfied (here `admin2` installed but its `api` dependency didn't). Verify with `ls user/plugins/<dependency>`. The same `ls` guards a *second*, distinct way a plugin ends up non-functional: its **code folder can be missing while its config persists** (e.g. in the per-host env tree), so it looks configured but never loads. Checking `ls user/plugins/<name>` catches both the missing-dependency and the config-without-code cases — see `grav-plugin-config-without-code-wont-enable.md`.
|
||||||
- **Know the Admin2 ⇄ api coupling.** Admin2 authenticates via the api plugin's `/api/v1` endpoints; a missing or unrouted api plugin makes admin login fail *silently* (login POST 404s, nothing logged). A quick `curl /api/v1/pages` expecting `401` (not `404`) is a good post-deploy smoke check.
|
- **Know the Admin2 ⇄ api coupling.** Admin2 authenticates via the api plugin's `/api/v1` endpoints; a missing or unrouted api plugin makes admin login fail *silently* (login POST 404s, nothing logged). A quick `curl /api/v1/pages` expecting `401` (not `404`) is a good post-deploy smoke check.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
title: Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade
|
||||||
|
date: 2026-07-05
|
||||||
|
category: docs/solutions/tooling-decisions/
|
||||||
|
module: docker-dev-env
|
||||||
|
problem_type: tooling_decision
|
||||||
|
component: tooling
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- Upgrading the Grav core in the local Docker dev environment
|
||||||
|
- App core is baked into the image while only user content is bind-mounted
|
||||||
|
- Deciding between an image rebuild and an in-container package upgrade
|
||||||
|
- "docker compose up refuses to recreate a fixed container_name"
|
||||||
|
tags: [grav, docker, dockerfile, image-rebuild, gpm, upgrade, container-recreate]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The test and prod servers had already self-upgraded to Grav 2.0.7 in place, but the local Docker dev environment was still on 2.0.4. The question was how to bring local to 2.0.7 **durably** — in a way that survives the next image rebuild and keeps local reproducible from the repo.
|
||||||
|
|
||||||
|
The instinct is to do what the servers do: `php bin/gpm self-upgrade` inside the running container. That is the wrong tool for the local box, and understanding why is the whole point of this note.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
**The local Grav core is baked into the Docker image, so you upgrade it by editing the `Dockerfile` and rebuilding — never by upgrading inside a running container.**
|
||||||
|
|
||||||
|
The dev image (`Dockerfile`) `curl`s a specific release zip and copies its `system/`, `vendor/`, `bin/`, `index.php`, etc. into the image at build time:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.7/grav-admin-v2.0.7.zip' ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The version is **hardcoded in the URL** — there is no `ARG`, so the `GRAV_VERSION` variable in `.env*` does **not** feed the local build (it only pins the base zip for a *fresh remote install*). `docker-compose.yml` volume-mounts **only** `./user:/var/www/html/user` (plus a php.ini). Everything else — the entire core — lives in the immutable image layer.
|
||||||
|
|
||||||
|
The durable local upgrade sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bump BOTH occurrences of the version in the Dockerfile release URL
|
||||||
|
# (the /download/<ver>/ path and the grav-admin-v<ver>.zip filename)
|
||||||
|
|
||||||
|
# 2. Rebuild the image (the FROM getgrav/grav layer is cached; the RUN
|
||||||
|
# layer re-fetches the new zip in a few seconds)
|
||||||
|
docker compose build grav
|
||||||
|
|
||||||
|
# 3. Recreate the container. `up -d` may refuse (see gotcha below); if so:
|
||||||
|
docker rm -f intotheeast_grav && docker compose up -d grav
|
||||||
|
|
||||||
|
# 4. Verify the core version
|
||||||
|
docker exec -w /var/www/html intotheeast_grav php bin/grav --version # -> Grav CLI Application 2.0.7
|
||||||
|
|
||||||
|
# 5. Refresh plugins to match the servers, then clear cache
|
||||||
|
make install-plugins # docker exec ... php bin/gpm install <plugins.txt> -y
|
||||||
|
docker exec -w /var/www/html intotheeast_grav php bin/grav cache
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha — `docker compose up` won't replace a running fixed-name container.** The service pins `container_name: intotheeast_grav`, so `docker compose up -d` (and even `--force-recreate`) fails with `Conflict. The container name "/intotheeast_grav" is already in use`. Remove the old container first: `docker rm -f intotheeast_grav`, then `up -d`. This is **data-safe** because all persistent content lives in the `./user` bind mount, which is untouched by removing/recreating the container. (This same singleton collision bit an earlier upgrade session when the running container from the main checkout held the name+port. — session history)
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**An in-container `gpm self-upgrade` is non-durable locally.** It writes into the image's filesystem layer, not the `./user` volume, so the upgraded core evaporates on the next `docker compose build` / container recreate. The image, not the running container, is the source of truth for the core — so the core version must be baked into the `Dockerfile` to persist and to stay reproducible from the repo.
|
||||||
|
|
||||||
|
**Local and server upgrade by deliberately different mechanisms:**
|
||||||
|
|
||||||
|
- **Servers** are native webroot installs with no image, so `bin/gpm self-upgrade` mutates the install in place and *is* durable there. (Note: `bin/grav upgrade` does **not** exist — the correct verb is `bin/gpm self-upgrade`. — session history)
|
||||||
|
- **Local** is rebuilt from an image, so only a `Dockerfile` bump persists.
|
||||||
|
|
||||||
|
A consequence worth remembering (accepted risk, flagged in the original upgrade session): the local gate never exercises the server's in-place `self-upgrade` path — a fresh image bakes a clean core and reinstalls plugins clean, whereas the server mutates an existing core in place. A green local build proves the clean-install path, not the in-place upgrade path; the remote upgrade is the first real test of that. (session history)
|
||||||
|
|
||||||
|
**Same mental model applies beyond version upgrades.** Because the core/runtime is baked and only `./user` is mounted, *any* runtime capability lives in the image. Adding server-side HEIC support (ImageMagick/libheif) would likewise require a custom image rebuild — which is why HEIC was handled client-side instead. "The core is in the image; only `./user` is a volume" is the reusable principle. (session history)
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Any time the **local** Grav core version needs to change (upgrade or, rarely, a pinned downgrade — note `gpm self-upgrade` is forward-only and cannot downgrade).
|
||||||
|
- Whenever you catch yourself about to run `gpm self-upgrade` inside the dev container "to match the server" — stop and bump the `Dockerfile` instead.
|
||||||
|
- When `docker compose up`/`--force-recreate` reports a container-name conflict for a service with a fixed `container_name`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Concrete run from the 2.0.4 → 2.0.7 local upgrade (2026-07-05):
|
||||||
|
|
||||||
|
```
|
||||||
|
# Dockerfile line 3: .../download/2.0.4/grav-admin-v2.0.4.zip
|
||||||
|
# -> .../download/2.0.7/grav-admin-v2.0.7.zip
|
||||||
|
|
||||||
|
$ docker compose build grav
|
||||||
|
=> CACHED [1/2] FROM docker.io/getgrav/grav:latest
|
||||||
|
=> [2/2] RUN curl -sL '.../2.0.7/grav-admin-v2.0.7.zip' ... 3.4s
|
||||||
|
|
||||||
|
$ docker compose up -d grav
|
||||||
|
Error response from daemon: Conflict. The container name
|
||||||
|
"/intotheeast_grav" is already in use ...
|
||||||
|
|
||||||
|
$ docker rm -f intotheeast_grav && docker compose up -d grav
|
||||||
|
Container intotheeast_grav Started
|
||||||
|
|
||||||
|
$ docker exec -w /var/www/html intotheeast_grav php bin/grav --version
|
||||||
|
Grav CLI Application 2.0.7
|
||||||
|
|
||||||
|
# Smoke test from INSIDE the container (the host has no curl):
|
||||||
|
$ docker exec intotheeast_grav sh -c \
|
||||||
|
'for p in / /admin /gpx-manager; do curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:80$p"; done'
|
||||||
|
200
|
||||||
|
200
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config caveat:** a server-side `gpm self-upgrade` runs Grav's schema migration and rewrites `system.yaml` `strict_mode` flags (`twig_compat` → `twig2_compat`/`twig3_compat`). A fresh-image rebuild does **not** trigger that migration, so `user/config/system.yaml` in the repo must already carry the intended Twig-3 flags (it does, from an earlier reconciliation). If it didn't, local and server config would silently drift. This is another reason the image-rebuild path depends on the repo config being the source of truth.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/guides/local-setup.md` — "Upgrading to a newer Grav" section documents the same bump-and-rebuild procedure, but with stale "RC bundle" wording and without the `docker rm -f`, version-verify, plugin-refresh, or non-durability details. **Refresh candidate** — fold these operational steps in and drop the "RC" language (`Dockerfile` now pins stable `grav-admin-v2.0.7.zip`).
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the server/`.env`/fresh-install counterpart. That doc frames `GRAV_VERSION` as a version-authority surface for *remote* installs; this doc adds the **fourth** authority surface (the hardcoded release-zip URL in `Dockerfile`) and clarifies that `.env* GRAV_VERSION` never touches the local Docker core. Servers correctly self-upgrade because they have no image; local Docker cannot.
|
||||||
|
- `docs/guides/deploy-cycle.md` — the local→test→prod runbook. Its Phase 0 (Local) covers bumping `GRAV_VERSION` for remote installs but not upgrading the local Docker core; its "where state lives" table omits that the local core lives in the Docker image.
|
||||||
Reference in New Issue
Block a user