diff --git a/README.md b/README.md
index 7cbc3086df2ac9ce39358e8dcf833e6219d98271..7549820ebd10243a2bd70269c66fce2645a116bf 100644
--- a/README.md
+++ b/README.md
@@ -2,62 +2,4 @@
 
 [GitLab](https://gitlab.tugraz.at/dbp/relay/dbp-relay-core-bundle) | [Packagist](https://packagist.org/packages/dbp/relay-core-bundle)
 
-## Bundle Config
-
-Created via `./bin/console config:dump-reference DbpRelayCoreBundle | sed '/^$/d'`
-
-```yaml
-# Default configuration for "DbpRelayCoreBundle"
-dbp_relay_core:
-    # Some string identifying the current build (commit hash)
-    build_info:              ~ # Example: deadbeef
-    # Some URL identifying the current build (URL to the commit on some git web interface)
-    build_info_url:          ~ # Example: 'https://gitlab.example.com/project/-/commit/deadbeef'
-    # The title text of the API docs page
-    docs_title:              'Relay API Gateway'
-    # The description text of the API docs page (supports markdown)
-    docs_description:        '*part of the [Digital Blueprint](https://gitlab.tugraz.at/dbp) project*'
-    lock_dsn:                ''
-    messenger_transport_dsn: '%env(MESSENGER_TRANSPORT_DSN)'
-```
-
-### Locking
-
-To handle [locking](https://symfony.com/doc/current/components/lock.html) you need to set above `lock_dsn` config,
-for example as `lock_dsn: '%env(LOCK_DSN)%'` with an environment variable `LOCK_DSN` in your `.env` file or by any other means.
-
-For example, you could use [Redis](https://redis.io/) for distributed locking or `semaphore` for local locking.
-
-Example:
-
-```dotenv
-# Redis (distributed locking)
-LOCK_DSN=redis://redis:6379/
-
-# Semaphore (local locking)
-LOCK_DSN=semaphore
-```
-
-### Symfony Messenger
-
-For projects that also use the [Symfony Messenger](https://symfony.com/doc/current/components/messenger.html)
-you need to set above `messenger_transport_dsn` config, for example as `messenger_transport_dsn: '%env(MESSENGER_TRANSPORT_DSN)%'`
-with an environment variable `MESSENGER_TRANSPORT_DSN` in your `.env` file or by any other means.
-
-[Redis](https://redis.io/) is also a way for doing this.
-
-Example:
-
-```dotenv
-MESSENGER_TRANSPORT_DSN=redis://redis:6379/local-messages/symfony/consumer?auto_setup=true&serializer=1&stream_max_entries=0&dbindex=0
-```
-
-You need to have a system in place to run the [Symfony Messenger](https://symfony.com/doc/current/components/messenger.html).
-Symfony recommends to use [Supervisor](http://supervisord.org/) to do this. You can use
-[Supervisor configuration](https://symfony.com/doc/current/messenger.html#supervisor-configuration) to help you with the setup process.
-
-Keep in mind that you need to **restart** the Symfony Messenger **workers** when you **deploy** Relay API **updates**
-to your server, so changes to the messaging system can be picked up.
-
-If you are using Supervisor to run the Symfony Messenger you can just stop the workers with
-`php bin/console messenger:stop-workers`, Supervisor will start them again.
+Docs: see <https://gitlab.tugraz.at/dbp/relay/dbp-relay-core-bundle/-/tree/main/docs>
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 0000000000000000000000000000000000000000..9522c0a860331ad734f7a4370c9880e8434c9ca2
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,61 @@
+# Bundle Configuration
+
+Created via `./bin/console config:dump-reference DbpRelayCoreBundle | sed '/^$/d'`
+
+```yaml
+# Default configuration for "DbpRelayCoreBundle"
+dbp_relay_core:
+    # Some string identifying the current build (commit hash)
+    build_info:           ~ # Example: deadbeef
+    # Some URL identifying the current build (URL to the commit on some git web interface)
+    build_info_url:       ~ # Example: 'https://gitlab.example.com/project/-/commit/deadbeef'
+    # The title text of the API docs page
+    docs_title:           'Relay API Gateway'
+    # The description text of the API docs page (supports markdown)
+    docs_description:     '*part of the [Digital Blueprint](https://gitlab.tugraz.at/dbp) project*'
+    # See https://symfony.com/doc/5.3/messenger.html#redis-transport
+    messenger_transport_dsn: '' # Example: 'redis://localhost:6379/messages'
+    # https://symfony.com/doc/5.3/components/lock.html
+    lock_dsn:             '' # Example: 'redis://redis:6379'
+```
+
+## Locking
+
+To handle [locking](https://symfony.com/doc/current/components/lock.html) you need to set above `lock_dsn` config,
+for example as `lock_dsn: '%env(LOCK_DSN)%'` with an environment variable `LOCK_DSN` in your `.env` file or by any other means.
+
+For example, you could use [Redis](https://redis.io/) for distributed locking or `semaphore` for local locking.
+
+Example:
+
+```dotenv
+# Redis (distributed locking)
+LOCK_DSN=redis://redis:6379/
+
+# Semaphore (local locking)
+LOCK_DSN=semaphore
+```
+
+## Symfony Messenger
+
+For projects that also use the [Symfony Messenger](https://symfony.com/doc/current/components/messenger.html)
+you need to set above `messenger_transport_dsn` config, for example as `messenger_transport_dsn: '%env(MESSENGER_TRANSPORT_DSN)%'`
+with an environment variable `MESSENGER_TRANSPORT_DSN` in your `.env` file or by any other means.
+
+[Redis](https://redis.io/) is also a way for doing this.
+
+Example:
+
+```dotenv
+MESSENGER_TRANSPORT_DSN=redis://redis:6379/local-messages/symfony/consumer?auto_setup=true&serializer=1&stream_max_entries=0&dbindex=0
+```
+
+You need to have a system in place to run the [Symfony Messenger](https://symfony.com/doc/current/components/messenger.html).
+Symfony recommends to use [Supervisor](http://supervisord.org/) to do this. You can use
+[Supervisor configuration](https://symfony.com/doc/current/messenger.html#supervisor-configuration) to help you with the setup process.
+
+Keep in mind that you need to **restart** the Symfony Messenger **workers** when you **deploy** Relay API **updates**
+to your server, so changes to the messaging system can be picked up.
+
+If you are using Supervisor to run the Symfony Messenger you can just stop the workers with
+`php bin/console messenger:stop-workers`, Supervisor will start them again.
diff --git a/docs/cron.md b/docs/cron.md
new file mode 100644
index 0000000000000000000000000000000000000000..d3b06e61e1c2e579ef60e1bedc24946f041e01eb
--- /dev/null
+++ b/docs/cron.md
@@ -0,0 +1,42 @@
+# Cron Jobs
+
+The API gateway provides one shared cron command which you should call every few
+minutes:
+
+```bash
+./bin/console dbp:relay:core:cron
+```
+
+For example in crontab, every 5 minutes:
+
+```bash
+*/5 * * * * /srv/api/bin/console dbp:relay:core:cron
+```
+
+This cron job will regularly prune caches and dispatch a cron event which can be
+handled by different bundles.
+
+To get access to such an even you have to implement an event listener:
+
+```yaml
+  Dbp\Relay\MyBundle\Cron\CleanupJob:
+    tags:
+      - { name: kernel.event_listener, event: dbp.relay.cron }
+```
+
+The listener gets called with a `CronEvent` object. By calling
+`CronEvent::isDue()` and passing an ID for logging and a  [cron
+expression](https://en.wikipedia.org/wiki/Cron) you get told when it is time to
+run:
+
+```php
+class CleanupJob
+{
+    public function onDbpRelayCron(CronEvent $event)
+    {
+        if ($event->isDue('mybundle-cleanup', '0 * * * *')) {
+            // Do cleanup things here..
+        }
+    }
+}
+```
diff --git a/docs/errors.md b/docs/errors.md
new file mode 100644
index 0000000000000000000000000000000000000000..0fcf0b2a13c557232c5069797122734ecdbb4d87
--- /dev/null
+++ b/docs/errors.md
@@ -0,0 +1,57 @@
+# API Errors and Error Handling
+
+By default Symfony and API Platform convert `HttpException` and all subclasses
+to HTTP errors with a matching status code. See
+https://api-platform.com/docs/core/errors for details.
+
+Since API Platform by default hides any message details for >= 500 and < 600 in
+production and doesn't allow injecting any extra information into the resulting
+JSON-LD error response we provide a special HttpException subclass which
+provides those features.
+
+The following will pass the error message to the client even in case the status
+code is 5xx. Note that you have to be careful to not include any secrets in the
+error message since they would be exposed to the client.
+
+```php
+use Dbp\Relay\CoreBundle\Exception\ApiError;
+
+throw new APIError(500, 'My custom message');
+```
+
+which results in:
+
+```json
+{
+  "@context": "/contexts/Error",
+  "@type": "hydra:Error",
+  "hydra:title": "An error occurred",
+  "hydra:description": "My custom message"
+}
+```
+
+Further more you can include extra information like an error ID and some extra
+information in form of an object:
+
+```php
+use Dbp\Relay\CoreBundle\Exception\ApiError;
+
+throw new APIError::withDetails(500, 'My custom message', 'my-id', ['foo' => 42]);
+```
+
+which results in:
+
+```json
+{
+  "@context": "/contexts/Error",
+  "@type": "hydra:Error",
+  "hydra:title": "An error occurred",
+  "hydra:description": "My custom message",
+  "relay:errorId": "my-id",
+  "relay:errorDetails": {
+    "foo": 42
+  }
+```
+
+If you are using status codes <= 400 and are fine with just the message, then
+using any of the builtin exception types is fine.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000000000000000000000000000000000000..c22ed565d5d60c775443b0b7885e31fcc6c5d92b
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,13 @@
+# About
+
+The core bundle is the central bundle that needs to be installed in every API
+gateway and also is a dependency of every other API bundle.
+
+* It provides functionality that is commonly needed by API bundles (error handling,
+  logging, etc)
+* It integrates the auth bundle with the Symfony security system
+* It provides console commands that API bundles can subscribe to
+* It configures all dependencies to our needs (api-platform, symfony, etc.)
+* and more ...
+
+A minimal working relay API gateway consists of the core bundle and an auth bundle.