diff --git a/Dockerfile b/Dockerfile
index 72447ae0b2efa114b3073f0838a817ccd769912e..9b1805ea8974f8523cc36bcc23eb03d211fd4361 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,6 +31,7 @@ RUN apt-get -y update && \
 	libpq-dev \
 	libssl-dev \
 	netcat-openbsd \
+	postgresql-client \
 	yarnpkg && \
     eatmydata pip install uwsgi django-compressor
 
diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index f4599d4d0e13ac02d01fda147331a3997d0f8dd0..feb7d996b7cca83c040ef7add8a5355e82335e82 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -1,8 +1,11 @@
 from datetime import datetime, time
+from typing import Callable, Dict, List, Sequence, Tuple
 
 from django import forms
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
+from django.db.models import QuerySet
+from django.http import HttpRequest
 from django.utils.translation import gettext_lazy as _
 
 from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget
@@ -370,3 +373,144 @@ class DashboardWidgetOrderForm(ExtensibleForm):
 DashboardWidgetOrderFormSet = forms.formset_factory(
     form=DashboardWidgetOrderForm, max_num=0, extra=0
 )
+
+
+class ActionForm(forms.Form):
+    """Generic form for executing actions on multiple items of a queryset.
+
+    This should be used together with a ``Table`` from django-tables2
+    which includes a ``SelectColumn``.
+
+    The queryset can be defined in two different ways:
+    You can use ``get_queryset`` or provide ``queryset`` as keyword argument
+    at the initialization of this form class.
+    If both are declared, it will use the keyword argument.
+
+    Any actions can be defined using the ``actions`` class attribute
+    or overriding the method ``get_actions``.
+    The actions use the same syntax like the Django Admin actions with one important difference:
+    Instead of the related model admin,
+    these actions will get the related ``ActionForm`` as first argument.
+    Here you can see an example for such an action:
+
+    .. code-block:: python
+
+        from django.utils.translation import gettext as _
+
+        def example_action(form, request, queryset):
+            # Do something with this queryset
+
+        example_action.short_description = _("Example action")
+
+    If you can include the ``ActionForm`` like any other form in your views,
+    but you must add the request as first argument.
+    When the form is valid, you should run ``execute``:
+
+    .. code-block:: python
+
+        from aleksis.core.forms import ActionForm
+
+        def your_view(request, ...):
+            # Something
+            action_form = ActionForm(request, request.POST or None, ...)
+            if request.method == "POST" and form.is_valid():
+                form.execute()
+
+            # Something
+    """
+
+    layout = Layout("action")
+    actions = []
+
+    def get_actions(self) -> Sequence[Callable]:
+        """Get all defined actions."""
+        return self.actions
+
+    def _get_actions_dict(self) -> Dict[str, Callable]:
+        """Get all defined actions as dictionary."""
+        return {value.__name__: value for value in self.get_actions()}
+
+    def _get_action_choices(self) -> List[Tuple[str, str]]:
+        """Get all defined actions as Django choices."""
+        return [
+            (value.__name__, getattr(value, "short_description", value.__name__))
+            for value in self.get_actions()
+        ]
+
+    def get_queryset(self) -> QuerySet:
+        """Get the related queryset."""
+        raise NotImplementedError("Queryset necessary.")
+
+    action = forms.ChoiceField(choices=[])
+    selected_objects = forms.ModelMultipleChoiceField(queryset=None)
+
+    def __init__(self, request: HttpRequest, *args, queryset: QuerySet = None, **kwargs):
+        self.request = request
+        self.queryset = queryset if isinstance(queryset, QuerySet) else self.get_queryset()
+        super().__init__(*args, **kwargs)
+        self.fields["selected_objects"].queryset = self.queryset
+        self.fields["action"].choices = self._get_action_choices()
+
+    def execute(self) -> bool:
+        """Execute the selected action on all selected objects.
+
+        :return: If the form is not valid, it will return ``False``.
+        """
+        if self.is_valid():
+            data = self.cleaned_data["selected_objects"]
+            action = self._get_actions_dict()[self.cleaned_data["action"]]
+            action(None, self.request, data)
+            return True
+        return False
+
+
+class ListActionForm(ActionForm):
+    """Generic form for executing actions on multiple items of a list of dictionaries.
+
+    Sometimes you want to implement actions for data from different sources
+    than querysets or even querysets from multiple models. For these cases,
+    you can use this form.
+
+    To provide an unique identification of each item, the dictionaries **must**
+    include the attribute ``pk``. This attribute has to be unique for the whole list.
+    If you don't mind this aspect, this will cause unexpected behavior.
+
+    Any actions can be defined as described in ``ActionForm``, but, of course,
+    the last argument won't be a queryset but a list of dictionaries.
+
+    For further information on usage, you can take a look at ``ActionForm``.
+    """
+
+    selected_objects = forms.MultipleChoiceField(choices=[])
+
+    def get_queryset(self):
+        # Return None in order not to raise an unwanted exception
+        return None
+
+    def _get_dict(self) -> Dict[str, dict]:
+        """Get the items sorted by pk attribute."""
+        return {item["pk"]: item for item in self.items}
+
+    def _get_choices(self) -> List[Tuple[str, str]]:
+        """Get the items as Django choices."""
+        return [(item["pk"], item["pk"]) for item in self.items]
+
+    def _get_real_items(self, items: Sequence[dict]) -> List[dict]:
+        """Get the real dictionaries from a list of pks."""
+        items_dict = self._get_dict()
+        real_items = []
+        for item in items:
+            if item not in items_dict:
+                raise ValidationError(_("No valid selection."))
+            real_items.append(items_dict[item])
+        return real_items
+
+    def clean_selected_objects(self) -> List[dict]:
+        data = self.cleaned_data["selected_objects"]
+        items = self._get_real_items(data)
+        return items
+
+    def __init__(self, request: HttpRequest, items, *args, **kwargs):
+        self.items = items
+        super().__init__(request, *args, **kwargs)
+        self.fields["selected_objects"].choices = self._get_choices()
diff --git a/aleksis/core/static/js/multi_select.js b/aleksis/core/static/js/multi_select.js
new file mode 100644
index 0000000000000000000000000000000000000000..cddf911b5f50be2217075d2c0f34191adab6f72e
--- /dev/null
+++ b/aleksis/core/static/js/multi_select.js
@@ -0,0 +1,48 @@
+$(document).ready(function () {
+    $(".select--header-box").change(function () {
+        /*
+        If the top checkbox is checked, all sub checkboxes should be checked,
+        if it gets unchecked, all other ones should get unchecked.
+        */
+        if ($(this).is(":checked")) {
+            $(this).closest("table").find('input[name="selected_objects"]').prop({
+                indeterminate: false,
+                checked: true,
+            });
+        } else {
+            $(this).closest("table").find('input[name="selected_objects"]').prop({
+                indeterminate: false,
+                checked: false,
+            });
+        }
+    });
+
+    $('input[name="selected_objects"]').change(function () {
+        /*
+        If a table checkbox changes, check the state of the other ones.
+        If all boxes are checked the box in the header should be checked,
+        if all boxes are unchecked the header box should be unchecked. If
+        only some boxes are checked the top one should be inderteminate.
+         */
+        let checked = $(this).is(":checked");
+        let indeterminate = false;
+        let table = $(this).closest("table");
+        table.find('input[name="selected_objects"]').each(function () {
+            if ($(this).is(":checked") !== checked) {
+                /* Set the header box to indeterminate if the boxes are not the same */
+                table.find(".select--header-box").prop({
+                    indeterminate: true,
+                })
+                indeterminate = true;
+                return false;
+            }
+        });
+        if (!(indeterminate)) {
+            /* All boxes are the same, set the header box to the same value */
+            table.find(".select--header-box").prop({
+                indeterminate: false,
+                checked: checked,
+            });
+        }
+    });
+});
diff --git a/aleksis/core/util/tables.py b/aleksis/core/util/tables.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bb9fd5c0b4212a1361ebd12b6acd5087731ca58
--- /dev/null
+++ b/aleksis/core/util/tables.py
@@ -0,0 +1,46 @@
+from django.utils.safestring import mark_safe
+
+from django_tables2 import CheckBoxColumn
+from django_tables2.utils import A, AttributeDict, computed_values
+
+
+class MaterializeCheckboxColumn(CheckBoxColumn):
+    """Checkbox column with Materialize support."""
+
+    empty_values = ()
+
+    @property
+    def header(self):
+        """Render the header cell."""
+        default = {"type": "checkbox"}
+        general = self.attrs.get("input")
+        specific = self.attrs.get("th__input")
+        attrs = AttributeDict(default, **(specific or general or {}))
+        return mark_safe("<label><input %s/><span></span></label>" % attrs.as_html())  # noqa
+
+    def render(self, value, bound_column, record):
+        """Render a data cell."""
+        default = {"type": "checkbox", "name": bound_column.name, "value": value}
+        if self.is_checked(value, record):
+            default.update({"checked": "checked"})
+
+        general = self.attrs.get("input")
+        specific = self.attrs.get("td__input")
+
+        attrs = dict(default, **(specific or general or {}))
+        attrs = computed_values(attrs, kwargs={"record": record, "value": value})
+        return mark_safe(  # noqa
+            "<label><input %s/><span></span</label>" % AttributeDict(attrs).as_html()
+        )
+
+
+class SelectColumn(MaterializeCheckboxColumn):
+    """Column with a check box prepared for `ActionForm` forms."""
+
+    def __init__(self, *args, **kwargs):
+        kwargs["attrs"] = {
+            "td__input": {"name": "selected_objects"},
+            "th__input": {"class": "select--header-box"},
+        }
+        kwargs.setdefault("accessor", A("pk"))
+        super().__init__(*args, **kwargs)
diff --git a/docker-startup.sh b/docker-startup.sh
index f0fd18ee63353f6867ea455e6190ac57a0695ee1..19ac40558e38421062be63bc178056d37a5e22b1 100755
--- a/docker-startup.sh
+++ b/docker-startup.sh
@@ -1,9 +1,7 @@
 #!/bin/bash
 
-HTTP_PORT=${HTTP_PORT:8000}
-
-export ALEKSIS_database__host=${ALEKSIS_database__host:-127.0.0.1}
-export ALEKSIS_database__port=${ALEKSIS_database__port:-5432}
+HTTP_PORT=${HTTP_PORT:-8000}
+RUN_MODE=uwsgi
 
 if [[ -z $ALEKSIS_secret_key ]]; then
     if [[ ! -e /var/lib/aleksis/secret_key ]]; then
@@ -13,13 +11,34 @@ if [[ -z $ALEKSIS_secret_key ]]; then
     ALEKSIS_secret_key=$(</var/lib/aleksis/secret_key)
 fi
 
-while ! nc -z $ALEKSIS_database__host $ALEKSIS_database__port; do
-    sleep 0.1
+echo -n "Waiting for database."
+while ! aleksis-admin dbshell -- -c "SELECT 1" >/dev/null 2>&1; do
+    sleep 0.5
+    echo -n .
 done
+echo
 
-aleksis-admin migrate
-aleksis-admin createinitialrevisions
 aleksis-admin compilescss
 aleksis-admin collectstatic --no-input --clear
 
-exec aleksis-admin runuwsgi -- --http-socket=:$HTTP_PORT
+case "$RUN_MODE" in
+    uwsgi)
+	aleksis-admin migrate
+	aleksis-admin createinitialrevisions
+	aleksis-admin compilescss
+	aleksis-admin collectstatic --no-input --clear
+	exec aleksis-admin runuwsgi -- --http-socket=:$HTTP_PORT
+        ;;
+    celery-worker)
+	aleksis-admin migrate
+	aleksis-admin createinitialrevisions
+	exec celery -A aleksis.core worker
+	;;
+    celery-beat)
+	aleksis-admin migrate
+	exec celery -A aleksis.core beat
+	;;
+    *)
+	exec "$@"
+	;;
+esac