The prior model claimed git-sync's add-set is scoped to the configured `folders` (pages/config/themes), so accounts/ and user/env/ were "safe by construction." That was wrong: prod auto-commit 9337003 pushed the whole user/env/<host>/config tree (JWT secret, CSRF salt, git-sync token + webhook secret), accounts/mischa.yaml, and system.yaml to Gitea — all outside the configured folders. git-sync stages everything under user/ not gitignored; .gitignore is the only reliable exclusion. - Rewrite the architecture-patterns doc around the corrected predicate and document the incident + remediation (gitignore /env/, rotate token+webhook). - Correct git-sync-notes.md: env/ is NOT safe on folder scope; drop the "never reaches Gitea" claim; fix the secret-files table intro + add /env/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
87 lines
6.0 KiB
Markdown
87 lines
6.0 KiB
Markdown
---
|
|
title: "Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder"
|
|
date: 2026-07-04
|
|
category: docs/solutions/conventions
|
|
module: "grav / plugin configuration"
|
|
problem_type: convention
|
|
component: tooling
|
|
severity: high
|
|
applies_when:
|
|
- "Editing functional config for any GPM-managed Grav plugin"
|
|
- "user/plugins/ is gitignored and only pages/config/accounts/themes are tracked"
|
|
- "Preparing a fresh install or production cutover"
|
|
- "A plugin behaves correctly locally but ships with only default config on deploy"
|
|
related_components:
|
|
- "grav"
|
|
- "gpm"
|
|
- "api plugin"
|
|
- "content repo"
|
|
- "deployment"
|
|
tags:
|
|
- grav
|
|
- plugin-config
|
|
- gpm
|
|
- config-override
|
|
- gitignore
|
|
- deployment
|
|
- api-plugin
|
|
---
|
|
|
|
# Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder
|
|
|
|
## Context
|
|
|
|
Grav resolves a plugin's config by deep-merging two layers: the plugin's own shipped file `user/plugins/<name>/<name>.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/<name>.yaml` (which wins). In this project the content repo tracks only `pages/`, `config/`, `accounts/`, `themes/`; `user/plugins/` and `user/data/` are gitignored (GPM manages plugin *code*). So any functional config a developer edits into a plugin's own `user/plugins/<name>/<name>.yaml` is invisible to version control.
|
|
|
|
It was this gap that left the `api` plugin unconfigured on the fresh prod install. Its `enabled`/`route`/`session_enabled`/cors/rate_limit config existed only in the untracked plugin folder locally, while the committed `user/config/plugins/api.yaml` held only a runtime `popularity.salt`. The local machine worked because the plugin folder had been hand-edited; every fresh environment got only the plugin's shipped defaults.
|
|
|
|
## Guidance
|
|
|
|
Put **functional** plugin configuration in the TRACKED override `user/config/plugins/<name>.yaml`. Grav deep-merges it over the plugin's shipped defaults, so it need only carry the keys that must differ (or the full config, for clarity). Keep **secrets and per-install generated values** OUT of the tracked file — JWT secrets, salts, encrypted tokens belong in gitignored `*-private.php` companion files (e.g. `api-private.php`, `security-private.php`) or should be regenerated per-install.
|
|
|
|
Never rely on edits to the plugin's own `user/plugins/<name>/<name>.yaml`: it is gitignored (won't deploy) and is overwritten on the next `php bin/gpm update`.
|
|
|
|
Concrete before/after, using the `api` plugin:
|
|
|
|
```yaml
|
|
# WRONG: user/plugins/api/api.yaml (gitignored, GPM-managed, wiped on update)
|
|
enabled: true
|
|
route: /api
|
|
auth:
|
|
session_enabled: true
|
|
```
|
|
|
|
```yaml
|
|
# RIGHT: user/config/plugins/api.yaml (tracked, deploys, survives gpm update)
|
|
enabled: true
|
|
route: /api
|
|
version_prefix: v1
|
|
auth:
|
|
session_enabled: true
|
|
# JWT secret intentionally NOT here — it lives in the gitignored api-private.php
|
|
```
|
|
|
|
## Why This Matters
|
|
|
|
Reproducible deploys: a fresh clone or `make remote-install-<env>` must produce a working site from the repo alone. Config stranded in the gitignored plugin folder silently yields a plugin with only its shipped defaults on every new environment — which, for a plugin whose behavior depends on non-default config, means it's misconfigured or effectively off. On prod the `api` plugin's route/auth simply didn't work.
|
|
|
|
The failure is silent and per-environment: it works on the developer's machine (where the plugin folder was hand-edited) and breaks everywhere else. `gpm update` compounds it by wiping the folder edit even locally, so the "working" state is not just unshared — it is also unstable on the one machine that had it.
|
|
|
|
## When to Apply
|
|
|
|
- Any time you configure a Grav plugin whose non-default settings must work on a server (prod/test) or survive a plugin update.
|
|
- Especially for plugins whose function depends on config: `api` (route/auth/cors), `admin2`, `flex-objects`, form/media settings, etc.
|
|
- When auditing a fresh-install failure: check whether the "working" local config actually lives in a tracked path (`git ls-files user/config/plugins/<name>.yaml`) or was stranded in `user/plugins/<name>/`.
|
|
|
|
## Examples
|
|
|
|
- **api plugin (this project):** the functional config was moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`, then `make content-push` + `make remote-fetch-content-<env>` deployed it. The JWT secret stayed in the gitignored `api-private.php`.
|
|
- **Quick audit command:** `git -C user ls-files config/plugins/` shows exactly which plugin configs are tracked/deployable; anything you rely on that isn't listed is a latent fresh-install failure.
|
|
|
|
## Related
|
|
|
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment.
|
|
- `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env/<host>/config/`, which is *also* untracked. Same "config that doesn't reach the repo" family.
|
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — the sync-boomerang consequence of this rule: a per-install value that a plugin regenerates into a *tracked* functional config file (e.g. `popularity.salt` in `api.yaml`) re-commits itself and ping-pongs across environments under bidirectional git-sync. The `*-private.php` companion pattern this doc establishes is exactly the durable fix.
|
|
- **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder.
|