diff --git a/tests/test_services.py b/tests/test_services.py
new file mode 100644
index 0000000000000000000000000000000000000000..f078a1b3ca293f2762ebede8d6519cd8674c0418
--- /dev/null
+++ b/tests/test_services.py
@@ -0,0 +1,57 @@
+import datetime
+import unittest
+
+from flask import url_for
+
+# These imports are required, because otherwise we get circular imports?!
+from uffd import ldap, user
+
+from utils import dump, UffdTestCase
+
+class TestServices(UffdTestCase):
+	def setUpApp(self):
+		self.app.config['SERVICES'] = [
+			{
+				'title': 'Service Title',
+				'subtitle': 'Service Subtitle',
+				'description': 'Short description of the service as plain text',
+				'url': 'https://example.com/',
+				'logo_url': '/static/fairy-dust-color.png',
+				'required_group': 'users',
+				'permission_levels': [
+					{'name': 'Moderator', 'required_group': 'moderators'},
+					{'name': 'Admin', 'required_group': 'uffd_admin'},
+				],
+				'confidential': True,
+				'groups': [
+					{'name': 'Group "crew_crew"', 'required_group': 'users'},
+					{'name': 'Group "crew_logistik"', 'required_group': 'uffd_admin'},
+				],
+				'infos': [
+					{'title': 'Documentation', 'html': '<p>Some information about the service as html</p>', 'required_group': 'users'},
+				],
+				'links': [
+					{'title': 'Link to an external site', 'url': '#', 'required_group': 'users'},
+				],
+			},
+			{
+				'title': 'Minimal Service Title',
+			}
+		]
+		self.app.config['SERVICES_PUBLIC'] = True
+
+	def login(self):
+		self.client.post(path=url_for('session.login'),
+			data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
+
+	def test_index(self):
+		r = self.client.get(path=url_for('services.index'))
+		dump('services_index_public', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertNotIn(b'https://example.com/', r.data)
+		self.login()
+		r = self.client.get(path=url_for('services.index'))
+		dump('services_index', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn(b'https://example.com/', r.data)
+
diff --git a/uffd/__init__.py b/uffd/__init__.py
index 720ad165e9e3fe571f738bb796ee4a76ac8c28fe..ab98f736b3035cccf545fa662345a73edeb5e49b 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -39,10 +39,10 @@ def create_app(test_config=None):
 
 	db.init_app(app)
 	# pylint: disable=C0415
-	from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa, oauth2
+	from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa, oauth2, services
 	# pylint: enable=C0415
 
-	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp + oauth2.bp:
+	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp + oauth2.bp + services.bp:
 		app.register_blueprint(i)
 
 	@app.route("/")
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 5ce34d2712abb845893a6e45dce561c9c8adb986..6febfc1a6fdee606baa82da1d424ebc47690bedd 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -41,6 +41,48 @@ OAUTH2_CLIENTS={
 	# ... 'required_group': ['groupa', ['groupb', 'groupc']] ... allows users with group "groupa" as well as users with both "groupb" and "groupc" access
 }
 
+# Service overview page (disabled if empty)
+SERVICES=[
+#	# Title is mandatory, all other fields are optional.
+#	# For permission_levels/groups/infos/links all fields are mandatory aside from required_group.
+#	{
+#		'title': 'Service Title',
+#		'subtitle': 'Service Subtitle',
+#		'description': 'Short description of the service as plain text',
+#		'url': 'https://example.com/',
+#		'logo_url': 'https://example.com/logo.png',
+#		# Basic access group name, service is accessible to everyone if empty
+#		'required_group': 'users',
+#		# Non-basic permission levels, the last matching entry is selected.
+#		# Users with a matching permission level are considered to have
+#		# access to the service (as if they have the basic access group).
+#		'permission_levels': [
+#			{'name': 'Moderator', 'required_group': 'moderators'},
+#			{'name': 'Admin', 'required_group': 'uffd_admin'},
+#		],
+#		# Per default all services are listed publicly (but grayed out for
+#		# guests/users without access). Confidential services are only visible
+#		# to users with access rights to the service.
+#		'confidential': True,
+#		# In-service groups, all matching items are visible
+#		'groups': [
+#			{'name': 'Group "crew_crew"', 'required_group': 'users'},
+#			{'name': 'Group "crew_logistik"', 'required_group': 'uffd_admin'},
+#		],
+#		# Infos are small/medium amounts of information displayed in a modal
+#		# dialog. All matching items are visible.
+#		'infos': [
+#			{'title': 'Documentation', 'html': '<p>Some information about the service as html</p>', 'required_group': 'users'},
+#		],
+#		# Links to external sites, all matching items are visible
+#		'links': [
+#			{'title': 'Link to an external site', 'url': '#', 'required_group': 'users'},
+#		]
+#	},
+]
+# Enable the service overview page for users who are not logged in
+SERVICES_PUBLIC=True
+
 # do NOT set in production
 
 #TEMPLATES_AUTO_RELOAD=True
diff --git a/uffd/services/__init__.py b/uffd/services/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..656390049779db11f3fbd9a16498218b18982068
--- /dev/null
+++ b/uffd/services/__init__.py
@@ -0,0 +1,3 @@
+from .views import bp as _bp
+
+bp = [_bp]
diff --git a/uffd/services/templates/overview.html b/uffd/services/templates/overview.html
new file mode 100644
index 0000000000000000000000000000000000000000..f68eba642e4e92569ed2d2a0833e56f3f29b3cbc
--- /dev/null
+++ b/uffd/services/templates/overview.html
@@ -0,0 +1,83 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+{% set iconstyle = 'style="width: 1.8em;"'|safe %}
+
+{% if not user %}
+<div class="alert alert-warning" role="alert">Some services may not be publicly listed! Log in to see all services you have access to.</div>
+{% endif %}
+
+{% macro service_card(service) %}
+  <div class="col mb-4">
+    <div class="card h-100 {{ 'text-muted' if not service.has_access }}">
+      <div class="card-body">
+				{% if service.logo_url %}
+					{% if service.url and service.has_access %}<a href="{{ service.url }}" class="text-reset">{% endif %}
+						<img alt="Logo for {{ service.title }}" src="{{ service.logo_url }}" style="width: 100%; height: 10em; {{ 'filter: grayscale(100%);' if not service.has_access }}">
+					{% if service.url and service.has_access %}</a>{% endif %}
+				{% endif %}
+        <h5 class="card-title">
+					{% if service.url and service.has_access %}
+						<a href="{{ service.url }}" class="text-reset">{{ service.title }}</a>
+					{% else %}
+						{{ service.title }}
+					{% endif %}
+				</h5>
+				{% if service.subtitle %}
+					<h6 class="card-subtitle mb-2 text-muted">{{ service.subtitle }}</h6>
+				{% endif %}
+				{% if service.description %}
+					<p class="card-text">{{ service.description }}</p>
+				{% endif %}
+      </div>
+			<div class="list-group list-group-flush">
+				{% if not service.has_access %}
+					<div class="list-group-item"><i class="fas fa-shield-alt" {{ iconstyle }}></i> No access</div>
+				{% elif service.permission %}
+					<div class="list-group-item"><i class="fas fa-shield-alt" {{ iconstyle }}></i> {{ service.permission }}</div>
+				{% endif %}
+				{% for group in service.groups %}
+					<div class="list-group-item"><i class="fas fa-users" {{ iconstyle }}></i> {{ group.name }}</div>
+				{% endfor %}
+				{% for info in service.infos %}
+					<a href="#" class="list-group-item list-group-item-action" data-toggle="modal" data-target="#info-modal-{{ info.id }}"><i class="fas fa-info-circle" {{ iconstyle }}></i> {{ info.title }}</a>
+				{% endfor %}
+				{% for link in service.links %}
+					<a href="{{ link.url }}" class="list-group-item list-group-item-action"><i class="fas fa-external-link-alt" {{ iconstyle }}></i> {{ link.title }}</a>
+				{% endfor %}
+			</div>
+    </div>
+  </div>
+{% endmacro %}
+
+<div class="row row-cols-2 row-cols-md-3 mt-2">
+	{% for service in services if service.has_access %}
+		{{ service_card(service) }}
+	{% endfor %}
+	{% for service in services if not service.has_access %}
+		{{ service_card(service) }}
+	{% endfor %}
+</div>
+
+{% for service in services %}
+{% for info in service.infos %}
+<div class="modal" tabindex="-1" id="info-modal-{{ info.id }}">
+	<div class="modal-dialog">
+		<div class="modal-content">
+			<div class="modal-header">
+				<h5 class="modal-title">{{ info.title }}</h5>
+				<button type="button" class="close" data-dismiss="modal" aria-label="Close">
+					<span aria-hidden="true">&times;</span>
+				</button>
+			</div>
+			<div class="modal-body">
+				{{ info.html|safe }}
+			</div>
+		</div>
+	</div>
+</div>
+{% endfor %}
+{% endfor %}
+
+{% endblock %}
diff --git a/uffd/services/views.py b/uffd/services/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..48fcf795bd52e41ce71897a8ef7eccfebdfd007c
--- /dev/null
+++ b/uffd/services/views.py
@@ -0,0 +1,85 @@
+from flask import Blueprint, render_template, current_app, abort
+
+from uffd.navbar import register_navbar
+from uffd.session import is_valid_session, get_current_user
+
+bp = Blueprint("services", __name__, template_folder='templates', url_prefix='/services')
+
+# pylint: disable=too-many-branches
+def get_services(user=None):
+	if not user and not current_app.config['SERVICES_PUBLIC']:
+		return []
+	services = []
+	for service_data in current_app.config['SERVICES']:
+		if not service_data.get('title'):
+			continue
+		service = {
+			'title': service_data['title'],
+			'subtitle': service_data.get('subtitle', ''),
+			'description': service_data.get('description', ''),
+			'url': service_data.get('url', ''),
+			'logo_url': service_data.get('logo_url', ''),
+			'has_access': True,
+			'permission': '',
+			'groups': [],
+			'infos': [],
+			'links': [],
+		}
+		if service_data.get('required_group'):
+			if not user or not user.is_in_group(service_data['required_group']):
+				service['has_access'] = False
+		for permission_data in service_data.get('permission_levels', []):
+			if permission_data.get('required_group'):
+				if not user or not user.is_in_group(permission_data['required_group']):
+					continue
+			if not permission_data.get('name'):
+				continue
+			service['has_access'] = True
+			service['permission'] = permission_data['name']
+		if service_data.get('confidential', False) and not service['has_access']:
+			continue
+		for group_data in service_data.get('groups', []):
+			if group_data.get('required_group'):
+				if not user or not user.is_in_group(group_data['required_group']):
+					continue
+			if not group_data.get('name'):
+				continue
+			service['groups'].append(group_data)
+		for info_data in service_data.get('infos', []):
+			if info_data.get('required_group'):
+				if not user or not user.is_in_group(info_data['required_group']):
+					continue
+			if not info_data.get('title') or not info_data.get('html'):
+				continue
+			info = {
+				'title': info_data['title'],
+				'html': info_data['html'],
+				'id': '%d-%d'%(len(services), len(service['infos'])),
+			}
+			service['infos'].append(info)
+		for link_data in service_data.get('links', []):
+			if link_data.get('required_group'):
+				if not user or not user.is_in_group(link_data['required_group']):
+					continue
+			if not link_data.get('url') or not link_data.get('title'):
+				continue
+			service['links'].append(link_data)
+		services.append(service)
+	return services
+
+def services_visible():
+	user = None
+	if is_valid_session():
+		user = get_current_user()
+	return len(get_services(user)) > 0
+
+@bp.route("/")
+@register_navbar('Services', icon='sitemap', blueprint=bp, visible=services_visible)
+def index():
+	user = None
+	if is_valid_session():
+		user = get_current_user()
+	services = get_services(user)
+	if not current_app.config['SERVICES']:
+		abort(404)
+	return render_template('overview.html', user=user, services=services)