New learning: docker exec defaults to root, so make targets writing into the ./user bind mount (esp. install-plugins -> gpm) created root-owned files (11,624 accumulated), breaking worktree-rm. Fix: HOST_UID/HOST_GID + `-u` on file-writing execs while the grav container still boots as root. Cross-linked reciprocally with the sibling docker-dev-env upgrade doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
117 lines
8.4 KiB
Markdown
117 lines
8.4 KiB
Markdown
---
|
|
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.
|
|
- `docs/solutions/integration-issues/docker-exec-root-owned-bind-mount-files.md` — the ownership counterpart to the `make install-plugins` step above (line 54). That `docker exec … php bin/gpm install` writes the plugin tree into the `./user` bind mount **as root** unless `-u $(HOST_UID):$(HOST_GID)` is passed; that doc explains the fix and why the container still boots as root.
|