diff --git a/README.rst b/README.rst
index 6b1d3fd0fbce32a9403b8be932f122514e2ede3c..034e5bdb06e03706b470fe113567f69bb7675a46 100644
--- a/README.rst
+++ b/README.rst
@@ -2,100 +2,47 @@
 schilder2000
 ============
 
-Schildergenerator reloaded – generate signs to print out with a simple web
-interface.  A rewrite from scratch of the old schildergenerator_.
+Schildergenerator reloaded – generate and print signs using a common design from
+a simple web interface.  A rewrite from scratch of the old schildergenerator_.
+Adapted from its README [#orig-authors]_:
 
-Development setup
------------------
+You might find it useful in the following scenarios:
 
-Please configure your editor to respect the EditorConfig_ settings.
+* You need to set up an event where visitors need signs to point them around.
+* You often need to change some signs in your organization.
+* You’re bored in the Fachschaftsbüro.
 
-Code and build process is split between frontend and backend part.  Note that
-the frontend must be build before the backend, as its results will be
-incorporated as static files into the Python package.
+What you need for this tool to unleash its maximum potential:
 
-Frontend
-~~~~~~~~
+* A printer on premise.
+* Some kind of server, preferrably running a flavor of Linux, capable of
+  printing on said printer via IPP.  A RaspberryPi would suffice, even if it’s
+  slow.
+* Web clients of any type being able to connect to said server.
+* The knowledge to modify a HTML template to fit your organizations design.
 
-The frontend support code is written in TypeScript and Less, and compiled and
-bundled using Webpack.  To install the dependencies and start a development
-server:
+After you put in the initial work of setting this up, you’ll have a service
+where everyone on your team can quickly create a new sign as soon as they see a
+need for it (provided they’ve got a mobile web client) just by typing a headline
+and some text, selecting or uploading an image -- and by the time they arrive at
+the printer, the sign is already printed and ready to be posted on the wall.
+All while heeding the event’s design.
 
-.. code:: shell-session
+This tool frees the event organisators of the responsibility to pre-think all
+the details of sign-making.  Instead, this part can simply be delegated to the
+helpers who are setting up the event by showing them the web frontend of this
+tool.
 
-	% npm install        # may update package-lock.json
-	% npm clean-install  # uses package-lock.json exactly
-	% npm run start
 
-This starts a development server that watches for changes and automatically
-rebuilds the bundle, and instructs the browser to reload using a WebSocket
-connection.  To build for production:
-
-.. code:: shell-session
-
-	% npm run build
-
-Backend
-~~~~~~~
-
-Dependencies are managed using PDM_, the build process is handled by
-PDM-Backend_.
-
-You may want to create a virtualenv explicitly in order to install pip_ as well,
-and/or activate the virtualenv:
-
-.. code:: shell-session
-
-	% pdm venv create --with-pip
-	% eval $(pdm venv activate)
-
-To install dependencies, including those for development:
-
-.. code:: shell-session
-
-	% pdm install --dev
-
-Create a directory ``instance`` for your instance configuration, and edit the
-configuration:
-
-.. code:: shell-session
-
-	% cp -r examples instance
-	% $EDITOR instance/config.py
-
-Do replace ``SECRET_KEY`` with a unique, random value; and customise
-``SQLALCHEMY_DATABASE_URI`` according to your environment.  See `SQLAlchemy
-documentation`_ for details.  You may need to create a database in your database
-server beforehand.
-
-Run database migrations:
-
-.. code:: shell-session
-
-	% pdm run migrate
-
-To start the development server:
-
-.. code:: shell-session
-
-	% pdm run serve
-
-If you want to use the ``flask`` command-line tool without specifying the app
-every time, you can use a ``.env`` file:
-
-.. code:: shell-session
-
-	% echo FLASK_APP=schilder2000 >> .env
-
-Production setup
-----------------
-
-Don’t.  This is not ready yet.
+.. _schildergenerator: https://git.fsmpi.rwth-aachen.de/schilder/schildergenerator
+.. [#orig-authors] Thanks to Dave Kliczbor and Felix Schäfer for their work
+                   there.
 
+.. SEPARATOR
+.. The line above is to separate content that should be included in
+   ``docs/index.rst`` (which includes this file) as well from content that
+   should only appear here.
 
-.. _schildergenerator: https://git.fsmpi.rwth-aachen.de/schilder/schildergenerator
-.. _EditorConfig: https://editorconfig.org/
-.. _PDM: https://pdm-project.org/
-.. _PDM-Backend: https://backend.pdm-project.org/
-.. _pip: https://pip.pypa.io/
-.. _`SQLAlchemy documentation`: https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
+See ``docs/installation.rst`` for installation instructions.  To build the
+documentation as HTML locally, run ``pdm run docs`` and open
+``docs/_build/index.html``.
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e37e540da6fe15af1d1710f4215d48fc89e53aa
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,44 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+from pathlib import Path
+
+try:
+    import tomllib
+except ImportError:
+    import tomli as tomllib
+
+with (Path(__file__).parent.parent / "pyproject.toml").open("rb") as f:
+    _pyproject = tomllib.load(f)
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = _pyproject["project"]["name"]
+author = ", ".join([a["name"] for a in _pyproject["project"]["authors"]])
+copyright = f"2024, {author}"
+version = str(_pyproject["project"].get("version", "DEV"))
+release = version
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = ["sphinx.ext.intersphinx"]
+
+templates_path = ["_templates"]
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "alabaster"
+
+intersphinx_mapping = dict(
+    flask=("https://flask.palletsprojects.com/en/3.0.x/", None),
+    flask_multipass=("https://flask-multipass.readthedocs.io/en/latest/", None),
+    flask_sqlalchemy=("https://flask-sqlalchemy.palletsprojects.com/en/3.1.x/", None),
+    gunicorn=("https://docs.gunicorn.org/en/stable/", None),
+)
diff --git a/docs/development.rst b/docs/development.rst
new file mode 100644
index 0000000000000000000000000000000000000000..ada9c3590d2c3edef9b70d378c336d06b994b060
--- /dev/null
+++ b/docs/development.rst
@@ -0,0 +1,87 @@
+Development setup
+=================
+
+Please configure your editor to respect the EditorConfig_ settings.
+
+Code and build process is split between frontend and backend part.  Note that
+the frontend must be build before the backend, as its results will be
+incorporated as static files into the Python package.
+
+Frontend
+--------
+
+The frontend support code is written in TypeScript and Less, and compiled and
+bundled using Webpack.  To install the dependencies and start a development
+server:
+
+.. code:: shell-session
+
+	% npm install        # may update package-lock.json, or instead:
+	% npm clean-install  # uses package-lock.json exactly
+	% npm run start
+
+This starts a development server that watches for changes and automatically
+rebuilds the bundle, and instructs the browser to reload using a WebSocket
+connection.  To build for production:
+
+.. code:: shell-session
+
+	% npm run build
+
+Backend
+-------
+
+Dependencies are managed using PDM_, the build process is handled by
+PDM-Backend_.
+
+You may want to create a virtualenv explicitly in order to install pip_ as well,
+and/or activate the virtualenv:
+
+.. code:: shell-session
+
+	% pdm venv create --with-pip
+	% eval $(pdm venv activate)
+
+To install dependencies, including those for development:
+
+.. code:: shell-session
+
+	% pdm install --dev
+
+Create a directory ``instance`` for your instance configuration, and edit the
+configuration:
+
+.. code:: shell-session
+
+	% cp -r examples instance
+	% $EDITOR instance/config/config.py
+
+Do replace ``SECRET_KEY`` with a unique, random value; and customise
+``SQLALCHEMY_DATABASE_URI`` according to your environment.  See `SQLAlchemy
+documentation`_ for details.  You may need to create a database in your database
+server beforehand.
+
+Run database migrations:
+
+.. code:: shell-session
+
+	% pdm run migrate
+
+To start the development server:
+
+.. code:: shell-session
+
+	% pdm run serve
+
+If you want to use the ``flask`` command-line tool without specifying the app
+every time, you can use a ``.env`` file:
+
+.. code:: shell-session
+
+	% echo FLASK_APP=schilder2000 >> .env
+
+.. _EditorConfig: https://editorconfig.org/
+.. _PDM: https://pdm-project.org/
+.. _PDM-Backend: https://backend.pdm-project.org/
+.. _pip: https://pip.pypa.io/
+.. _`SQLAlchemy documentation`: https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000000000000000000000000000000000000..a8bd4e24596e3159ada752fbc0e87a91549d7f72
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,10 @@
+.. include:: ../README.rst
+   :end-before: .. SEPARATOR
+
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   installation
+   development
diff --git a/docs/installation.rst b/docs/installation.rst
new file mode 100644
index 0000000000000000000000000000000000000000..09d3ea1e8e18be7af4f8e6da4575971b9bb1ceff
--- /dev/null
+++ b/docs/installation.rst
@@ -0,0 +1,269 @@
+Installation
+============
+
+To install this software, you can use a container-based setup, or run it
+directly in a Python environment.  As there are many ways (and opinions!) how to
+configure a web server and deploy a Python WSGI application, only some details
+are outlined here and the reader is expected to be familiar with at least web
+server administration.
+
+See also the :external+flask:std:doc:`Flask documentation on production
+deployment <deploying/index>`.
+
+Container setup (Podman, Kubernetes, …)
+---------------------------------------
+
+Pull the `container image`_ (or build it yourself using the provided
+``Containerfile``):
+
+.. code:: shell-session
+
+	% podman pull registry.git.fsmpi.rwth-aachen.de/schilder/schilder2000:{{TAG}}
+
+Config and data is expected in ``/usr/local/var/schilder2000-instance/``.  For
+starters, you can use the ``examples/`` directory in the `source repository`_,
+which is also included in the container image.  To create a volume based on it:
+
+.. code:: shell-session
+
+	% [[ $(id -u) -eq 0 ]] || podman unshare
+	# pushd $(podman image mount registry.git.fsmpi.rwth-aachen.de/schilder/schilder2000:{{TAG}})
+	# cd usr/local/var/schilder2000-instance
+	# tar cf - . | podman volume import schilder2000-instance
+	# popd
+	# podman image unmount registry.git.fsmpi.rwth-aachen.de/schilder/schilder2000:{{TAG}}
+
+You can also use a volume just for ``/usr/local/var/schilder2000-instance/data``
+and mount ``/usr/local/var/schilder2000-instance/config/config.py`` separately.
+
+Continue with :ref:`configuration` and :ref:`database-migration` before starting
+the container.
+
+Configure your inbound proxy to pass HTTP to port 8080, and your container
+management to launch the container as required.  For example, as plain
+invocation:
+
+.. code:: shell-session
+
+	% podman run --rm --detach --publish-all \
+	  --volume schilder2000-instance:/usr/local/var/schilder2000-instance \
+	  registry.git.fsmpi.rwth-aachen.de/schilder/schilder2000:{{TAG}}
+
+Without container
+-----------------
+
+You can use our pre-built `Python package`_ (wheel) or build it yourself.
+
+Dependencies
+~~~~~~~~~~~~
+
+You need Python 3.9 or later and the dependencies specified in
+``pyproject.toml``.  It is suggested to use a virtualenv, as distribution
+packages are often missing or outdated.
+
+Some optional dependencies need additional native libraries, namely
+MySQL/MariaDB Connector/C for MySQL/MariaDB support (both should work with
+either database), and OpenLDAP and Cyrus SASL for LDAP authentication.  If
+binary wheels for those are not available, you need the development versions of
+those as well as C compiler and Python development infrastructure.
+Additionally, Git is required to get the sources of some dependencies.
+
+.. code:: shell-session
+
+	# apt-get install python3-dev pkg-config gcc libmariadb-dev libldap-dev libsasl2-dev git
+	# dnf install python3-devel gcc 'pkgconfig(libmariadb)' 'pkgconfig(ldap)' git-core
+	# apk add -t .schilder2000 python3 mariadb-connector-c libldap  # Runtime
+	# apk add -t .schilder2000-build build-base git mariadb-connector-c-dev \
+	  openldap-dev python3-dev  # Build only, can be removed later
+
+To use OS packages as much as possible (note that the versions your distribution
+provides may be too outdated and you may need to install the development
+dependencies from above anyway):
+
+.. code:: shell-session
+
+	# apt-get install python3 weasyprint python3-jinja2 python3-flask \
+	  python3-asgiref python3-flask-sqlalchemy python3-flaskext.wtf alembic \
+	  python3-qrcode python3-ldap python3-authlib python3-psycopg \
+	  python3-mysqldb git
+	# dnf install python3 \
+	  python3dist\({weasyprint,jinja2,flask\\[async\\],flask-sqlalchemy,flask-wtf,alembic,qrcode}\) \
+	  python3dist\({python-ldap,python3-saml,authlib,psycopg,mysqlclient}\)
+	# apk add -t .schilder2000 python3 weasyprint py3-jinja2 py3-flask \
+	  py3-asgiref py3-flask-sqlalchemy py3-flask-wtf py3-alembic py3-ldap \
+	  py3-python3-saml py3-authlib py3-psycopg py3-mysqlclient git
+
+If you are not using the pre-built `Python package`_ (wheel), you also need
+Node.js and npm to build the frontend:
+
+.. code:: shell-session
+
+	# apt-get install npm
+	# dnf install nodejs-npm
+	# apk add npm
+
+You will also likely want a WSGI server.  If in doubt, choose Gunicorn_:
+
+.. code:: shell-session
+
+	(venv) % pip install gunicorn
+	# apt-get install gunicorn
+	# dnf install python3-gunicorn
+	# apk add py3-gunicorn
+
+
+Pre-built package
+~~~~~~~~~~~~~~~~~
+
+In your chosen deployment location (e. g., virtualenv), install the package with
+your desired optional extras.  These are ``auth-ldap``, ``auth-saml``,
+``auth-oauth``, ``all-auth``, ``db-postgres``, ``db-mysql``, ``all-db``,
+``all``.  For example, to install with support for SAML login and Postgres
+database:
+
+.. code:: shell-session
+
+	(venv) % pip install "schilder2000[auth-saml,db-postgres]" \
+	  --index-url https://git.fsmpi.rwth-aachen.de/api/v4/projects/305/packages/pypi/simple
+
+Continue with :ref:`configuration`.
+
+Building from source
+~~~~~~~~~~~~~~~~~~~~
+
+This package follows `PEP 517`_ conventions.  You can use `build`_ to generate
+SDist and wheel packages, or run ``pip install .`` in your local source tree, …
+if you are at this point, you probably know your choices anyway.
+
+The default build process will automatically invoke Node.js/npm to build the
+frontend.  To disable this behaviour, pass ``without-npm`` as build config
+setting.  This requires the files to already exist.  For example:
+
+.. code:: shell-session
+
+	% npm run build
+	% python -m build --config-setting without-npm
+
+.. _configuration:
+
+Configuration
+-------------
+
+Configuration and runtime data is stored in the instance directory.  For
+container installs, this is ``/usr/local/var/schilder2000-instance/``.
+For package installs, this is ``{{ python prefix }}/var/schilder2000-instance``;
+if in doubt, try to run ``flask -A schilder2000``, the error should tell you
+where it expects the instance directory.
+
+Example config and data is located in the ``examples`` directory.  The templates
+there get their footer text and logo from the application config and should also
+be useful as an example to write your own templates.
+
+The main application config is located in ``config/config.py``.  Available
+options:
+
+.. py:data:: SQLALCHEMY_DATABASE_URI
+   :type: str
+
+	**Required**.  Database connection URI.  See
+	:external:py:data:`Flask-SQLAlchemy documentation
+	<flask_sqlalchemy.config.SQLALCHEMY_DATABASE_URI>` for details and
+	additional options.  Note that the ``db-postgres`` optional dependency
+	install the ``psycopg`` driver (i. e., version 3), not ``psycopg2``.
+
+.. py:data:: SECRET_KEY
+   :type: str | bytes
+
+	**Required**.  Secret key for signing cookies and other security related
+	needs.  See :external+flask:py:data:`Flask documentation <SECRET_KEY>`
+	for details.
+
+.. py:data:: SCHILD_FOOTER
+   :type: str
+
+	Footer text used by the templates shipped in ``examples/``.
+
+.. py:data:: SCHILD_LOGO
+   :type: str
+
+	Logo used by the templates shipped in ``examples/``.  Expects a file
+	relative to ``{{ instance path }}/data/static``.
+
+.. py:data:: TEMPLATES_AUTO_RELOAD
+   :type: bool
+
+	Reload templates when they are changed.  See
+	:external+flask:py:data:`Flask documentation <TEMPLATES_AUTO_RELOAD>`
+	for details.
+
+.. py:data:: PRINTERS
+   :type: dict[str, str]
+
+	**Required**.  Available printers.  Maps display names to IPP(S) URLs.
+
+.. py:data:: REQUIRE_LOGIN
+   :type: bool
+
+	**Required**.  Whether authentication is required to access the service.
+	If enabled, requires additional configuration for
+	:external+flask_multipass:std:doc:`index`.
+
+.. py:data:: MULTIPASS_AUTH_PROVIDERS
+   :type: dict[str, dict]
+
+.. py:data:: MULTIPASS_IDENTITY_PROVIDERS
+   :type: dict[str, dict]
+
+.. py:data:: MULTIPASS_PROVIDER_MAP
+   :type: dict[str, str]
+
+	See :external+flask_multipass:std:doc:`Flask-Multipass documentation
+	<index>` for details.
+
+.. py:data:: MULTIPASS_IDENTITY_INFO_KEYS
+   :type: list
+
+	Required by Flask-Multipass, but can be empty, as identity information
+	is not used.
+
+See also :external+flask:std:doc:`Flask documentation <config>` for additional
+options and information.
+
+.. _database-migration:
+
+Database migration
+~~~~~~~~~~~~~~~~~~
+
+Unless you use SQLite, create the database in your database server.  In all
+cases, run the migrations:
+
+.. code:: shell-session
+
+	% flask -A schilder2000 alembic upgrade head  # without container
+	% podman run --rm --volume <...> <image> flask alembic upgrade head  # with container
+
+WSGI and webserver setup
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+An example config for Gunicorn_ is provided in ``gunicorn.conf.py``.  This
+will listen on ``[::]:8080`` (port 8080, all interfaces), and write the access
+log to stdout.
+
+See :external+flask:std:doc:`Flask documentation on Gunicorn
+<deploying/gunicorn>` and :external+gunicorn:std:doc:`Gunicorn documentation
+<index>` for further information.
+
+If you want to use another WSGI server, configure it to use
+``schilder2000:create_app()`` as application object.  Note that this is a
+factory function that returns the application callable, you have to call it!
+
+To use your webserver to directly serve static files, route ``/static`` to ``{{
+python packages directory }}/schilder2000/static`` and ``/instance/static`` to
+``{{ instance path }}/data/static``.
+
+.. _`Python package`: https://git.fsmpi.rwth-aachen.de/schilder/schilder2000/-/packages
+.. _`container image`: https://git.fsmpi.rwth-aachen.de/schilder/schilder2000/container_registry/33
+.. _Gunicorn: https://gunicorn.org/
+.. _`source repository`: https://git.fsmpi.rwth-aachen.de/schilder/schilder2000
+.. _`PEP 517`: https://peps.python.org/pep-0517/
+.. _`build`: https://github.com/pypa/build
diff --git a/examples/config/config.py b/examples/config/config.py
index f792cc1af3773f537ed57df93e66ebe4b7b2acaa..3e025edbb6ddfe048673accf212fe0243330675f 100644
--- a/examples/config/config.py
+++ b/examples/config/config.py
@@ -7,12 +7,12 @@ from pathlib import Path
 
 
 SQLALCHEMY_DATABASE_URI = "postgresql+psycopg:///schilder2000"
-# SQLALCHEMY_DATABASE_URI = "sqlite:///schilder2000.db"  # Relative to instance directory
+# SQLALCHEMY_DATABASE_URI = "sqlite:///data/schilder2000.db"  # Relative to instance directory
 # SQLALCHEMY_DATABASE_URI = "mysql:///schilder2000"
 
 # To generate a secret key:
 #   % python -c 'import secrets; print(secrets.token_hex())'
-SECRET_KEY = "abc123"  # Replace me!
+#SECRET_KEY = ...  # Replace me!
 TEMPLATES_AUTO_RELOAD = True
 
 SCHILD_FOOTER = "Organization Without a Cool Acronym – O.W.C.A."
diff --git a/examples/data/templates/_layout.html.j2 b/examples/data/templates/_layout.html.j2
index 9056e07520d54ff3e8746d259bcbabb3d4ced800..9d2e639836c53379a27c12ee402059f55061fb95 100644
--- a/examples/data/templates/_layout.html.j2
+++ b/examples/data/templates/_layout.html.j2
@@ -20,7 +20,7 @@
 			</div>
 
 			<div id="logo">
-				<img src="{{ url_for('instance.static', filename='geier.png') }}" />
+				<img src="{{ url_for('instance.static', filename=config['SCHILD_LOGO']) }}" />
 			</div>
 		</footer>
 	</body>
diff --git a/pdm.lock b/pdm.lock
index 6ca98eb79ce973d139ecf2298a9bbe9c464d85ec..c0f968a97bad7d71f93317a42bded338744a6efe 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -2,10 +2,10 @@
 # It is not intended for manual editing.
 
 [metadata]
-groups = ["default", "auth-ldap", "auth-oauth", "auth-saml", "db-mysql", "db-postgres", "dev"]
+groups = ["default", "auth-ldap", "auth-oauth", "auth-saml", "db-mysql", "db-postgres", "dev", "docs"]
 strategy = ["inherit_metadata"]
 lock_version = "4.5.0"
-content_hash = "sha256:426cc9921b084477e1e6775780cf783ca6941201253b4c20f888b7fa19717a71"
+content_hash = "sha256:cb72c38a190b2e922bfba2ca29390a8bf6832123ae43187cdd433f5dde03d422"
 
 [[metadata.targets]]
 requires_python = ">=3.9"
@@ -129,6 +129,17 @@ files = [
     {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
 ]
 
+[[package]]
+name = "alabaster"
+version = "0.7.16"
+requires_python = ">=3.9"
+summary = "A light, configurable Sphinx theme"
+groups = ["docs"]
+files = [
+    {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"},
+    {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"},
+]
+
 [[package]]
 name = "alembic"
 version = "1.13.2"
@@ -244,6 +255,20 @@ files = [
     {file = "awesomeversion-24.6.0.tar.gz", hash = "sha256:aee7ccbaed6f8d84e0f0364080c7734a0166d77ea6ccfcc4900b38917f1efc71"},
 ]
 
+[[package]]
+name = "babel"
+version = "2.16.0"
+requires_python = ">=3.8"
+summary = "Internationalization utilities"
+groups = ["docs"]
+dependencies = [
+    "pytz>=2015.7; python_version < \"3.9\"",
+]
+files = [
+    {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"},
+    {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"},
+]
+
 [[package]]
 name = "backoff"
 version = "2.2.1"
@@ -388,7 +413,7 @@ name = "certifi"
 version = "2024.8.30"
 requires_python = ">=3.6"
 summary = "Python package for providing Mozilla's CA Bundle."
-groups = ["default"]
+groups = ["default", "docs"]
 files = [
     {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
     {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
@@ -481,7 +506,7 @@ name = "charset-normalizer"
 version = "3.3.2"
 requires_python = ">=3.7.0"
 summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-groups = ["default"]
+groups = ["default", "docs"]
 files = [
     {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
     {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
@@ -567,8 +592,8 @@ name = "colorama"
 version = "0.4.6"
 requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 summary = "Cross-platform colored terminal text."
-groups = ["default", "dev"]
-marker = "platform_system == \"Windows\" or sys_platform == \"win32\""
+groups = ["default", "dev", "docs"]
+marker = "sys_platform == \"win32\" or platform_system == \"Windows\""
 files = [
     {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -685,6 +710,17 @@ files = [
     {file = "docstring_to_markdown-0.15-py3-none-any.whl", hash = "sha256:27afb3faedba81e34c33521c32bbd258d7fbb79eedf7d29bc4e81080e854aec0"},
 ]
 
+[[package]]
+name = "docutils"
+version = "0.21.2"
+requires_python = ">=3.9"
+summary = "Docutils -- Python Documentation Utilities"
+groups = ["docs"]
+files = [
+    {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
+    {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
+]
+
 [[package]]
 name = "exceptiongroup"
 version = "1.2.2"
@@ -1061,18 +1097,29 @@ name = "idna"
 version = "3.10"
 requires_python = ">=3.6"
 summary = "Internationalized Domain Names in Applications (IDNA)"
-groups = ["default"]
+groups = ["default", "docs"]
 files = [
     {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
     {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
 ]
 
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+summary = "Getting image size from png/jpeg/jpeg2000/gif file"
+groups = ["docs"]
+files = [
+    {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
+    {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
+]
+
 [[package]]
 name = "importlib-metadata"
 version = "8.5.0"
 requires_python = ">=3.8"
 summary = "Read metadata from Python packages"
-groups = ["default", "dev"]
+groups = ["default", "dev", "docs"]
 marker = "python_version < \"3.10\""
 dependencies = [
     "typing-extensions>=3.6.4; python_version < \"3.8\"",
@@ -1150,7 +1197,7 @@ name = "jinja2"
 version = "3.1.4"
 requires_python = ">=3.7"
 summary = "A very fast and expressive template engine."
-groups = ["default", "dev"]
+groups = ["default", "dev", "docs"]
 dependencies = [
     "MarkupSafe>=2.0",
 ]
@@ -1314,7 +1361,7 @@ name = "markupsafe"
 version = "2.1.5"
 requires_python = ">=3.7"
 summary = "Safely add untrusted strings to HTML/XML markup."
-groups = ["default", "dev"]
+groups = ["default", "dev", "docs"]
 files = [
     {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
     {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
@@ -1494,7 +1541,7 @@ name = "packaging"
 version = "24.1"
 requires_python = ">=3.8"
 summary = "Core utilities for Python packages"
-groups = ["dev"]
+groups = ["dev", "docs"]
 files = [
     {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
     {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
@@ -1729,7 +1776,7 @@ name = "pygments"
 version = "2.18.0"
 requires_python = ">=3.8"
 summary = "Pygments is a syntax highlighting package written in Python."
-groups = ["default", "dev"]
+groups = ["default", "dev", "docs"]
 files = [
     {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
     {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
@@ -2003,7 +2050,7 @@ name = "requests"
 version = "2.32.3"
 requires_python = ">=3.8"
 summary = "Python HTTP for Humans."
-groups = ["default"]
+groups = ["default", "docs"]
 dependencies = [
     "certifi>=2017.4.17",
     "charset-normalizer<4,>=2",
@@ -2083,6 +2130,113 @@ files = [
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 
+[[package]]
+name = "snowballstemmer"
+version = "2.2.0"
+summary = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
+groups = ["docs"]
+files = [
+    {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+    {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
+]
+
+[[package]]
+name = "sphinx"
+version = "7.4.7"
+requires_python = ">=3.9"
+summary = "Python documentation generator"
+groups = ["docs"]
+dependencies = [
+    "Jinja2>=3.1",
+    "Pygments>=2.17",
+    "alabaster~=0.7.14",
+    "babel>=2.13",
+    "colorama>=0.4.6; sys_platform == \"win32\"",
+    "docutils<0.22,>=0.20",
+    "imagesize>=1.3",
+    "importlib-metadata>=6.0; python_version < \"3.10\"",
+    "packaging>=23.0",
+    "requests>=2.30.0",
+    "snowballstemmer>=2.2",
+    "sphinxcontrib-applehelp",
+    "sphinxcontrib-devhelp",
+    "sphinxcontrib-htmlhelp>=2.0.0",
+    "sphinxcontrib-jsmath",
+    "sphinxcontrib-qthelp",
+    "sphinxcontrib-serializinghtml>=1.1.9",
+    "tomli>=2; python_version < \"3.11\"",
+]
+files = [
+    {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"},
+    {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"},
+]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+requires_python = ">=3.9"
+summary = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
+groups = ["docs"]
+files = [
+    {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"},
+    {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"},
+]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+requires_python = ">=3.9"
+summary = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
+groups = ["docs"]
+files = [
+    {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"},
+    {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"},
+]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+requires_python = ">=3.9"
+summary = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
+groups = ["docs"]
+files = [
+    {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"},
+    {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"},
+]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+requires_python = ">=3.5"
+summary = "A sphinx extension which renders display math in HTML via JavaScript"
+groups = ["docs"]
+files = [
+    {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
+    {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
+]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+requires_python = ">=3.9"
+summary = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
+groups = ["docs"]
+files = [
+    {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"},
+    {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"},
+]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+requires_python = ">=3.9"
+summary = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
+groups = ["docs"]
+files = [
+    {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"},
+    {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"},
+]
+
 [[package]]
 name = "sqlalchemy"
 version = "2.0.35"
@@ -2175,7 +2329,7 @@ name = "tomli"
 version = "2.0.1"
 requires_python = ">=3.7"
 summary = "A lil' TOML parser"
-groups = ["dev"]
+groups = ["dev", "docs"]
 marker = "python_version < \"3.11\""
 files = [
     {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
@@ -2304,7 +2458,7 @@ name = "urllib3"
 version = "2.2.3"
 requires_python = ">=3.8"
 summary = "HTTP library with thread-safe connection pooling, file post, and more."
-groups = ["default"]
+groups = ["default", "docs"]
 files = [
     {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
     {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
@@ -2537,7 +2691,7 @@ name = "zipp"
 version = "3.20.2"
 requires_python = ">=3.8"
 summary = "Backport of pathlib-compatible object wrapper for zip files"
-groups = ["default", "dev"]
+groups = ["default", "dev", "docs"]
 marker = "python_version < \"3.10\""
 files = [
     {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"},
diff --git a/pyproject.toml b/pyproject.toml
index 336fc497e04fff197bcd36a081415483a596d183..9c32596421581f62377d8052e89099601b1ddde3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,10 +48,15 @@ dev = [
     "pylsp-rope~=0.1",
     "flask-shell-ipython~=0.5",
 ]
+docs = [
+    "sphinx~=7.4",
+    "tomli~=2.0; python_version < '3.11'",
+]
 
 [tool.pdm.scripts]
 serve = "flask -A schilder2000 run --debug"
 migrate = "flask -A schilder2000 alembic upgrade head"
+docs = "sphinx-build docs docs/_build"
 
 [tool.pdm.build]
 includes = [
@@ -59,6 +64,7 @@ includes = [
     "schilder2000/templates",
     "schilder2000/migrations",
     # Referenced files in manifest.json included via pdm_build.py
+    "docs",
 ]
 
 [tool.pdm.version]