--- 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// path and the grav-admin-v.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 -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.