A `reset --hard` content deploy leaves Grav's compiled-Twig/page cache stale, so the first visitor pays the recompile. `remote-warmup` clears the cache then crawls the public site (homepage + trips listing + every trip page linked from it) to pre-render pages. Grav has no native warmup command, so it's an HTTP crawl — which also doubles as a smoke test (non-2xx pages flagged). Wired into REMOTE_TARGETS (-test/-prod variants) and added as the final step in both deploy-cycle.md phases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
10 KiB
Upgrade & Deploy Cycle: local → test → prod
This runbook is the repeatable procedure for shipping a Grav upgrade or any server-affecting change (core version, plugins, config, theme) through the three environments. It was distilled from the 2026-07 Grav 2.0.4→2.0.7 cutover, where every production surprise traced back to one of the desyncs this procedure now forces you to check.
Governing principle: test is a full dress rehearsal of prod — same
config, same -test/-prod make targets, same order. A gotcha only gets caught
on test if test is a faithful mirror of prod. Do not shortcut test.
All server operations go through make remote-* targets (never raw SSH — the
targets build the SSH connection from .env.<env>, which must never be read
directly). Every remote-* target has -test and -prod variants; a bare
target fails via guard-env.
The mental model: three places state lives
Every failure in the reference cutover was a desync between these three layers. Before and after each deploy step, ask: are they in sync?
| Layer | Location | Synced by | Failure mode |
|---|---|---|---|
| Plugin code | user/plugins/<name>/ |
GPM only (gitignored /plugins/*) |
can vanish while config remains → plugin won't enable |
| Repo config | user/config/… |
content-push / git-sync |
holds GPM channel + is where the version floor bites |
| Host config | user/env/<host>/config/… |
nothing — server-only | not restored on fresh install; must be re-applied; must be gitignored |
Referenced gotcha docs:
docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md— code-vs-config desync.docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md— staleGRAV_VERSION/ version floor.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.
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)
- Make the change in the repo:
- GPM channel:
gpm.releases: stableinuser/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 addgit-sync(it is remote-only).- Prod-only overrides (Twig cache/debug,
debugger.shutdown.close_connection: false) indeploy/env/prod/system.yaml— never commit prod values intouser/config/system.yaml. - Bump
GRAV_VERSIONin.env.testand.env.prodto the target version. A stale value here installs the wrong core (an rc), which then blocks theapiplugin 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>.zipURL inDockerfile,docker compose build grav, thendocker rm -f intotheeast_grav && docker compose up -d grav. The local core is baked into the image, so.env GRAV_VERSIONdoes not touch it and an in-containergpm self-upgradeis non-durable. Seedocs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md.
- GPM channel:
make build-assetsif you touchedjs/src/*(never hand-edit the bundledjs/*.js).- Run the dev server (
docker compose … up) and the Playwright suite. - Pre-flight assertions:
gpm.releasesisstable.plugins.txtis correct and does not containgit-sync.- No prod Twig values leaked into the committed
system.yaml.
- Commit.
make content-push.
Phase 1 — Test (the rehearsal — catch things here)
Pre-flight
make remote-git-sync-disable-testbefore any content reset. This is the safety catch for the whole window: it stops a half-migrated state (e.g. a fresh install-timeversions.yaml) from auto-committing and pushing on the first sync.
Apply — in this fixed order
make remote-fetch-content-test # 1. clean-reset synced folders to repo state
make remote-upgrade-grav-test # 2. gpm self-upgrade (rewrites schema — expect drift)
make remote-update-plugins-test # 3. gpm update the plugins.txt set (auto-applies deploy/patches/)
make remote-gpm-install-test PKG=git-sync # 4. EXPLICITLY (re)install each remote-only plugin
make remote-apply-env-test # 5. re-deploy the env override (not synced; gone after install)
make remote-warmup-test # 6. clear + warm cache — a reset deploy leaves it stale
Always finish a deploy with
remote-warmup-<env>— even a content-only deploy. Areset --hard(step 1) changes files under Grav without going through it, so the compiled-Twig/page cache is stale and the first visitor eats the recompile.remote-warmupclears the cache, then crawls the public pages (homepage + trips listing + every trip page linked from it) to pre-render them. Grav has no native warmup command — this is an HTTP crawl, so it also doubles as a smoke test (a non-2xx on any page is flagged loudly).
Why each matters:
- Step 3 re-applies
deploy/patches/*.patchautomatically (it chainsremote-apply-plugin-patches). GPM install/update lays down pristine third-party plugins, wiping local fixes to git-ignoreduser/plugins/— the patch step restores them. Content pulls (step 1) do not touchplugins/, so the patch only needs re-applying after a GPM op, not after every sync. Runmake remote-apply-plugin-patches-teststandalone if you ever GPM-install outside this sequence. Requires thepatchtool on the server. Seedeploy/patches/README.md. - Step 4 is non-optional even if git-sync "was already there" — remote-only plugins are not in
plugins.txt, so nothing in steps 1–3 restores them. If the code is missing, the plugin is inert despite valid config. - Step 5 re-writes
user/env/<host>/config/…fromdeploy/env/<env>/. The env tree is not synced by anything, so a fresh install loses it until you re-apply.
Verify (smoke checklist — this is the payoff)
- Code present, not just config:
ls user/plugins/<name>/for every expected plugin (especiallygit-sync). An empty/absent dir = reinstall (step 4). (Do this via an ssh one-liner you run, ormake remote-diag-test.) - Plugin patches applied: confirm the add-page-by-form fix survived the GPM op —
grep -c toArray user/plugins/add-page-by-form/add-page-by-form.phpshould be ≥1 (0 = pristine, re-runmake remote-apply-plugin-patches-test). Functional check: edit a journal entry and add a photo — a pristine plugin 500s on save. - HTTP:
/→ 200,/admin→ 200,/api/v1/pages→ 401,/gpx-manager→ 200. Watch for the double-Content-Encodinggarbage page (fix:debugger.shutdown.close_connection: falsein the env override — already indeploy/env/prod/system.yaml). - Post smoke test: submit one entry via
/postand confirm it appears in the trip feed immediately. This proves thecache-on-saveplugin works with prod caching on. - Config drift:
make remote-diag-test— diff server config against the repo. Fold any intended schema migration (e.g. the Twig-3strict_modeflags aself-upgradewrites) back intouser/config/system.yaml, or the nextfetch-contentreverts it.
Re-enable + prove sync
make remote-git-sync-enable-test.- Confirm a content push round-trips to the server, and that no secret/boomerang commit lands on Gitea. Verify
/env/is gitignored so the env tree (which holds the token, JWT, CSRF salt) can never enter the sync add-set.
Phase 2 — Prod (repeat identically — should be mechanical)
Run the exact same sequence with -prod targets. Because test rehearsed it,
prod holds no surprises. Differences to layer on:
- Optional:
make remote-maintenance-on-prodat the start,remote-maintenance-off-prodat the end, for a clean window. - Confirm secrets are valid/rotated and
/env/is gitignored beforeremote-git-sync-enable-prod. Re-enable git-sync last. - After a clean cutover, bump the outer-repo submodule pin to the finished
user/commit — and pushuser/before the outer repo (the superproject references a child SHA that must already exist upstream).
make remote-git-sync-disable-prod
make remote-fetch-content-prod
make remote-upgrade-grav-prod
make remote-update-plugins-prod
make remote-gpm-install-prod PKG=git-sync
make remote-apply-env-prod
make remote-warmup-prod # clear + warm cache; also HTTP-smokes public pages
# ── smoke checklist (same as test) ──
make remote-git-sync-enable-prod
For a first-time / from-scratch prod bring-up, make remote-install-prod does the
full install; then still run remote-apply-env-prod and the smoke checklist, and
reinstall remote-only plugins explicitly.
Rollback & safety
- git-sync stays disabled through the whole apply window on each host — it is the catch that prevents a half-migrated state from auto-pushing.
- Content is a git repo: a bad content deploy is recoverable with
make remote-fetch-content-<env>back to a known commit. - Core + plugins are GPM-reinstallable (
remote-upgrade-grav,remote-update-plugins,remote-gpm-install PKG=…). - The one thing tooling cannot regenerate is the un-synced
user/env/<host>/tree — its source of truth isdeploy/env/<env>/, so keep that current and re-apply withremote-apply-env-<env>.
One-line invariants (the through-line)
testis config-identical toprod, run with the same targets in the same order.- Verify the code layer (
ls user/plugins/<name>/), not just config, on every deploy. - Reinstall remote-only plugins (git-sync) explicitly — nothing else restores them.
GRAV_VERSIONin.env.<env>andgpm.releases: stableare correct before any server GPM op.- Re-apply the env override after every install; keep
/env/gitignored. - git-sync off during the window, on last; confirm the round-trip carries no secrets.
- Diagnose actual state before changing config — an
lsorremote-diagbeats a guess.