Files
intotheeast-com/docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md
m038andClaude Opus 4.8 3d9d3ecb85 docs(solutions): capture docker exec root-owned bind-mount fix
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
2026-07-08 12:15:01 +02:00

8.4 KiB

title, date, category, module, problem_type, component, severity, applies_when, tags
title date category module problem_type component severity applies_when tags
Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade 2026-07-05 docs/solutions/tooling-decisions/ docker-dev-env tooling_decision tooling medium
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
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) curls a specific release zip and copies its system/, vendor/, bin/, index.php, etc. into the image at build time:

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:

# 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_compattwig2_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.

  • 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.