diff --git a/pygitweb/README.md b/pygitweb/README.md
index 56c4873..8be1ee1 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -30,12 +30,15 @@ python -m pygitweb.main
 
 ## Config
 
-Set environment variables (or use a Python config file via `GITWEB_CONFIG`):
-
-- `GITWEB_PROJECTROOT` — absolute path to directory containing git repositories (default: `/pub/scm`)
-- `GITWEB_LIST` — same path or path to a project-list file
-- `GITWEB_EXPORT_OK` — filename that must exist to allow export (e.g. `git-daemon-export-ok`); empty = no check
-- `GITWEB_SITENAME` — site name in titles
+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:
+
+- `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`)
+- `PYGITWEB_EXPORT_OK` — filename that must exist to allow export (e.g. `git-daemon-export-ok`); empty = no check
+- `PYGITWEB_SITE_NAME` — site name in titles (default: `PyGitWeb`)
+- `PYGITWEB_GIT` — path to the git executable (default: `git`)
+- `PYGITWEB_AUTH` — fully-qualified auth provider class (e.g. `module.path.ClassName`); unset/`None` to disable
 
 ## Routes
 
diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index 185a647..3ea0e15 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -16,8 +16,7 @@ import pygit2
 from fastapi import HTTPException, Request
 from fastapi.responses import HTMLResponse, PlainTextResponse, Response
 
-from pygitweb import config
-from pygitweb.config import BLOB_LANG
+from pygitweb.config import BLOB_LANG, settings
 from pygitweb.formatting import age_string, esc_html, sanitize, to_utf8
 from pygitweb.git_helpers import (
 	get_blob_at_ref_path,
@@ -260,7 +259,7 @@ def git_object(project: str, h: str | None) -> Response:
 		)
 		pre = PREAMBLE.render(
 			title=f"Blob {oid_short} - {esc_html(project)}",
-			site_name=config.SITE_NAME,
+			site_name=settings.SITE_NAME,
 		)
 		return HTMLResponse(
 			f"{pre}<h1>Blob {esc_html(oid_short)}</h1>"
@@ -300,7 +299,7 @@ def git_summary(project: str) -> HTMLResponse:
 			["Remotes", f"<a href='/project/{project}?a=remotes'>view remotes</a>"],
 		],
 	)
-	pre = PREAMBLE.render(title=f"{esc_html(config.SITE_NAME)} - {project}", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{esc_html(settings.SITE_NAME)} - {project}", site_name=settings.SITE_NAME)
 	body_parts = [f"{pre}<h1>{esc_html(project)}</h1>{table}"]
 
 	readme = get_readme_at_ref_path(project, head, "")
@@ -316,7 +315,7 @@ def git_remotes(project: str) -> HTMLResponse:
 	"""Remotes page: list configured remotes (name, url, push_url)."""
 	remotes = git_get_remotes_info(project)
 	if not remotes:
-		pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=config.SITE_NAME)
+		pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=settings.SITE_NAME)
 		return HTMLResponse(f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}")
 	rows = []
 	for r in remotes:
@@ -328,7 +327,7 @@ def git_remotes(project: str) -> HTMLResponse:
 		cols=["Name", "URL", "Push URL"],
 		rows=rows,
 	)
-	pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"Remotes - {esc_html(project)}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")
 
 
@@ -367,7 +366,7 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 		f"</div>"
 	)
 	blob_script = '<script src="/static/blob-view.js"></script>'
-	pre = PREAMBLE.render(title=f"{esc_html(f)} - {esc_html(project)}", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{esc_html(f)} - {esc_html(project)}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
 
 
@@ -402,7 +401,7 @@ def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
 	title_path = f" / {f}" if f else ""
 	pre = PREAMBLE.render(
 		title=f"{esc_html(project)}{esc_html(title_path)} - Tree",
-		site_name=config.SITE_NAME,
+		site_name=settings.SITE_NAME,
 	)
 	table = env.get_template("table.html").render(cols=["Name", "Type"], rows=rows)
 	body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{esc_html(title_path)}</h1>{table}"]
@@ -428,7 +427,7 @@ def git_log(project: str, h: str | None, request: Request, page: int, pagecount:
 		rows.append(_format_commit_table_row(project, commit, short=False))
 	ref_display = h[:7] if h else "HEAD"
 	title = f"Log - {esc_html(project)} @ {esc_html(ref_display)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"], rows=rows
 	)
@@ -450,7 +449,7 @@ def git_shortlog(project: str, h: str | None, request: Request, page: int, pagec
 		rows.append(_format_commit_table_row(project, commit, short=True))
 	ref_display = h[:7] if h else "HEAD"
 	title = f"Shortlog - {esc_html(project)} @ {esc_html(ref_display)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(cols=["Commit", "Subject", "Author", "Age", "Diff"], rows=rows)
 	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
 	return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")
@@ -481,7 +480,7 @@ def git_history(
 		rows.append(_format_commit_table_row(project, commit, short=False))
 	ref_display = h[:7] if h else "HEAD"
 	title = f"History - {esc_html(f)} - {esc_html(project)} @ {esc_html(ref_display)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"], rows=rows
 	)
@@ -509,7 +508,7 @@ def git_heads(project: str) -> HTMLResponse:
 			f'<a href="{tree_link}">tree</a>',
 		])
 	title = f"Heads - {esc_html(project)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Head", "Commit", ""],
 		rows=rows,
@@ -559,7 +558,7 @@ def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLR
 			obj_cell,
 		])
 	title = f"Tags - {esc_html(project)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	table = env.get_template("table.html").render(
 		cols=["Tag", "Object"],
 		rows=rows,
@@ -623,7 +622,7 @@ def git_tag(project: str, h: str | None) -> HTMLResponse:
 		table_rows.append(["Message", f"<pre class='tag-message'>{esc_html(message)}</pre>"])
 	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
 	title = f"Tag {esc_html(tag_name)} - {esc_html(project)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	return HTMLResponse(
 		f"{pre}<h1>Tag {esc_html(tag_name)}</h1>"
 		f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
@@ -674,7 +673,7 @@ def git_commit(project: str, h: str | None) -> HTMLResponse:
 	table_rows.append(["", f'<a href="{diff_link}">diff</a> · <a href="{patch_link}">patch</a>'])
 	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
 	title = f"Commit {esc_html(oid_short)} - {esc_html(project)}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	return HTMLResponse(
 		f"{pre}<h1>Commit {esc_html(oid_short)}</h1>"
 		f"<p>Project: <a href='/project/{project}'>{esc_html(project)}</a></p>"
@@ -687,7 +686,7 @@ def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
 	diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
 	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
 	title = f"Commit diff {oid_short} - {project}"
-	pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
 	body = env.get_template("commitdiff.html").render(
 		project=project,
 		oid_short=oid_short,
@@ -763,7 +762,7 @@ def git_blobdiff(
 	blob_link = f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
 	pre = PREAMBLE.render(
 		title=f"Blob diff - {esc_html(path_new)} - {esc_html(project)}",
-		site_name=config.SITE_NAME,
+		site_name=settings.SITE_NAME,
 	)
 	body = env.get_template("blobdiff.html").render(
 		project=project,
diff --git a/pygitweb/config.py b/pygitweb/config.py
index 1754c05..35ed893 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -1,19 +1,17 @@
 """
-Gitweb configuration: settings, config file loading, loadavg, features, snapshot formats.
-Ported from gitweb/gitweb.perl (evaluate_gitweb_config, read_config_file, get_loadavg,
-check_loadavg, known_snapshot_formats, feature_*, gitweb_get_feature, gitweb_check_feature,
-filter_snapshot_fmts, filter_and_validate_refs, configure_gitweb_features, get_branch_refs).
+PyGitWeb configuration: env-driven settings via pydantic-settings, constants, and loadavg.
 """
 
 from __future__ import annotations
 
 import os
-import re
 from pathlib import Path
-from typing import Any
 
-# Allowed actions (from %actions in gitweb.perl)
-ACTIONS = {
+from pydantic import Field, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+# Actions (most identical to gitweb.perl actions)
+ACTIONS: set[str] = {
 	"blame",
 	"blame_incremental",
 	"blame_data",
@@ -45,8 +43,8 @@ ACTIONS = {
 	"project_index",
 }
 
-# Map file extension to highlight.js language (class name)
-BLOB_LANG = {
+# Map file extension to highlight.js language
+BLOB_LANG: dict[str, str] = {
 	"py": "python",
 	"js": "javascript",
 	"ts": "typescript",
@@ -84,157 +82,50 @@ BLOB_LANG = {
 	"dockerfile": "dockerfile",
 }
 
-# Defaults (equivalent to @GITWEB_*@ in gitweb.perl)
-PROJECTROOT = os.getenv("PYGITWEB_PROJECTROOT", str(Path.home()))
-PROJECT_MAXDEPTH = int(os.getenv("PYGITWEB_PROJECT_MAXDEPTH", "1"))
-PROJECTS_LIST = os.getenv("PYGITWEB_LIST", PROJECTROOT)
-SITE_NAME = os.getenv("PYGITWEB_SITENAME", "") or "PyGitWeb"
-EXPORT_OK = os.getenv("PYGITWEB_EXPORT_OK", "")
-# When True, list all directories under project root without repo/export_ok checks (default on for now).
-LIST_ALL = os.getenv("PYGITWEB_LIST_ALL", "1").lower() in ("1", "true", "yes")
-STRICT_EXPORT = os.getenv("GITWEB_STRICT_EXPORT", "0").lower() in (
-	"1",
-	"true",
-	"yes",
-)
-GIT_BINDIR = os.getenv("GIT_BINDIR", "")
-GIT = (GIT_BINDIR + "/git") if GIT_BINDIR else "git"
-MAXLOAD: float | None = None  # 300 in perl; None = disabled
-
-"""
-The auth provider to use. "None" disables authentication entirely.
-RootProvider: Stub which provides admin login with a user/password. Parent class for other providers.
-SSHProvider: TODO - Will authenticate using existing SSH keys. Easiest coming from raw git-daemon.
-OAuth2Provider: TODO - Will authenticate against an OAuth2 server.
-OIDCProvider: TODO - Will authenticate against an OIDC server.
-MatrixProvider: TODO - Will authenticate against the Matrix network.
-LDAPProvider: TODO - Will authenticate against a local LDAP server.
-"""
-PYGITWEB_AUTH: str | None = os.getenv("PYGITWEB_AUTH", None)
-if PYGITWEB_AUTH == "None":
-	PYGITWEB_AUTH = None
-
-PYGITWEB_ADMIN_USER: str | None = os.getenv("PYGITWEB_ADMIN_USER", None)
-PYGITWEB_ADMIN_PASSWORD: str | None = os.getenv("PYGITWEB_ADMIN_PASSWORD", None)
-PYGITWEB_SESSION_TIMEOUT: str | int | None = os.getenv("PYGITWEB_SESSION_TIMEOUT", 3600 * 24 * 7)
 
-# Config file paths (can be overridden by env)
-GITWEB_CONFIG: str | None = os.getenv("GITWEB_CONFIG", None)
-GITWEB_CONFIG_SYSTEM: str | None = os.getenv("GITWEB_CONFIG_SYSTEM", None)
-GITWEB_CONFIG_COMMON: str | None = os.getenv("GITWEB_CONFIG_COMMON", None)
-
-# Snapshot formats (from %known_snapshot_formats)
-KNOWN_SNAPSHOT_FORMATS: dict[str, dict[str, Any]] = {
-	"tgz": {
-		"display": "tar.gz",
-		"type": "application/x-gzip",
-		"suffix": ".tar.gz",
-		"format": "tar",
-		"compressor": ["gzip", "-n"],
-	},
-	"tbz2": {
-		"display": "tar.bz2",
-		"type": "application/x-bzip2",
-		"suffix": ".tar.bz2",
-		"format": "tar",
-		"compressor": ["bzip2"],
-	},
-	"txz": {
-		"display": "tar.xz",
-		"type": "application/x-xz",
-		"suffix": ".tar.xz",
-		"format": "tar",
-		"compressor": ["xz"],
-		"disabled": True,
-	},
-	"zip": {
-		"display": "zip",
-		"type": "application/zip",
-		"suffix": ".zip",
-		"format": "zip",
-	},
-}
-
-KNOWN_SNAPSHOT_FORMAT_ALIASES: dict[str, str | None] = {
-	"gzip": "tgz",
-	"bzip2": "tbz2",
-	"xz": "txz",
-	"x-gzip": None,
-	"gz": None,
-	"x-bzip2": None,
-	"bz2": None,
-	"x-zip": None,
-	"": None,
-}
-
-
-# Feature defaults; override in config or per-repo
-_feature_snapshot_default = ["tgz"]
-_snapshot_fmts: list[str] = []
-_extra_branch_refs: list[str] = []
-
-
-def read_config_file(filename: str | None) -> bool:
-	"""Load and execute a Python config file. Returns True on success. Port of read_config_file."""
-	if not filename or not Path(filename).exists():
-		return False
-	try:
-		with open(filename) as f:
-			code = compile(f.read(), filename, "exec")
-			glob = {
-				"PROJECTROOT": PROJECTROOT,
-				"PROJECTS_LIST": PROJECTS_LIST,
-				"SITE_NAME": SITE_NAME,
-				"EXPORT_OK": EXPORT_OK,
-				"LIST_ALL": LIST_ALL,
-				"STRICT_EXPORT": STRICT_EXPORT,
-				"GIT": GIT,
-				"MAXLOAD": MAXLOAD,
-				"KNOWN_SNAPSHOT_FORMATS": KNOWN_SNAPSHOT_FORMATS,
-				"os": os,
-				"Path": Path,
-			}
-			exec(code, glob)
-			for k in (
-				"PROJECTROOT",
-				"PROJECTS_LIST",
-				"SITE_NAME",
-				"EXPORT_OK",
-				"LIST_ALL",
-				"STRICT_EXPORT",
-				"GIT",
-				"MAXLOAD",
-				"KNOWN_SNAPSHOT_FORMATS",
-			):
-				if k in glob:
-					globals()[k] = glob[k]
-		return True
-	except Exception:
-		raise
-
-
-def evaluate_gitweb_config() -> None:
-	"""Resolve config paths and load common + instance/system config. Port of evaluate_gitweb_config."""
-	global GITWEB_CONFIG, GITWEB_CONFIG_SYSTEM, GITWEB_CONFIG_COMMON
-	if not GITWEB_CONFIG:
-		GITWEB_CONFIG = os.getenv("GITWEB_CONFIG", "")
-	if not GITWEB_CONFIG_SYSTEM:
-		GITWEB_CONFIG_SYSTEM = os.getenv("GITWEB_CONFIG_SYSTEM", "")
-	if not GITWEB_CONFIG_COMMON:
-		GITWEB_CONFIG_COMMON = os.getenv("GITWEB_CONFIG_COMMON", "")
+class Settings(BaseSettings):
+	"""
+	Runtime settings sourced from PYGITWEB_* env vars (and optionally a .env file).
 
-	if GITWEB_CONFIG == GITWEB_CONFIG_COMMON:
-		GITWEB_CONFIG = ""
-	if GITWEB_CONFIG_SYSTEM == GITWEB_CONFIG_COMMON:
-		GITWEB_CONFIG_SYSTEM = ""
+	The auth provider:
+	"None" or unset disables authentication. Otherwise "module.path.ClassName" is imported
+	lazily in main.py. Planned providers: Root, SSH, OAuth2, OIDC, Matrix, LDAP.
+	"""
 
-	if GITWEB_CONFIG_COMMON and Path(GITWEB_CONFIG_COMMON).exists():
-		read_config_file(GITWEB_CONFIG_COMMON)
-	if GITWEB_CONFIG and Path(GITWEB_CONFIG).exists():
-		read_config_file(GITWEB_CONFIG)
-		return
-	if GITWEB_CONFIG_SYSTEM and Path(GITWEB_CONFIG_SYSTEM).exists():
-		read_config_file(GITWEB_CONFIG_SYSTEM)
+	model_config = SettingsConfigDict(
+		env_prefix="PYGITWEB_",
+		env_file=".env",
+		env_file_encoding="utf-8",
+		case_sensitive=False,
+		validate_assignment=True,
+		extra="ignore",
+	)
+
+	PROJECTROOT: str = Field(default_factory=lambda: str(Path.home()))
+	PROJECTS_LIST: str = ""  # falls back to PROJECTROOT when blank
+	PROJECT_MAXDEPTH: int = 1
+	SITE_NAME: str = "PyGitWeb"
+	EXPORT_OK: str = ""
+	LIST_ALL: bool = True
+	STRICT_EXPORT: bool = False
+	GIT: str = "git"
+	MAXLOAD: float | None = None
+
+	AUTH: str | None = None
+	ADMIN_USER: str | None = None
+	ADMIN_PASSWORD: str | None = None
+	SESSION_TIMEOUT: int = 3600 * 24 * 7
+
+	@model_validator(mode="after")
+	def _defaults(self) -> Settings:
+		if not self.PROJECTS_LIST:
+			self.PROJECTS_LIST = self.PROJECTROOT
+		if self.AUTH == "None":
+			self.AUTH = None
+		return self
+
+
+settings = Settings()
 
 
 def get_loadavg() -> float:
@@ -251,93 +142,6 @@ def get_loadavg() -> float:
 
 
 def check_loadavg() -> None:
-	"""Raise 503 if load exceeds maxload. Port of check_loadavg."""
-	if MAXLOAD is not None and get_loadavg() > MAXLOAD:
+	"""Raise 503 if load exceeds MAXLOAD."""
+	if settings.MAXLOAD is not None and get_loadavg() > settings.MAXLOAD:
 		raise RuntimeError("503:The load average on the server is too high")
-
-
-def gitweb_get_feature(
-	name: str,
-	git_dir: str | None = None,
-	get_project_config: Any = None,
-) -> list[Any]:
-	"""Return feature value(s); project override when git_dir and get_project_config set. Port of gitweb_get_feature."""
-	if name == "snapshot":
-		defaults = _feature_snapshot_default
-		if git_dir and get_project_config:
-			val = get_project_config("snapshot") if callable(get_project_config) else None
-			if val:
-				defaults = (
-					[] if val.strip().lower() == "none" else [x.strip() for x in re.split(r"[\s,]+", val) if x.strip()]
-				)
-		return list(defaults)
-	if name == "avatar":
-		return ["gravatar"]  # default
-	if name == "extra-branch-refs":
-		if git_dir and get_project_config and callable(get_project_config):
-			val = get_project_config("extrabranchrefs")
-			if val:
-				parts = [val] if isinstance(val, str) else (val if isinstance(val, list) else [])
-				return [x for part in parts for x in str(part).split()]
-		return []
-	return []
-
-
-def gitweb_check_feature(name: str, git_dir: str | None = None, get_project_config: Any = None) -> bool | Any:
-	"""First value of gitweb_get_feature. Port of gitweb_check_feature."""
-	vals = gitweb_get_feature(name, git_dir, get_project_config)
-	return vals[0] if vals else False
-
-
-def filter_snapshot_fmts(fmts: list[str]) -> list[str]:
-	"""Resolve aliases and drop unknown/disabled. Port of filter_snapshot_fmts."""
-	result = []
-	for f in fmts:
-		key = KNOWN_SNAPSHOT_FORMAT_ALIASES.get(f, f)
-		if key is None:
-			continue
-		if key not in KNOWN_SNAPSHOT_FORMATS:
-			continue
-		opt = KNOWN_SNAPSHOT_FORMATS[key]
-		if opt.get("disabled"):
-			continue
-		result.append(key)
-	return result
-
-
-def filter_and_validate_refs(refs: list[str], is_valid_ref_format: Any) -> list[str]:
-	"""Validate ref names and unique sort; 'heads' omitted (added in get_branch_refs).
-
-	Port of filter_and_validate_refs.
-	"""
-	seen: set[str] = set()
-	for ref in refs:
-		if not is_valid_ref_format(ref):
-			raise ValueError(f"Invalid ref '{ref}' in 'extra-branch-refs' feature")
-		if ref != "heads":
-			seen.add(ref)
-	return sorted(seen)
-
-
-def configure_gitweb_features(
-	get_project_config: Any = None,
-	git_dir: str | None = None,
-	is_valid_ref_format: Any = None,
-) -> None:
-	"""Set snapshot_fmts and extra_branch_refs. Port of configure_gitweb_features."""
-	global _snapshot_fmts, _extra_branch_refs
-	_snapshot_fmts = filter_snapshot_fmts(gitweb_get_feature("snapshot", git_dir, get_project_config))
-	avatar = gitweb_get_feature("avatar", git_dir, get_project_config)
-	if avatar and avatar[0] not in ("gravatar", "picon"):
-		avatar = [""]
-	raw = gitweb_get_feature("extra-branch-refs", git_dir, get_project_config)
-	_extra_branch_refs = filter_and_validate_refs(raw, is_valid_ref_format) if is_valid_ref_format else []
-
-
-def get_branch_refs() -> list[str]:
-	"""Return ['heads', ...extra_branch_refs]. Port of get_branch_refs."""
-	return ["heads"] + _extra_branch_refs
-
-
-def get_snapshot_fmts() -> list[str]:
-	return _snapshot_fmts
diff --git a/pygitweb/git_helpers.py b/pygitweb/git_helpers.py
index c215cf2..66ab298 100644
--- a/pygitweb/git_helpers.py
+++ b/pygitweb/git_helpers.py
@@ -16,8 +16,7 @@ from typing import Any
 
 import pygit2
 
-# From config
-from pygitweb.config import PROJECTROOT
+from pygitweb.config import settings
 from pygitweb.formatting import to_utf8
 
 # Common README filenames to look for (order matters: prefer README.md)
@@ -25,7 +24,7 @@ README_CANDIDATES = ["README.md", "README", "readme.md", "readme", "Readme.md"]
 
 
 def _repo_path(project: str) -> str:
-	return os.path.join(PROJECTROOT, project)
+	return os.path.join(settings.PROJECTROOT, project)
 
 
 def open_repo(project: str):
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 6b64818..e079406 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -19,7 +19,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFi
 from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
 from fastapi.staticfiles import StaticFiles
 
-from pygitweb import __meta__, config
+from pygitweb import __meta__
 from pygitweb.actions import (
 	git_blob,
 	git_blobdiff,
@@ -40,25 +40,9 @@ from pygitweb.actions import (
 	git_tree,
 	parse_pagination,
 )
-from pygitweb.config import (
-	ACTIONS,
-	EXPORT_OK,
-	PROJECTROOT,
-	PYGITWEB_ADMIN_PASSWORD,
-	PYGITWEB_ADMIN_USER,
-	PYGITWEB_AUTH,
-	PYGITWEB_SESSION_TIMEOUT,
-	STRICT_EXPORT,
-	check_loadavg,
-	configure_gitweb_features,
-	evaluate_gitweb_config,
-)
+from pygitweb.config import ACTIONS, check_loadavg, settings
 from pygitweb.formatting import esc_html
-from pygitweb.git_helpers import (
-	git_get_project_config,
-	git_get_references,
-	git_get_type,
-)
+from pygitweb.git_helpers import git_get_references, git_get_type
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.settings import router as settings_router
 from pygitweb.tasks import (
@@ -69,18 +53,13 @@ from pygitweb.tasks import (
 	task_router,
 )
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
-from pygitweb.validation import (
-	is_valid_action,
-	is_valid_pathname,
-	is_valid_project,
-	is_valid_ref_format,
-)
+from pygitweb.validation import is_valid_action, is_valid_pathname, is_valid_project
 
 with open("pygitweb/README.md", encoding="utf-8") as _readme_file:
 	_app_description = _readme_file.read()
 
 app = FastAPI(
-	debug=(PYGITWEB_AUTH is None),
+	debug=(settings.AUTH is None),
 	title="PyGitWeb",
 	summary="FastAPI + Pygit2 Repo Browser",
 	description=_app_description,
@@ -96,8 +75,8 @@ if _static_dir.is_dir():
 def _project_in_list(project: str) -> bool:
 	lst = git_get_projects_list(
 		filter_path="",
-		paranoid=STRICT_EXPORT,
-		export_ok=EXPORT_OK,
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
 	)
 	return any(p.get("path") == project for p in lst)
 
@@ -106,24 +85,22 @@ _auth_provider = None
 
 
 def _get_auth_provider():
-	"""Return auth provider instance if PYGITWEB_AUTH is set; None if auth disabled."""
+	"""Return auth provider instance if settings.AUTH is set; None if auth disabled."""
 	global _auth_provider
-	if not PYGITWEB_AUTH:
+	if not settings.AUTH:
 		return None
 	if _auth_provider is not None:
 		return _auth_provider
 	try:
-		mod_name, _, cls_name = PYGITWEB_AUTH.rpartition(".")
+		mod_name, _, cls_name = settings.AUTH.rpartition(".")
 		mod = __import__(mod_name, fromlist=[cls_name])
 		cls = getattr(mod, cls_name)
-		# RootAuthProvider accepts admin_user, admin_password, session_timeout from env
-		admin_user = PYGITWEB_ADMIN_USER.encode("utf-8") or None
-		admin_password = PYGITWEB_ADMIN_PASSWORD.encode("utf-8") or None
-		timeout = PYGITWEB_SESSION_TIMEOUT
+		admin_user = settings.ADMIN_USER.encode("utf-8") if settings.ADMIN_USER else None
+		admin_password = settings.ADMIN_PASSWORD.encode("utf-8") if settings.ADMIN_PASSWORD else None
 		_auth_provider = cls(
 			admin_user=admin_user,
 			admin_password=admin_password,
-			session_timeout=timeout,
+			session_timeout=settings.SESSION_TIMEOUT,
 		)
 	except Exception:
 		_auth_provider = None
@@ -132,7 +109,7 @@ def _get_auth_provider():
 
 def _request_can_add_project(request: Request) -> bool:
 	"""True if auth is disabled or request has a valid session (header X-Session-Token or param/cookie session)."""
-	if not PYGITWEB_AUTH:
+	if not settings.AUTH:
 		return True
 	provider = _get_auth_provider()
 	if provider is None:
@@ -151,16 +128,6 @@ app.include_router(task_router, prefix="/tasks")
 app.include_router(comment_router, prefix="/comments")
 
 
-@app.on_event("startup")
-def startup():
-	evaluate_gitweb_config()
-	configure_gitweb_features(
-		get_project_config=git_get_project_config,
-		git_dir=None,
-		is_valid_ref_format=is_valid_ref_format,
-	)
-
-
 @app.middleware("http")
 async def loadavg_middleware(request: Request, call_next):
 	try:
@@ -178,9 +145,9 @@ def _validate_project(project: str | None) -> str:
 		raise HTTPException(status_code=400, detail="Project needed")
 	if not is_valid_project(
 		project,
-		PROJECTROOT,
-		EXPORT_OK,
-		STRICT_EXPORT,
+		settings.PROJECTROOT,
+		settings.EXPORT_OK,
+		settings.STRICT_EXPORT,
 		_project_in_list,
 	):
 		raise HTTPException(status_code=404, detail="No such project")
@@ -205,8 +172,8 @@ def git_project_list(
 	project_filter = pf or ""
 	list_ = git_get_projects_list(
 		filter_path=project_filter,
-		paranoid=STRICT_EXPORT,
-		export_ok=EXPORT_OK,
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
 	)
 	if not list_:
 		raise HTTPException(status_code=404, detail="No projects found")
@@ -221,7 +188,7 @@ def git_project_list(
 			has_boards = False
 		if has_boards:
 			return f'<a href="/project/{path_enc}/board/">project board</a>'
-		grey_style = ' style="color: #999; cursor: not-allowed;"' if PYGITWEB_AUTH else ""
+		grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
 		proj_q = quote(path, safe="")
 		return f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'
 
@@ -237,7 +204,7 @@ def git_project_list(
 			for pr in list_[:50]
 		],
 	)
-	pre = PREAMBLE.render(title=f"{esc_html(config.SITE_NAME)} - Projects", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{esc_html(settings.SITE_NAME)} - Projects", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}<h1>Project List</h1>{table}{POSTAMBLE}")
 
 
@@ -250,8 +217,8 @@ def git_project_index(
 
 	projects = git_get_projects_list(
 		filter_path=pf or "",
-		paranoid=STRICT_EXPORT,
-		export_ok=EXPORT_OK,
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
 	)
 	if not projects:
 		raise HTTPException(status_code=404, detail="No projects found")
@@ -268,7 +235,7 @@ def git_project_index(
 @app.get("/opml", response_class=PlainTextResponse)
 def git_opml():
 	"""OPML feed list. Port of git_opml (stub)."""
-	projects = git_get_projects_list(export_ok=EXPORT_OK)
+	projects = git_get_projects_list(export_ok=settings.EXPORT_OK)
 	# Minimal OPML
 	lines = ['<?xml version="1.0"?><opml version="1.0"><head><title>Git</title></head><body>']
 	for pr in projects[:100]:
@@ -284,7 +251,7 @@ def board_create_page(
 	project: Annotated[str | None, Query(alias="p")] = None,
 ) -> RedirectResponse:
 	"""Create a board named 'Tasks' (refs/tags/boards/Tasks) and redirect to the project summary."""
-	if PYGITWEB_AUTH:
+	if settings.AUTH:
 		raise HTTPException(status_code=401, detail="Authentication required")
 	p = project or request.query_params.get("project")
 	if not p:
@@ -323,11 +290,11 @@ def addproject_namevalid(request: Request):
 def addproject_page(request: Request):
 	"""Add project form page."""
 	pre = PREAMBLE.render(
-		title=f"{config.SITE_NAME} - Add Project",
-		site_name=config.SITE_NAME,
+		title=f"{settings.SITE_NAME} - Add Project",
+		site_name=settings.SITE_NAME,
 	)
 	tpl = env.get_template("addproject.html")
-	body = tpl.render(site_name=config.SITE_NAME)
+	body = tpl.render(site_name=settings.SITE_NAME)
 	return HTMLResponse(status_code=200, content=f"{pre}{body}{POSTAMBLE}")
 
 
@@ -359,8 +326,8 @@ async def addproject_submit(
 	do_maintenance = perform_maintenance.lower() in ("on", "1", "true", "yes")
 	do_task_board = create_task_board.lower() in ("on", "1", "true", "yes")
 
-	dest_path = os.path.join(PROJECTROOT, project_name)
-	os.makedirs(PROJECTROOT, exist_ok=True)
+	dest_path = os.path.join(settings.PROJECTROOT, project_name)
+	os.makedirs(settings.PROJECTROOT, exist_ok=True)
 
 	try:
 		if remote_url and remote_url != "":
@@ -408,7 +375,7 @@ async def addproject_submit(
 		if do_maintenance:
 			with suppress(subprocess.SubprocessError, FileNotFoundError):
 				subprocess.run(
-					[config.GIT, "-C", dest_path, "maintenance", "start"],
+					[settings.GIT, "-C", dest_path, "maintenance", "start"],
 					capture_output=True,
 					timeout=60,
 				)
@@ -444,7 +411,7 @@ def project_board(
 			t["task_url"] = f"{board_url}?task={quote(t['ref'], safe='')}"
 	pre = PREAMBLE.render(
 		title=f"{esc_html(project)} - Board",
-		site_name=config.SITE_NAME,
+		site_name=settings.SITE_NAME,
 	)
 	body = env.get_template("board.html").render(
 		project=project,
@@ -542,7 +509,7 @@ def dispatch(
 	if action == "object":
 		return git_object(project, hash_param)
 	# Stub others with minimal response
-	pre = PREAMBLE.render(title=f"{esc_html(action)} - {esc_html(project)}", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{esc_html(action)} - {esc_html(project)}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}<p>Action: {esc_html(action)}</p><p>Project: {esc_html(project)}</p>{POSTAMBLE}")
 
 
diff --git a/pygitweb/projects.py b/pygitweb/projects.py
index 91c9019..0035def 100644
--- a/pygitweb/projects.py
+++ b/pygitweb/projects.py
@@ -13,7 +13,7 @@ from typing import Any
 
 import pygit2
 
-from pygitweb.config import LIST_ALL, PROJECT_MAXDEPTH, PROJECTROOT, PROJECTS_LIST
+from pygitweb.config import settings
 from pygitweb.validation import check_export_ok
 
 
@@ -58,9 +58,9 @@ def _find_projects_in_dir(
 			repo = pygit2.discover_repository(path)
 			if not repo:
 				continue
-			project_path = os.path.relpath(path, PROJECTROOT)
+			project_path = os.path.relpath(path, settings.PROJECTROOT)
 			project_path = project_path.replace("\\", "/")
-			git_dir = os.path.join(PROJECTROOT, project_path)
+			git_dir = os.path.join(settings.PROJECTROOT, project_path)
 			if not skip_export_check and not check_export_ok(git_dir, export_ok, export_auth_hook):
 				continue
 			result.append({"path": project_path})
@@ -71,13 +71,16 @@ def _find_projects_in_dir(
 def git_get_projects_list(
 	filter_path: str = "",
 	paranoid: bool = False,
-	projectroot: str = PROJECTROOT,
-	projects_list: str = PROJECTS_LIST,
-	project_maxdepth: int = PROJECT_MAXDEPTH,
+	projectroot: str | None = None,
+	projects_list: str | None = None,
+	project_maxdepth: int | None = None,
 	export_ok: str = "",
 	export_auth_hook: Callable[[str], bool] | None = None,
 ) -> list[dict[str, Any]]:
 	"""List projects from directory scan or file. Port of git_get_projects_list."""
+	projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
+	projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
+	project_maxdepth = project_maxdepth if project_maxdepth is not None else settings.PROJECT_MAXDEPTH
 	if os.path.isdir(projects_list):
 		root = projects_list.rstrip("/")
 		prefix_len = len(root) + 1
@@ -91,7 +94,7 @@ def git_get_projects_list(
 			project_maxdepth,
 			export_ok,
 			export_auth_hook,
-			skip_export_check=LIST_ALL,
+			skip_export_check=settings.LIST_ALL,
 		)
 		if filter_path and paranoid:
 			result = [p for p in result if p["path"].startswith(filter_path + "/")]
@@ -113,7 +116,7 @@ def git_get_projects_list(
 				if filter_path and not path.startswith(filter_path + "/"):
 					continue
 				git_dir = os.path.join(projectroot, path)
-				if not LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
+				if not settings.LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
 					continue
 				pr = {"path": path}
 				if owner:
@@ -127,13 +130,14 @@ _gitweb_project_owner: dict[str, str] | None = None
 
 
 def git_get_project_list_from_file(
-	projects_list: str = PROJECTS_LIST,
-	projectroot: str = PROJECTROOT,
+	projects_list: str | None = None,
+	projectroot: str | None = None,
 ) -> dict[str, str]:
 	"""Load project -> owner from file. Port of git_get_project_list_from_file."""
 	global _gitweb_project_owner
 	if _gitweb_project_owner is not None:
 		return _gitweb_project_owner
+	projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
 	_gitweb_project_owner = {}
 	if os.path.isfile(projects_list):
 		from urllib.parse import unquote
@@ -153,13 +157,14 @@ def git_get_project_list_from_file(
 
 def git_get_project_owner(
 	project: str,
-	projectroot: str = PROJECTROOT,
+	projectroot: str | None = None,
 	get_project_config: Callable[[str, str], Any] | None = None,
 ) -> str | None:
 	"""Owner from list file or config or file ownership. Port of git_get_project_owner."""
 	if not project:
 		return None
-	owners = git_get_project_list_from_file(PROJECTS_LIST, projectroot)
+	projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
+	owners = git_get_project_list_from_file(settings.PROJECTS_LIST, projectroot)
 	if project in owners and owners[project]:
 		return owners[project]
 	if get_project_config:
@@ -169,7 +174,7 @@ def git_get_project_owner(
 	return None
 
 
-def git_get_last_activity(project: str, projectroot: str = PROJECTROOT) -> int | None:
+def git_get_last_activity(project: str, projectroot: str | None = None) -> int | None:
 	"""Last commit timestamp for project. Port of git_get_last_activity."""
 	from pygitweb.git_helpers import git_get_head_hash, parse_commit
 
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index d585e1c..cf30b5c 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -16,6 +16,7 @@ dependencies = [
     "jinja2>=3.1.0",
     "orjson>=0.19.0",
     "pygit2>=1.12.0",
+    "pydantic-settings>=2.13.0",
 ]
 
 [tool.setuptools]
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index f9536e4..a6b88a5 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -13,12 +13,7 @@ import pygit2
 from fastapi import APIRouter, HTTPException, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
-from pygitweb import config
-from pygitweb.config import (
-	EXPORT_OK,
-	PROJECTROOT,
-	STRICT_EXPORT,
-)
+from pygitweb.config import settings
 from pygitweb.formatting import esc_html
 from pygitweb.git_helpers import open_repo
 from pygitweb.projects import git_get_projects_list
@@ -39,53 +34,22 @@ router = APIRouter(prefix="/settings", tags=["settings"])
 
 
 # ---------- PyGitWeb settings schema ----------
-# Name, type, docstring for form generation. Must match config module attribute names.
-PYGITWEB_SETTINGS = [
+# Name, type, docstring for form generation. Must match Settings field names.
+PYGITWEB_SETTINGS: list[tuple[str, type, str]] = [
 	("PROJECTROOT", str, "Root filesystem path under which git repositories live."),
-	(
-		"PROJECTS_LIST",
-		str,
-		"Path used for listing projects (often same as PROJECTROOT).",
-	),
+	("PROJECTS_LIST", str, "Path used for listing projects (often same as PROJECTROOT)."),
 	("SITE_NAME", str, "Site name shown in the web interface."),
-	(
-		"EXPORT_OK",
-		str,
-		"If set, only repos with this file (or matching path) are listed.",
-	),
-	(
-		"LIST_ALL",
-		bool,
-		"When true, list all directories under project root without export_ok checks.",
-	),
-	(
-		"STRICT_EXPORT",
-		bool,
-		"When true, require export_ok (or path match) to list projects.",
-	),
+	("EXPORT_OK", str, "If set, only repos with this file (or matching path) are listed."),
+	("LIST_ALL", bool, "When true, list all directories under project root without export_ok checks."),
+	("STRICT_EXPORT", bool, "When true, require export_ok (or path match) to list projects."),
 	("GIT", str, "Path to the git executable (e.g. for maintenance)."),
-	(
-		"MAXLOAD",
-		(float, type(None)),
-		"Max load average; 503 when exceeded. None to disable.",
-	),
+	("MAXLOAD", float, "Max load average; 503 when exceeded. Empty to disable."),
 ]
 
 
 def _get_pygitweb_values() -> list[tuple[str, Any, type, str]]:
 	"""Return (name, current_value, type, docstring) for each PyGitWeb setting."""
-	out = []
-	for name, expected_type, docstring in PYGITWEB_SETTINGS:
-		if not hasattr(config, name):
-			continue
-		val = getattr(config, name)
-		out.append((
-			name,
-			val,
-			expected_type if isinstance(expected_type, type) else type(val),
-			docstring,
-		))
-	return out
+	return [(name, getattr(settings, name), expected_type, doc) for name, expected_type, doc in PYGITWEB_SETTINGS]
 
 
 def _render_form_field(name: str, value: Any, value_type: type, docstring: str, name_prefix: str = "") -> str:
@@ -326,7 +290,7 @@ def settings_pygitweb_page(request: Request):
 	for name, val, value_type, doc in _get_pygitweb_values():
 		fields_html.append(_render_form_field(name, val, value_type, doc))
 	form_body = "\n".join(fields_html)
-	pre = PREAMBLE.render(title=f"{config.SITE_NAME} - PyGitWeb settings", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - PyGitWeb settings", site_name=settings.SITE_NAME)
 	body = env.get_template("pygitweb_settings.html").render(form_body=form_body)
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
@@ -337,23 +301,18 @@ async def settings_pygitweb_submit(request: Request):
 	form = await request.form()
 
 	def _get(k: str) -> str:
-		return str(form.get(k) or "").strip() or ""
-
-	config.PROJECTROOT = _get("PROJECTROOT") or config.PROJECTROOT
-	config.PROJECTS_LIST = _get("PROJECTS_LIST") or config.PROJECTS_LIST
-	config.SITE_NAME = _get("SITE_NAME") or config.SITE_NAME
-	config.EXPORT_OK = _get("EXPORT_OK")
-	config.LIST_ALL = _get("LIST_ALL").lower() in ("on", "1", "true", "yes")
-	config.STRICT_EXPORT = _get("STRICT_EXPORT").lower() in ("1", "true", "yes")
-	config.GIT = _get("GIT") or config.GIT
-	maxload_s = _get("MAXLOAD")
-	if not maxload_s:
-		config.MAXLOAD = None
-	else:
-		try:
-			config.MAXLOAD = float(maxload_s)
-		except ValueError:
-			config.MAXLOAD = None
+		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)
 	return RedirectResponse(url="/settings/pygitweb", status_code=303)
 
 
@@ -365,7 +324,7 @@ def settings_pygit2_page(request: Request):
 	for name, val, kind, doc in _get_pygit2_values():
 		fields_html.append(_render_pygit2_field(name, val, kind, doc))
 	form_body = "\n".join(fields_html)
-	pre = PREAMBLE.render(title=f"{config.SITE_NAME} - PyGit2 settings", site_name=config.SITE_NAME)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - PyGit2 settings", site_name=settings.SITE_NAME)
 	body = env.get_template("pygit2_settings.html").render(form_body=form_body)
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
@@ -404,8 +363,8 @@ def _project_in_list(project: str) -> bool:
 	"""True if project is in the visible project list."""
 	lst = git_get_projects_list(
 		filter_path="",
-		paranoid=STRICT_EXPORT,
-		export_ok=EXPORT_OK,
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
 	)
 	return any(p.get("path") == project for p in lst)
 
@@ -416,9 +375,9 @@ def _validate_project(project: str | None) -> str:
 		raise HTTPException(status_code=400, detail="Project needed")
 	if not is_valid_project(
 		project,
-		PROJECTROOT,
-		EXPORT_OK,
-		STRICT_EXPORT,
+		settings.PROJECTROOT,
+		settings.EXPORT_OK,
+		settings.STRICT_EXPORT,
 		_project_in_list,
 	):
 		raise HTTPException(status_code=404, detail="No such project")
@@ -435,8 +394,8 @@ def settings_project_page(request: Request, name: str):
 	rows.append(_project_config_table_row("", "", len(entries)))
 	table_html = env.get_template("table.html").render(cols=["Key", "Value"], rows=rows)
 	pre = PREAMBLE.render(
-		title=f"{config.SITE_NAME} - Project settings: {esc_html(project)}",
-		site_name=config.SITE_NAME,
+		title=f"{settings.SITE_NAME} - Project settings: {esc_html(project)}",
+		site_name=settings.SITE_NAME,
 	)
 	submit_url = f"/settings/project/{_quote_path(project)}/submit"
 	cancel_url = f"/project/{_quote_path(project)}"
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index a854baf..7962eee 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -23,7 +23,7 @@ from pygittools.tasks import (
 	get_task,
 	get_task_by_oid,
 )
-from pygitweb.config import EXPORT_OK, PROJECTROOT, PYGITWEB_AUTH, STRICT_EXPORT
+from pygitweb.config import settings
 from pygitweb.git_helpers import git_get_references, open_repo
 from pygitweb.projects import git_get_projects_list
 from pygitweb.validation import is_valid_project
@@ -34,15 +34,15 @@ EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
 def _require_auth_disabled() -> None:
 	"""Raise 401 if authentication is enabled (we will add real auth later)."""
-	if PYGITWEB_AUTH:
+	if settings.AUTH:
 		raise HTTPException(status_code=401, detail="Authentication required")
 
 
 def _project_in_list(project: str) -> bool:
 	lst = git_get_projects_list(
 		filter_path="",
-		paranoid=STRICT_EXPORT,
-		export_ok=EXPORT_OK,
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
 	)
 	return any(p.get("path") == project for p in lst)
 
@@ -52,9 +52,9 @@ def _validate_project(project: str | None) -> str:
 		raise HTTPException(status_code=400, detail="Project needed")
 	if not is_valid_project(
 		project,
-		PROJECTROOT,
-		EXPORT_OK,
-		STRICT_EXPORT,
+		settings.PROJECTROOT,
+		settings.EXPORT_OK,
+		settings.STRICT_EXPORT,
 		_project_in_list,
 	):
 		raise HTTPException(status_code=404, detail="No such project")
