diff --git a/README.rst b/README.rst
index a79a1fd88b41c2b9ca433f16bc6f44a64fd0c8e4..6e4141741c09c2032e8052557b38f28668aaeccc 100644
--- a/README.rst
+++ b/README.rst
@@ -43,4 +43,6 @@ All while heeding the event’s design.
    ``docs/index.rst`` (which includes this file) as well from content that
    should only appear here.
 
-See ``docs/installation.rst`` for installation instructions.
+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
index 49bf80711cffb97814a0754ae20ae9bf9402818a..6e37e540da6fe15af1d1710f4215d48fc89e53aa 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -25,7 +25,7 @@ release = version
 # -- General configuration ---------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 
-extensions = []
+extensions = ["sphinx.ext.intersphinx"]
 
 templates_path = ["_templates"]
 exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
@@ -35,4 +35,10 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 
 html_theme = "alabaster"
-html_static_path = ["_static"]
+
+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
index 4c1fa63138632342f3ae7c82ca6148480241a83c..ada9c3590d2c3edef9b70d378c336d06b994b060 100644
--- a/docs/development.rst
+++ b/docs/development.rst
@@ -16,7 +16,7 @@ server:
 
 .. code:: shell-session
 
-	% npm install        # may update package-lock.json
+	% npm install        # may update package-lock.json, or instead:
 	% npm clean-install  # uses package-lock.json exactly
 	% npm run start
 
diff --git a/docs/installation.rst b/docs/installation.rst
index ee5adc48230d192b69479c0246a00acc7a54ce71..c1fdd55efade73716e49fd388dad35931b872bd2 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -1,39 +1,224 @@
 Installation
 ============
 
-It is recommended to use our pre-built `Python package`_ (wheel) or `container
-image`_.  Building the package yourself is left as an exercise to the reader
-(hint: it is a standard Python package, you need Node.js, pass
-``--config-setting without-npm`` to ``pyproject-build`` if you do not want it to
-build the frontend for you, but then you need to run ``npm run build``
-beforehand).
-
-The container image ships with all supported features.  For the Python package,
-some features require optional dependencies: ``auth-ldap``, ``auth-saml``,
+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`.
+
+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
+
+	# 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 the package with support for SAML login and
-Postgres database:
+``all``.  For example, to install with support for SAML login and Postgres
+database:
+
+.. code:: shell-session
+
+	% 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
 
-	% pip install "schilder2000[auth-saml,db-postgres]" --index-url https://git.fsmpi.rwth-aachen.de/api/v4/projects/305/packages/pypi/simple
+	% 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, mount a volume to ``/usr/local/var/schilder2000-instance/``.
+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.
+where it expects the instance directory.
 
-The main application config is located in ``config/config.py``.  Customise it as
-needed, the example config should provide enough comments.
+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.
 
-An example config for Gunicorn_ is provided in ``gunicorn.conf.py``.  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!
+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
+~~~~~~~~~~~~~~~~~~
 
 Unless you use SQLite, create the database in your database server.  In all
 cases, run the migrations:
@@ -42,10 +227,28 @@ cases, run the migrations:
 
 	% flask -A schilder2000 alembic upgrade head
 
-If you want 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``.
+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 a6b2cc58edaeaa5e778bcbbb63ba7e1bb4666666..3e025edbb6ddfe048673accf212fe0243330675f 100644
--- a/examples/config/config.py
+++ b/examples/config/config.py
@@ -12,7 +12,7 @@ SQLALCHEMY_DATABASE_URI = "postgresql+psycopg:///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/pyproject.toml b/pyproject.toml
index 227f2389774a65f0ddac037098e121bafbdbde64..9c32596421581f62377d8052e89099601b1ddde3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,6 +56,7 @@ docs = [
 [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 = [