diff --git a/pygitweb/main.py b/pygitweb/main.py
index 4fce3a0..1173f06 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -7,10 +7,7 @@ from __future__ import annotations
 
 import asyncio
 import os
-import signal
-import sys
-import threading
-from collections.abc import AsyncGenerator, Callable
+from collections.abc import AsyncGenerator
 from contextlib import asynccontextmanager
 from datetime import UTC, datetime
 from pathlib import Path
@@ -102,6 +99,7 @@ from pygitweb.plugin_loader import load_plugin_actions, load_subpages
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.sessions import clear_all_sessions
 from pygitweb.settings import router as settings_router
+from pygitweb.shutdown import begin_shutdown, install_graceful_shutdown_wakeup
 from pygitweb.tasks import (
 	board_router,
 	comment_router,
@@ -146,52 +144,17 @@ def _updates_idle_response() -> Response:
 	return Response(status_code=200, content=b"")
 
 
-def _install_graceful_shutdown_wakeup(app: FastAPI) -> None:
-	"""Wake ``updates=true`` waiters as soon as shutdown is requested.
-
-	Uvicorn waits for open connections to finish before running ASGI lifespan shutdown,
-	so toggling ``shutdown_event`` only from lifespan would deadlock with long polls.
-	We chain OS signals (same ones uvicorn uses) and notify waiters before uvicorn's
-	handler runs.
-
-	Skipped when not running on the main thread (e.g. Starlette ``TestClient`` lifespan).
-	"""
-	if threading.current_thread() is not threading.main_thread():
-		return
-	signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM)
-	if sys.platform == "win32":
-		signals = signals + (signal.SIGBREAK,)
-	for sig in signals:
-
-		def make_chain(
-			previous: Callable[[int, object | None], object] | int | None,
-		) -> Callable[[int, object | None], None]:
-			def handler(signum: int, frame: object | None) -> None:
-				app.state.shutting_down = True
-				app.state.shutdown_event.set()
-				if callable(previous) and previous not in (signal.SIG_DFL, signal.SIG_IGN):
-					previous(signum, frame)
-
-			return handler
-
-		try:
-			prev = signal.getsignal(sig)
-			signal.signal(sig, make_chain(prev))
-		except ValueError:
-			continue
-
-
 @asynccontextmanager
 async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
 	app.state.shutting_down = False
 	app.state.shutdown_event = asyncio.Event()
-	_install_graceful_shutdown_wakeup(app)
+	app.state.can_signal_shutdown = False
+	install_graceful_shutdown_wakeup(app)
 	warm_timeline_cache_async()
 	try:
 		yield
 	finally:
-		app.state.shutting_down = True
-		app.state.shutdown_event.set()
+		begin_shutdown(app)
 		clear_timeline_cache_async()
 		clear_all_sessions()
 
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index 6823fa2..5d5b974 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -11,7 +11,7 @@ from typing import Any
 from urllib.parse import quote
 
 import pygit2
-from fastapi import APIRouter, Depends, Request
+from fastapi import APIRouter, BackgroundTasks, Depends, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.auth import require_permission
@@ -19,6 +19,7 @@ from pygitweb.config import settings
 from pygitweb.dependencies import ValidatedSettingsProject
 from pygitweb.permissions import Permission
 from pygitweb.settings_config import settings_batch_update
+from pygitweb.shutdown import request_graceful_shutdown
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 
 
@@ -320,6 +321,18 @@ async def settings_pygitweb_submit(request: Request):
 	return RedirectResponse(url="/settings/pygitweb", status_code=303)
 
 
+@router.post("/pygitweb/shutdown", response_class=HTMLResponse)
+async def settings_pygitweb_shutdown(request: Request, background_tasks: BackgroundTasks) -> HTMLResponse:
+	"""Gracefully stop the PyGitWeb server process."""
+	background_tasks.add_task(request_graceful_shutdown, request.app)
+	pre = PREAMBLE.render(
+		title=f"{settings.SITE_NAME} - Shutting down",
+		site_name=settings.SITE_NAME,
+	)
+	body = env.get_template("shutdown_ack.html").render()
+	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
 # ---------- Routes: PyGit2 ----------
 @router.get("/pygit2", response_class=HTMLResponse)
 def settings_pygit2_page(request: Request):
diff --git a/pygitweb/settings_permissions_test.py b/pygitweb/settings_permissions_test.py
index 6ce7cff..1937ed2 100644
--- a/pygitweb/settings_permissions_test.py
+++ b/pygitweb/settings_permissions_test.py
@@ -74,3 +74,25 @@ def test_settings_forbidden_without_grant(client: TestClient, settings_auth: Aut
 		_login(client, "viewer")
 		r = client.get("/settings/pygit2")
 	assert r.status_code == 403
+
+
+def test_shutdown_forbidden_without_grant(client: TestClient, settings_auth: AuthConfig) -> None:
+	from pygitweb.config import settings
+
+	with patch.object(settings, "AUTH", True):
+		_login(client, "viewer")
+		r = client.post("/settings/pygitweb/shutdown")
+	assert r.status_code == 403
+
+
+def test_shutdown_allowed_with_grant(client: TestClient, settings_auth: AuthConfig) -> None:
+	from pygitweb.config import settings
+
+	app.state.can_signal_shutdown = True
+	with patch.object(settings, "AUTH", True), patch("pygitweb.shutdown.os.kill") as kill:
+		_login(client, "admin")
+		r = client.post("/settings/pygitweb/shutdown")
+	assert r.status_code == 200
+	assert "Shutting down" in r.text
+	assert app.state.shutting_down is True
+	kill.assert_called_once()
diff --git a/pygitweb/shutdown.py b/pygitweb/shutdown.py
new file mode 100644
index 0000000..257c0fa
--- /dev/null
+++ b/pygitweb/shutdown.py
@@ -0,0 +1,61 @@
+"""Graceful shutdown helpers shared by signal handlers and the settings UI."""
+
+from __future__ import annotations
+
+import os
+import signal
+import sys
+import threading
+from collections.abc import Callable
+
+from fastapi import FastAPI
+
+
+def begin_shutdown(app: FastAPI) -> None:
+	"""Wake long-poll waiters; safe to call multiple times."""
+	app.state.shutting_down = True
+	shutdown_event = getattr(app.state, "shutdown_event", None)
+	if shutdown_event is not None:
+		shutdown_event.set()
+
+
+def request_graceful_shutdown(app: FastAPI) -> None:
+	"""Begin graceful shutdown and signal the server process when supported."""
+	begin_shutdown(app)
+	if getattr(app.state, "can_signal_shutdown", False):
+		os.kill(os.getpid(), signal.SIGTERM)
+
+
+def install_graceful_shutdown_wakeup(app: FastAPI) -> bool:
+	"""Chain OS signals so long-poll waiters wake before uvicorn shuts down.
+
+	Returns whether SIGTERM may be sent to this process later (e.g. from the
+	settings shutdown button). Skipped off the main thread.
+	"""
+	if threading.current_thread() is not threading.main_thread():
+		app.state.can_signal_shutdown = False
+		return False
+	signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM)
+	if sys.platform == "win32":
+		signals = signals + (signal.SIGBREAK,)
+	installed = False
+	for sig in signals:
+
+		def make_chain(
+			previous: Callable[[int, object | None], object] | int | None,
+		) -> Callable[[int, object | None], None]:
+			def handler(signum: int, frame: object | None) -> None:
+				begin_shutdown(app)
+				if callable(previous) and previous not in (signal.SIG_DFL, signal.SIG_IGN):
+					previous(signum, frame)
+
+			return handler
+
+		try:
+			prev = signal.getsignal(sig)
+			signal.signal(sig, make_chain(prev))
+			installed = True
+		except ValueError:
+			continue
+	app.state.can_signal_shutdown = installed
+	return installed
diff --git a/pygitweb/templates/pygitweb_settings.html b/pygitweb/templates/pygitweb_settings.html
index 625a807..e2cbbbb 100644
--- a/pygitweb/templates/pygitweb_settings.html
+++ b/pygitweb/templates/pygitweb_settings.html
@@ -10,3 +10,12 @@
     </form>
   </div>
 </div>
+<div class="card mt-3">
+  <div class="card-body">
+    <h2 class="card-title">Server</h2>
+    <p class="text-secondary">Stop the PyGitWeb process gracefully. Long-poll connections are released, then the server exits.</p>
+    <form action="/settings/pygitweb/shutdown" method="post" onsubmit="return confirm('Shut down PyGitWeb now?');">
+      <button type="submit" class="btn btn-danger">Shut down PyGitWeb</button>
+    </form>
+  </div>
+</div>
diff --git a/pygitweb/templates/shutdown_ack.html b/pygitweb/templates/shutdown_ack.html
new file mode 100644
index 0000000..b6dc0f3
--- /dev/null
+++ b/pygitweb/templates/shutdown_ack.html
@@ -0,0 +1,6 @@
+<h1 class="page-title">Shutting down</h1>
+<div class="card">
+  <div class="card-body">
+    <p>PyGitWeb is shutting down gracefully. You can close this tab.</p>
+  </div>
+</div>
