diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..0ae64ac
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,12 @@
+"""Set test env vars before the pygitweb package is imported (see pygitweb/conftest.py)."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+_test_auth_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-auth.json"
+_test_settings_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-settings.json"
+os.environ.setdefault("PYGITWEB_AUTH", "0")
+os.environ.setdefault("PYGITWEB_AUTH_CONFIG", str(_test_auth_config))
+os.environ.setdefault("PYGITWEB_SETTINGS_CONFIG", str(_test_settings_config))
diff --git a/pygittools/hooks_push_test.py b/pygittools/hooks_push_test.py
index 2bba199..90cb7ce 100644
--- a/pygittools/hooks_push_test.py
+++ b/pygittools/hooks_push_test.py
@@ -29,7 +29,7 @@ def _init_client_with_remote(tmp_path: Path) -> tuple[Repository, Path, Path, Pa
 	tree = index.write_tree()
 	client.create_commit("HEAD", SIG, SIG, "init", tree, [])
 	client.references.create("refs/heads/wip/topic", client.head.target, force=True)
-	client.checkout(f"refs/heads/wip/topic")
+	client.checkout("refs/heads/wip/topic")
 	return client, client_path, bare_path, backup_path
 
 
diff --git a/pygitweb/README.md b/pygitweb/README.md
index 6af8c72..dbfdf7b 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -30,8 +30,9 @@ python -m pygitweb.main
 
 ## Config
 
-Settings load from `PYGITWEB_*` environment variables (or a `.env` file in the working directory) via
-`pydantic-settings`. See `pygitweb/config.py` for the full schema. Common ones:
+Settings load from `~/.pygitweb/settings.json` (or `PYGITWEB_SETTINGS_CONFIG`), `PYGITWEB_*`
+environment variables, and optionally a `.env` file in the working directory via
+`pydantic-settings`. See `pygitweb/config.py` and `pygitweb/settings.schema.json` for the full schema. Common ones:
 
 - `PYGITWEB_PROJECTROOT` — absolute path to directory containing git repositories (default: `$HOME`)
 - `PYGITWEB_PROJECTS_LIST` — directory to scan, or path to a project-list file (default: `PROJECTROOT`)
@@ -40,6 +41,7 @@ Settings load from `PYGITWEB_*` environment variables (or a `.env` file in the w
 - `PYGITWEB_GIT` — path to the git executable (default: `git`)
 - `PYGITWEB_AUTH` — `0` / `false` / `off` disables auth; `1` / `true` / `on` enables it. If **unset or empty**, auth defaults to **on**.
 - `PYGITWEB_AUTH_CONFIG` — path to the auth JSON file (default: `~/.pygitweb/auth.json`). Generated to a reasonable default if not present.
+- `PYGITWEB_SETTINGS_CONFIG` — path to the settings JSON file (default: `~/.pygitweb/settings.json`). Created from the environment on first run if missing.
 - Browser login: `GET /login` (optional `?next=/path`), `POST /login` with form fields `username`, `password`, `next`; sets an HttpOnly cookie with an opaque in-process session id (also accepted by protected routes via `Authorization: Bearer`). Sessions are wiped when the process exits. `GET /logout?next=/` revokes the session and clears the cookie.
 
 ## Routes
diff --git a/pygitweb/__init__.py b/pygitweb/__init__.py
index ad5c90a..e07991d 100644
--- a/pygitweb/__init__.py
+++ b/pygitweb/__init__.py
@@ -1,3 +1,14 @@
+import os
+import sys
+from pathlib import Path
+
+if "pytest" in sys.modules:
+	_test_auth_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-auth.json"
+	_test_settings_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-settings.json"
+	os.environ.setdefault("PYGITWEB_AUTH", "0")
+	os.environ.setdefault("PYGITWEB_AUTH_CONFIG", str(_test_auth_config))
+	os.environ.setdefault("PYGITWEB_SETTINGS_CONFIG", str(_test_settings_config))
+
 import pygitweb.__meta__
 from pygitweb.main import app as app
 
diff --git a/pygitweb/config.py b/pygitweb/config.py
index 53cc9e8..cf92125 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -87,7 +87,9 @@ BLOB_LANG: dict[str, str] = {
 
 class Settings(BaseSettings):
 	"""
-	Runtime settings sourced from PYGITWEB_* env vars (and optionally a .env file).
+	Runtime settings from ~/.pygitweb/settings.json (or PYGITWEB_SETTINGS_CONFIG),
+	PYGITWEB_* env vars, and optionally a .env file. On first run the file is created
+	from the environment. When the file exists, env vars override file values (with a warning).
 
 	When AUTH is true, PyGitWeb uses OAuth2 password flow (POST /token), Bearer tokens,
 	and browser login. Local credentials live in the auth JSON file (see PYGITWEB_AUTH_CONFIG).
@@ -118,6 +120,7 @@ class Settings(BaseSettings):
 
 	AUTH: bool | None = None
 	AUTH_CONFIG: str = ""
+	SETTINGS_CONFIG: str = ""
 
 	@field_validator("AUTH", mode="before")
 	@classmethod
@@ -160,8 +163,9 @@ def _finalize_auth_settings(s: Settings) -> None:
 		)
 
 
-settings = Settings()
-_finalize_auth_settings(settings)
+from pygitweb.settings_config import init_settings  # noqa: E402
+
+settings = init_settings()
 
 from pygitweb.auth_config import init_auth_config  # noqa: E402
 
diff --git a/pygitweb/conftest.py b/pygitweb/conftest.py
index 07fc468..7151358 100644
--- a/pygitweb/conftest.py
+++ b/pygitweb/conftest.py
@@ -1,20 +1,14 @@
-"""Ensure tests default auth off unless a test patches settings (before config import is too late for that)."""
+"""Pytest fixtures for pygitweb (test env vars are set in the repo-root conftest.py)."""
 
 from __future__ import annotations
 
 import asyncio
-import os
 from collections.abc import Generator
 from contextlib import contextmanager
-from pathlib import Path
 
 import pytest
 from fastapi.testclient import TestClient
 
-_test_auth_config = Path(os.environ.get("TEMP", "/tmp")) / "pygitweb-test-auth.json"
-os.environ.setdefault("PYGITWEB_AUTH", "0")
-os.environ.setdefault("PYGITWEB_AUTH_CONFIG", str(_test_auth_config))
-
 
 def clear_client_cookies(client: TestClient) -> None:
 	client.cookies.clear()
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index 3feaf8b..0868efd 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -30,7 +30,7 @@ packages = ["pygitweb", "pygitweb.api", "pygitweb.api.plugins"]
 pygitweb = "."
 
 [tool.setuptools.package-data]
-pygitweb = ["templates/**/*", "static/**/*"]
+pygitweb = ["templates/**/*", "static/**/*", "*.schema.json"]
 
 [tool.uv.sources]
 pygittools = { workspace = true }
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index a903c44..fcec930 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -16,6 +16,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.auth import require_permission
 from pygitweb.config import settings
+from pygitweb.settings_config import settings_batch_update
 from pygitweb.dependencies import ValidatedSettingsProject
 from pygitweb.permissions import Permission
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
@@ -299,22 +300,23 @@ def settings_pygitweb_page(request: Request):
 
 @router.post("/pygitweb/submit", response_class=HTMLResponse)
 async def settings_pygitweb_submit(request: Request):
-	"""Apply PyGitWeb settings (in-memory for current process)."""
+	"""Apply PyGitWeb settings and persist to the settings JSON file."""
 	form = await request.form()
 
 	def _get(k: str) -> str:
 		return str(form.get(k) or "").strip()
 
 	_TRUTHY = {"on", "1", "true", "yes"}
-	for name, expected_type, _doc in PYGITWEB_SETTINGS:
-		raw = _get(name)
-		if expected_type is bool:
-			setattr(settings, name, raw.lower() in _TRUTHY)
-		elif expected_type is float:
-			with suppress(ValueError):
-				setattr(settings, name, float(raw) if raw else None)
-		elif raw:
-			setattr(settings, name, raw)
+	with settings_batch_update(settings):
+		for name, expected_type, _doc in PYGITWEB_SETTINGS:
+			raw = _get(name)
+			if expected_type is bool:
+				setattr(settings, name, raw.lower() in _TRUTHY)
+			elif expected_type is float:
+				with suppress(ValueError):
+					setattr(settings, name, float(raw) if raw else None)
+			elif raw:
+				setattr(settings, name, raw)
 	return RedirectResponse(url="/settings/pygitweb", status_code=303)
 
 
diff --git a/pygitweb/settings.schema.json b/pygitweb/settings.schema.json
new file mode 100644
index 0000000..c40b9ad
--- /dev/null
+++ b/pygitweb/settings.schema.json
@@ -0,0 +1,73 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "description": "PyGitWeb runtime settings persisted to ~/.pygitweb/settings.json (or PYGITWEB_SETTINGS_CONFIG).",
+  "type": "object",
+  "properties": {
+    "PROJECTROOT": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Root filesystem path under which git repositories live."
+    },
+    "PROJECTS_LIST": {
+      "type": "string",
+      "description": "Path used for listing projects (often same as PROJECTROOT)."
+    },
+    "PROJECT_MAXDEPTH": {
+      "type": "integer",
+      "minimum": 0,
+      "description": "Maximum directory depth when scanning for projects."
+    },
+    "SITE_NAME": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Site name shown in the web interface."
+    },
+    "EXPORT_OK": {
+      "type": "string",
+      "description": "If set, only repos with this file (or matching path) are listed."
+    },
+    "LIST_ALL": {
+      "type": "boolean",
+      "description": "When true, list all directories under project root without export_ok checks."
+    },
+    "STRICT_EXPORT": {
+      "type": "boolean",
+      "description": "When true, require export_ok (or path match) to list projects."
+    },
+    "GIT": {
+      "type": "string",
+      "minLength": 1,
+      "description": "Path to the git executable."
+    },
+    "MAXLOAD": {
+      "type": ["number", "null"],
+      "description": "Max load average; 503 when exceeded. null to disable."
+    },
+    "AUTH": {
+      "type": ["boolean", "null"],
+      "description": "When true, authentication is enabled. null means default-on with a warning on first run."
+    },
+    "AUTH_CONFIG": {
+      "type": "string",
+      "description": "Path to the auth JSON file. Empty uses ~/.pygitweb/auth.json."
+    },
+    "SETTINGS_CONFIG": {
+      "type": "string",
+      "description": "Path to this settings JSON file. Empty uses ~/.pygitweb/settings.json."
+    }
+  },
+  "required": [
+    "PROJECTROOT",
+    "PROJECTS_LIST",
+    "PROJECT_MAXDEPTH",
+    "SITE_NAME",
+    "EXPORT_OK",
+    "LIST_ALL",
+    "STRICT_EXPORT",
+    "GIT",
+    "MAXLOAD",
+    "AUTH",
+    "AUTH_CONFIG",
+    "SETTINGS_CONFIG"
+  ]
+}
diff --git a/pygitweb/settings_config.py b/pygitweb/settings_config.py
new file mode 100644
index 0000000..13d8450
--- /dev/null
+++ b/pygitweb/settings_config.py
@@ -0,0 +1,122 @@
+"""
+PyGitWeb settings file: ~/.pygitweb/settings.json (or PYGITWEB_SETTINGS_CONFIG).
+
+On first run, loads from environment and writes the file. When the file exists,
+loads from JSON then applies explicit environment/.env overrides (with a warning).
+Changes via the settings UI or setattr persist back to the file.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+	from pygitweb.config import Settings
+
+DEFAULT_SETTINGS_CONFIG_PATH = Path.home() / ".pygitweb" / "settings.json"
+
+_persist_enabled = False
+_batch_update = False
+_active_path: Path | None = None
+_setattr_patched = False
+
+
+def settings_config_path() -> Path:
+	explicit = __import__("os").environ.get("PYGITWEB_SETTINGS_CONFIG", "").strip()
+	if explicit:
+		return Path(explicit).expanduser()
+	return DEFAULT_SETTINGS_CONFIG_PATH
+
+
+def write_settings_file(settings: Settings, path: Path | None = None) -> None:
+	target = path or _active_path or settings_config_path()
+	target.parent.mkdir(parents=True, exist_ok=True)
+	target.write_text(
+		json.dumps(settings.model_dump(mode="json"), indent=2) + "\n",
+		encoding="utf-8",
+	)
+
+
+def _warn_env_overrides(path: Path, fields: list[str]) -> None:
+	names = ", ".join(fields)
+	print(
+		f"WARNING: PyGitWeb: environment overrides settings file {path}\n         Overridden field(s): {names}\n",
+		file=sys.stderr,
+	)
+
+
+def _apply_env_overrides(base: Settings) -> list[str]:
+	from pygitweb.config import Settings
+
+	env_settings = Settings()
+	overrides: list[str] = []
+	for name in env_settings.model_fields_set:
+		env_val = getattr(env_settings, name)
+		if getattr(base, name) == env_val:
+			continue
+		setattr(base, name, env_val)
+		overrides.append(name)
+	return overrides
+
+
+def _patch_settings_setattr() -> None:
+	global _setattr_patched
+	if _setattr_patched:
+		return
+
+	from pygitweb.config import Settings
+
+	original = Settings.__setattr__
+
+	def _setattr(self: Settings, name: str, value: object) -> None:
+		original(self, name, value)
+		if not _persist_enabled or _batch_update:
+			return
+		if name not in Settings.model_fields:
+			return
+		write_settings_file(self)
+
+	Settings.__setattr__ = _setattr  # type: ignore[method-assign]
+	_setattr_patched = True
+
+
+def init_settings() -> Settings:
+	from pygitweb.config import Settings, _finalize_auth_settings
+
+	global _persist_enabled, _active_path
+
+	_persist_enabled = False
+	path = settings_config_path()
+	_active_path = path
+
+	if path.is_file():
+		settings = Settings.model_validate_json(path.read_text(encoding="utf-8"))
+		overrides = _apply_env_overrides(settings)
+		if overrides:
+			_warn_env_overrides(path, overrides)
+	else:
+		settings = Settings()
+
+	_finalize_auth_settings(settings)
+	write_settings_file(settings, path)
+	_patch_settings_setattr()
+	_persist_enabled = True
+	return settings
+
+
+@contextmanager
+def settings_batch_update(settings: Settings):
+	"""Apply multiple setting changes and persist once."""
+	global _batch_update
+
+	_batch_update = True
+	try:
+		yield
+	finally:
+		_batch_update = False
+		if _persist_enabled:
+			write_settings_file(settings)
diff --git a/pygitweb/settings_config_test.py b/pygitweb/settings_config_test.py
new file mode 100644
index 0000000..77de4ce
--- /dev/null
+++ b/pygitweb/settings_config_test.py
@@ -0,0 +1,101 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from pygitweb.config import Settings
+from pygitweb.settings_config import init_settings, settings_batch_update, write_settings_file
+
+
+@pytest.fixture
+def settings_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+	path = tmp_path / "settings.json"
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(path))
+	monkeypatch.setenv("PYGITWEB_AUTH", "0")
+	return path
+
+
+def test_creates_settings_file_from_environment(settings_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setenv("PYGITWEB_SITE_NAME", "FromEnv")
+	settings = init_settings()
+	assert settings_path.is_file()
+	data = json.loads(settings_path.read_text(encoding="utf-8"))
+	assert data["SITE_NAME"] == "FromEnv"
+	assert settings.SITE_NAME == "FromEnv"
+
+
+def test_loads_existing_settings_file(settings_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	settings_path.write_text(
+		json.dumps({
+			"PROJECTROOT": "/var/git",
+			"PROJECTS_LIST": "/var/git",
+			"PROJECT_MAXDEPTH": 2,
+			"SITE_NAME": "FromFile",
+			"EXPORT_OK": "",
+			"LIST_ALL": True,
+			"STRICT_EXPORT": False,
+			"GIT": "git",
+			"MAXLOAD": None,
+			"AUTH": False,
+			"AUTH_CONFIG": "",
+			"SETTINGS_CONFIG": "",
+		})
+		+ "\n",
+		encoding="utf-8",
+	)
+	monkeypatch.delenv("PYGITWEB_SITE_NAME", raising=False)
+	settings = init_settings()
+	assert settings.SITE_NAME == "FromFile"
+	assert settings.PROJECT_MAXDEPTH == 2
+
+
+def test_env_overrides_settings_file(settings_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
+	settings_path.write_text(
+		json.dumps({
+			"PROJECTROOT": "/var/git",
+			"PROJECTS_LIST": "/var/git",
+			"PROJECT_MAXDEPTH": 1,
+			"SITE_NAME": "FromFile",
+			"EXPORT_OK": "",
+			"LIST_ALL": True,
+			"STRICT_EXPORT": False,
+			"GIT": "git",
+			"MAXLOAD": None,
+			"AUTH": False,
+			"AUTH_CONFIG": "",
+			"SETTINGS_CONFIG": "",
+		})
+		+ "\n",
+		encoding="utf-8",
+	)
+	monkeypatch.setenv("PYGITWEB_SITE_NAME", "FromEnv")
+	settings = init_settings()
+	assert settings.SITE_NAME == "FromEnv"
+	assert json.loads(settings_path.read_text(encoding="utf-8"))["SITE_NAME"] == "FromEnv"
+	err = capsys.readouterr().err
+	assert "environment overrides settings file" in err
+	assert "SITE_NAME" in err
+
+
+def test_setattr_persists_to_file(settings_path: Path) -> None:
+	settings = init_settings()
+	settings.SITE_NAME = "Updated"
+	assert json.loads(settings_path.read_text(encoding="utf-8"))["SITE_NAME"] == "Updated"
+
+
+def test_batch_update_persists_once(settings_path: Path) -> None:
+	settings = init_settings()
+	with settings_batch_update(settings):
+		settings.SITE_NAME = "BatchOne"
+		settings.GIT = "/usr/bin/git"
+	data = json.loads(settings_path.read_text(encoding="utf-8"))
+	assert data["SITE_NAME"] == "BatchOne"
+	assert data["GIT"] == "/usr/bin/git"
+
+
+def test_write_settings_file_roundtrip(settings_path: Path) -> None:
+	settings = Settings(AUTH=False, SITE_NAME="Roundtrip")
+	write_settings_file(settings, settings_path)
+	loaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
+	assert loaded.SITE_NAME == "Roundtrip"
+	assert loaded.AUTH is False
diff --git a/pyproject.toml b/pyproject.toml
index 70d33da..eeb887a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,3 +41,8 @@ select = [
     # isort
     "I",
 ]
+
+[tool.mypy]
+exclude = [
+    ".*_test\\.py"
+]
