diff --git a/pygitweb/main.py b/pygitweb/main.py
index 49d76a3..fa17303 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -56,6 +56,7 @@ from pygitweb.actions import (
     git_tree,
     parse_pagination,
 )
+from pygitweb.settings import router as settings_router
 
 app = FastAPI(
     debug=(DISTGIT_AUTH == "None"),
@@ -125,6 +126,9 @@ def _request_can_add_project(request: Request) -> bool:
     return getattr(provider, "validate_session", lambda _: False)(token)
 
 
+app.include_router(settings_router)
+
+
 @app.on_event("startup")
 def startup():
     evaluate_gitweb_config()
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
new file mode 100644
index 0000000..72f8ebe
--- /dev/null
+++ b/pygitweb/settings.py
@@ -0,0 +1,337 @@
+"""
+Settings routes: PyGitWeb global options, PyGit2 library settings, and per-project git config.
+Forms are generated from type() and docstrings; project config shows only LOCAL/WORKTREE entries.
+"""
+from __future__ import annotations
+
+import os
+from typing import Any
+from urllib.parse import quote
+
+import pygit2
+from fastapi import APIRouter, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from pygitweb import config
+from pygitweb.config import (
+    EXPORT_OK,
+    GIT,
+    LIST_ALL,
+    MAXLOAD,
+    PROJECTROOT,
+    PROJECTS_LIST,
+    SITE_NAME,
+    STRICT_EXPORT,
+)
+from pygitweb.formatting import esc_html
+from pygitweb.git_helpers import open_repo
+from pygitweb.projects import git_get_projects_list
+from pygitweb.validation import is_valid_project
+
+
+def _quote_path(path: str) -> str:
+    """Quote path segment for URL (e.g. project name with slashes)."""
+    return quote(path, safe="/")
+from pygitweb.templates_env import PREAMBLE, POSTAMBLE, env
+
+# libgit2 config levels: only local (5) and worktree (6) for per-repo editing
+GIT_CONFIG_LEVEL_LOCAL = 5
+GIT_CONFIG_LEVEL_WORKTREE = 6
+
+router = APIRouter(prefix="/settings", tags=["settings"])
+
+
+# ---------- PyGitWeb settings schema ----------
+# Name, type, docstring for form generation. Must match config module attribute names.
+PYGITWEB_SETTINGS = [
+    ("PROJECTROOT", str, "Root filesystem path under which git repositories live."),
+    ("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."),
+    ("GIT", str, "Path to the git executable (e.g. for maintenance)."),
+    ("MAXLOAD", (float, type(None)), "Max load average; 503 when exceeded. None 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
+
+
+def _render_form_field(name: str, value: Any, value_type: type, docstring: str, name_prefix: str = "") -> str:
+    """Generate HTML for a single form field based on type."""
+    field_name = f"{name_prefix}{name}" if name_prefix else name
+    safe_name = field_name.replace(".", "_")
+    hint = f'<small class="form-hint text-muted">{esc_html(docstring)}</small>'
+    if value_type is bool:
+        checked = " checked" if value else ""
+        return f'<div class="mb-3"><div class="form-check"><input type="checkbox" class="form-check-input" id="{safe_name}" name="{field_name}" value="on"{checked}><label class="form-check-label" for="{safe_name}">{name}</label></div>{hint}</div>'
+    if value_type in (int, float):
+        str_val = "" if value is None else str(value)
+        return f'<div class="mb-3"><label class="form-label" for="{safe_name}">{name}</label><input type="number" step="any" class="form-control" id="{safe_name}" name="{field_name}" value="{esc_html(str_val)}">{hint}</div>'
+    # str or None
+    str_val = "" if value is None else str(value)
+    return f'<div class="mb-3"><label class="form-label" for="{safe_name}">{name}</label><input type="text" class="form-control" id="{safe_name}" name="{field_name}" value="{esc_html(str_val)}">{hint}</div>'
+
+
+# ---------- PyGit2 Settings schema ----------
+# List of (attr_name, docstring, value_kind: 'bool'|'int'|'str'|'readonly').
+# Based on https://www.pygit2.org/settings.html
+PYGIT2_SETTINGS = [
+    ("cache_max_size", "Maximum total data size (bytes) cached in memory across all repositories. Default 256MB.", "int"),
+    ("cache_object_limit", "Max size per object type for caching (use cache_object_limit_commit etc. for fine-grained).", "skip"),  # multi-param
+    ("cached_memory", "Current bytes in cache and maximum allowed (read-only).", "readonly"),
+    ("disable_pack_keep_file_checks", "Skip .keep file checks when accessing packfiles; can help on remote filesystems.", "bool"),
+    ("enable_caching", "Enable or disable caching completely.", "bool"),
+    ("enable_fsync_gitdir", "Enable or disable fsync for git directory operations.", "bool"),
+    ("enable_http_expect_continue", "Enable or disable HTTP Expect/Continue for large pushes.", "bool"),
+    ("enable_ofs_delta", "Enable or disable offset delta encoding.", "bool"),
+    ("enable_strict_hash_verification", "Enable or disable strict hash verification.", "bool"),
+    ("enable_strict_object_creation", "Enable or disable strict object creation validation.", "bool"),
+    ("enable_strict_symbolic_ref_creation", "Enable or disable strict symbolic reference creation validation.", "bool"),
+    ("enable_unsaved_index_safety", "Enable or disable unsaved index safety checks.", "bool"),
+    ("extensions", "List of enabled extensions (read-only).", "readonly"),
+    ("homedir", "Home directory for config lookup.", "str"),
+    ("mwindow_file_limit", "Maximum number of files to be mapped at any time.", "int"),
+    ("mwindow_mapped_limit", "Maximum memory that will be mapped in total by the library.", "int"),
+    ("mwindow_size", "Maximum mmap window size.", "int"),
+    ("owner_validation", "Validate that repository directories are owned by the current user.", "bool"),
+    ("pack_max_objects", "Maximum number of objects in a pack.", "int"),
+    ("search_path", "Configuration file search path (read-only).", "readonly"),
+    ("server_connect_timeout", "Server connection timeout in milliseconds.", "int"),
+    ("server_timeout", "Server timeout in milliseconds.", "int"),
+    ("ssl_cert_dir", "TLS certificates lookup directory path.", "str"),
+    ("ssl_cert_file", "TLS certificate file path.", "str"),
+    ("template_path", "Default template path for new repositories.", "str"),
+    ("user_agent", "User agent string for network operations.", "str"),
+    ("user_agent_product", "User agent product name.", "str"),
+    ("windows_sharemode", "Windows share mode for opening files.", "int"),
+]
+
+
+def _get_pygit2_values() -> list[tuple[str, Any, str, str]]:
+    """Return (name, value, kind, docstring) for each PyGit2 setting we can show."""
+    st = pygit2.Settings
+    instance = pygit2.Settings()  # need instance to read property values
+    out = []
+    for attr, docstring, kind in PYGIT2_SETTINGS:
+        if kind == "skip":
+            continue
+        if not hasattr(st, attr):
+            continue
+        try:
+            prop = getattr(st, attr)
+            if callable(prop) and not isinstance(prop, property):
+                continue
+            val = getattr(instance, attr)
+            if isinstance(val, (list, tuple)):
+                val = ", ".join(str(x) for x in val) if val else ""
+            elif val is None:
+                val = ""
+            out.append((attr, val, kind, docstring))
+        except (TypeError, AttributeError):
+            continue
+    return out
+
+
+def _render_pygit2_field(name: str, value: Any, kind: str, docstring: str, name_prefix: str = "pygit2_") -> str:
+    """Generate HTML for a PyGit2 form field."""
+    field_name = f"{name_prefix}{name}"
+    safe_name = field_name.replace(".", "_")
+    hint = f'<small class="form-hint text-muted">{esc_html(docstring)}</small>'
+    if kind == "readonly":
+        str_val = str(value) if value != "" else "(not set)"
+        return f'<div class="mb-3"><label class="form-label" for="{safe_name}">{name}</label><input type="text" class="form-control bg-secondary" id="{safe_name}" name="{field_name}" value="{esc_html(str_val)}" readonly>{hint}</div>'
+    if kind == "bool":
+        checked = " checked" if (value is True or (isinstance(value, str) and value.lower() in ("true", "1", "on", "yes"))) else ""
+        return f'<div class="mb-3"><div class="form-check"><input type="checkbox" class="form-check-input" id="{safe_name}" name="{field_name}" value="on"{checked}><label class="form-check-label" for="{safe_name}">{name}</label></div>{hint}</div>'
+    if kind == "int":
+        str_val = str(value) if value != "" and value is not None else ""
+        return f'<div class="mb-3"><label class="form-label" for="{safe_name}">{name}</label><input type="number" class="form-control" id="{safe_name}" name="{field_name}" value="{esc_html(str_val)}">{hint}</div>'
+    str_val = str(value) if value is not None else ""
+    return f'<div class="mb-3"><label class="form-label" for="{safe_name}">{name}</label><input type="text" class="form-control" id="{safe_name}" name="{field_name}" value="{esc_html(str_val)}">{hint}</div>'
+
+
+# ---------- Project config (local/worktree only) ----------
+def _get_project_config_entries(project: str) -> list[tuple[str, str]]:
+    """
+    Return list of (name, value) for repo config, only from LOCAL (5) or WORKTREE (6).
+    For each name we keep the last value (highest priority when iterating).
+    """
+    repo = open_repo(project)
+    cfg = repo.config
+    # Iterate; for level in (5, 6) keep last value per name
+    by_name: dict[str, str] = {}
+    for entry in cfg:
+        if entry.level not in (GIT_CONFIG_LEVEL_LOCAL, GIT_CONFIG_LEVEL_WORKTREE):
+            continue
+        by_name[entry.name] = entry.value
+    return [(k, v) for k, v in sorted(by_name.items())]
+
+
+def _project_config_table_row(key: str, value: str, index: int) -> list[str]:
+    """One table row as [key_cell_html, value_cell_html] for project config form."""
+    key_name = f"config_key_{index}"
+    val_name = f"config_value_{index}"
+    key_cell = f'<input type="text" class="form-control form-control-sm" id="{key_name}" name="{key_name}" value="{esc_html(key)}" placeholder="e.g. user.name">'
+    val_cell = f'<input type="text" class="form-control form-control-sm" id="{val_name}" name="{val_name}" value="{esc_html(value)}" placeholder="value">'
+    return [key_cell, val_cell]
+
+
+# ---------- Routes: PyGitWeb ----------
+@router.get("/pygitweb", response_class=HTMLResponse)
+def settings_pygitweb_page(request: Request):
+    """PyGitWeb global settings form."""
+    fields_html = []
+    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)
+    body = f'<h1 class="page-title">PyGitWeb settings</h1><div class="card"><div class="card-body"><form action="/settings/pygitweb/submit" method="post" class="needs-validation" novalidate>{form_body}<div class="form-footer"><button type="submit" class="btn btn-primary">Save</button><a href="/" class="btn btn-ghost-secondary">Cancel</a></div></form></div></div>'
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@router.post("/pygitweb/submit", response_class=HTMLResponse)
+async def settings_pygitweb_submit(request: Request):
+    """Apply PyGitWeb settings (in-memory for current process)."""
+    form = await request.form()
+    def _get(k: str) -> str:
+        return (form.get(k) or "").strip()
+
+    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 RedirectResponse(url="/settings/pygitweb", status_code=303)
+
+
+# ---------- Routes: PyGit2 ----------
+@router.get("/pygit2", response_class=HTMLResponse)
+def settings_pygit2_page(request: Request):
+    """PyGit2 library settings form."""
+    fields_html = []
+    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)
+    body = f'<h1 class="page-title">PyGit2 settings</h1><div class="card"><div class="card-body"><form action="/settings/pygit2/submit" method="post" class="needs-validation" novalidate>{form_body}<div class="form-footer"><button type="submit" class="btn btn-primary">Save</button><a href="/" class="btn btn-ghost-secondary">Cancel</a></div></form></div></div>'
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@router.post("/pygit2/submit", response_class=HTMLResponse)
+async def settings_pygit2_submit(request: Request):
+    """Apply PyGit2 settings from form (all pygit2_* fields)."""
+    form = await request.form()
+    st = pygit2.Settings
+    for key, form_value in form.items():
+        if not key.startswith("pygit2_"):
+            continue
+        attr = key[7:]  # strip pygit2_
+        if attr not in [x[0] for x in PYGIT2_SETTINGS]:
+            continue
+        kind = next((k[2] for k in PYGIT2_SETTINGS if k[0] == attr), "str")
+        if kind in ("readonly", "skip"):
+            continue
+        try:
+            if kind == "bool":
+                val = (form_value or "").strip().lower() in ("on", "1", "true", "yes")
+                setattr(st, attr, val)
+            elif kind == "int":
+                s = (form_value or "").strip()
+                val = int(s) if s else 0
+                setattr(st, attr, val)
+            else:
+                setattr(st, attr, (form_value or "").strip() or None)
+        except (TypeError, AttributeError, ValueError):
+            continue
+    return RedirectResponse(url="/settings/pygit2", status_code=303)
+
+
+# ---------- Routes: Project ----------
+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,
+    )
+    return any(p.get("path") == project for p in lst)
+
+
+def _validate_project(project: str | None) -> str:
+    """Validate project name and that it exists; raise HTTPException if not."""
+    if not project:
+        raise HTTPException(status_code=400, detail="Project needed")
+    if not is_valid_project(
+        project,
+        PROJECTROOT,
+        EXPORT_OK,
+        STRICT_EXPORT,
+        _project_in_list,
+    ):
+        raise HTTPException(status_code=404, detail="No such project")
+    return project
+
+
+@router.get("/project/{name:path}", response_class=HTMLResponse)
+def settings_project_page(request: Request, name: str):
+    """Project-specific git config form (local/worktree only)."""
+    project = _validate_project(name)
+    entries = _get_project_config_entries(project)
+    rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
+    # One empty row for adding new
+    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)
+    submit_url = f"/settings/project/{_quote_path(project)}/submit"
+    cancel_url = f"/project/{_quote_path(project)}"
+    body = f'<h1 class="page-title">Project settings: {esc_html(project)}</h1><p class="text-muted">Repository config (local and worktree only).</p><div class="card"><div class="card-body"><form action="{esc_html(submit_url)}" method="post" class="needs-validation" novalidate>{table_html}<div class="form-footer mt-3"><button type="submit" class="btn btn-primary">Save</button><a href="{esc_html(cancel_url)}" class="btn btn-ghost-secondary">Cancel</a></div></form></div></div>'
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@router.post("/project/{name:path}/submit", response_class=HTMLResponse)
+async def settings_project_submit(request: Request, name: str):
+    """Apply project config from form."""
+    project = _validate_project(name)
+    form = await request.form()
+    # Collect key/value pairs; keys are config_key_0, config_value_0, ...
+    indices = set()
+    for form_key in form:
+        if form_key.startswith("config_key_"):
+            try:
+                indices.add(int(form_key.split("_")[-1]))
+            except ValueError:
+                pass
+    pairs = []
+    for i in sorted(indices):
+        k = (form.get(f"config_key_{i}") or "").strip()
+        v = (form.get(f"config_value_{i}") or "").strip()
+        if k:
+            pairs.append((k, v))
+    repo = open_repo(project)
+    cfg = repo.config
+    for config_key, config_value in pairs:
+        try:
+            cfg[config_key] = config_value
+        except (pygit2.GitError, ValueError, KeyError):
+            continue
+    return RedirectResponse(url=f"/settings/project/{_quote_path(project)}", status_code=303)
diff --git a/pygitweb/templates/preamble.html b/pygitweb/templates/preamble.html
index d76e251..2b22dc8 100644
--- a/pygitweb/templates/preamble.html
+++ b/pygitweb/templates/preamble.html
@@ -59,11 +59,15 @@
               <span class="nav-link-title">Tasks</span>
             </a>
           </li>
-          <li class="nav-item">
-            <a class="nav-link" href="#">
+          <li class="nav-item dropdown">
+            <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" role="button">
               <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg></span>
               <span class="nav-link-title">Settings</span>
             </a>
+            <div class="dropdown-menu dropdown-menu-column dropdown-menu-column-1">
+              <a class="dropdown-item" href="/settings/pygitweb">PyGitWeb</a>
+              <a class="dropdown-item" href="/settings/pygit2">PyGit2</a>
+            </div>
           </li>
         </ul>
       </div>
