diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1737d6c7dbc924e361bc115404ec3328f77ea868..a00eaef688041a307224bf5e5a442277cbfe470e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -30,16 +30,15 @@ build:pip:
 build:apt:
   extends: .build
   script:
-    - cp CHANGELOG debian/changelog
-    - export PYBUILD_INSTALL_ARGS="--install-lib=/usr/share/uffd/ --install-scripts=/usr/share/uffd/"
-    - gbp dch --no-git-author --ignore-branch --debian-tag=v%\(version\)s
-    - dpkg-buildpackage -us -uc
-    - mkdir build
-    - mv ../*.deb build/
+  - ./debian/create_changelog.py uffd > debian/changelog
+  - export PYBUILD_INSTALL_ARGS="--install-lib=/usr/share/uffd/ --install-scripts=/usr/share/uffd/"
+  - dpkg-buildpackage -us -uc
+  - mv ../*.deb ./
+  - dpkg-deb -I *.deb
+  - dpkg-deb -c *.deb
   artifacts:
     paths:
-      - build/*.deb
-      - debian/changelog
+    - ./*.deb
 
 db_migrations_updated:
   stage: test
@@ -73,7 +72,6 @@ linter:buster:
     reports:
       codequality: codeclimate.json
 
-
 linter:bullseye:
   image: registry.git.cccv.de/uffd/docker-images/bullseye
   stage: test
@@ -171,20 +169,11 @@ test:package:pip:bullseye:
   dependencies:
   - build:pip
 
-test:package:apt:changelog:
-  stage: test
-  rules:
-  - if: '$CI_COMMIT_TAG =~ /v[0-9]+[.][0-9]+[.][0-9]+.*/'
-  script:
-  - head -n 1 debian/changelog | grep -qv UNRELEASED || { echo 'CHANGELOG not up-to-date!'; exit 1; }
-  dependencies:
-  - build:apt
-
 test:package:apt:buster:
   image: registry.git.cccv.de/uffd/docker-images/buster
   stage: test
   script:
-  - apt -y install ./build/*.deb
+  - apt -y install ./*.deb
   - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; )
   - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini
   - service nginx start || ( service nginx status; nginx -t; exit 1; )
@@ -197,7 +186,7 @@ test:package:apt:bullseye:
   image: registry.git.cccv.de/uffd/docker-images/bullseye
   stage: test
   script:
-  - apt -y install ./build/*.deb
+  - apt -y install ./*.deb
   - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; )
   - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini
   - service nginx start || ( service nginx status; nginx -t; exit 1; )
@@ -222,7 +211,7 @@ publish:pip:
 publish:apt:
   extends: .publish
   script:
-  - export DEBPATH="$(echo build/*.deb)"
+  - export DEBPATH="$(echo *.deb)"
   - echo Upload deb file, add it to repo and clean up upload
   - curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X POST -F file=@"$DEBPATH" "${APT_API_URL}/api/files/${APT_REPO}-ci-upload-${CI_JOB_ID}"
   - curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X POST "${APT_API_URL}/api/repos/${APT_REPO}/file/${APT_REPO}-ci-upload-${CI_JOB_ID}"
diff --git a/CHANGELOG b/CHANGELOG
deleted file mode 100644
index ea3e937d01fa7c49ed71f640620008c51086ac60..0000000000000000000000000000000000000000
--- a/CHANGELOG
+++ /dev/null
@@ -1,112 +0,0 @@
-uffd (1.1.1) unstable; urgency=medium
-
-  [ Julian Rother ]
-  * Fix regression: OAuth2 authorize endpoint rejects empty scope parameter
-  * Fix regression: OAuth2 token endpoint does not support Basic-Auth
-  * Verify 2FA recovery codes and TOTP codes in constant-time
-
- -- root <root@runner-f9u6bnzu-project-27-concurrent-0>  Mon, 13 Sep 2021 20:18:30 +0000
-
-uffd (1.1.0) unstable; urgency=medium
-
-  [ Julian Rother ]
-  * Switched tests from tmpfile to in-memory databases
-  * Catch LDAPSASLPrepError on login
-  * Restrict password alphabet to SASLprep-safe ASCII subset
-  * Replace flask_oauthlib with plain oauthlib
-  * Fix for 45d4598 (Replace flask_oauthlib with plain oauthlib)
-  * Don't display login page if user is already logged in
-  * Display per-client-customizable message on login page
-  * Dedicated error page for permission errors
-  * Fix HTML element id construction in role view
-  * Removed TestUserViewsOLUserAsUser test cases
-  * Make sure that users can only confirm their own verification tokens
-  * Refactor permission checking and differenciate login and selfservice access
-  * Refactor base template and add narrow base template
-  * Handle if user referenced in session does not exist
-  * Support for python3-fido2 v0.9.x (Debian Bullseye)
-  * Support for python3-werkzeug v1.0.x (Debian Bullseye)
-  * Properly rollback db transaction in db_flush (tests)
-  * Fix debian package dependency on python3-oauthlib
-  * Verify OAuth2 codes/tokens in constant-time
-  * Verify invite link secrets in constant-time
-  * Verify selfservice link secrets in constant-time
-  * Verify signup link secrets in constant-time
-  * Verify api keys in constant-time
-  * Explain OAuth2 code/token customization hack
-  * Add CI tests for Bullseye and fix remaining compatability issues
-  * Publish Debian packages to packages.cccv.de
-
-  [ C-Tim ]
-  * fix(uffd-admin): Fix bug with util-linux fallback path
-  * fix(migrations): Calculate correct path for migrations instead of assuming cwd
-
-  [ Julian Rother ]
-  * CI check for CHANGELOG on release
-  * Refactor migrations to support MySQL/MariaDB
-  * Add Debian repo signing key and install instructions
-  * Auto-generate SECRET_KEY in Debian package, minor improvement of uffd-admin
-
- -- root <root@runner-f9u6bnzu-project-27-concurrent-0>  Mon, 13 Sep 2021 12:07:04 +0000
-
-uffd (1.0.0) unstable; urgency=medium
-
-  [ nd ]
-  * enable more pylint checks
-  * disable all checks for ldapalchemy and enable duplicate code check
-  * disable cuplicate-code check in pylint again
-
-  [ Julian Rother ]
-  * Fixed typo in German translation (#91)
-  * Made shell context more usable and cleaned up imports in __init__.py
-
-  [ nd ]
-  * refactor selfservice mail sending
-
-  [ Julian Rother ]
-  * Disabled unhelpful deprecation warnings for pytest
-  * Fixed minor html validity error in qr code generation
-  * Moved token generation to common module and introduced token_urlfriendly
-  * Implemented ordering for navbar items
-  * Added api endpoint for mail aliases
-
-  [ Sistason ]
-  * Updated translations readme, made some editorial translation changes and fixed some typos
-
-  [ Julian Rother ]
-  * Made devicelogin button text easier to understand, closes #91
-  * Fixed layout bug in selfservice introduced by 7b94843b
-  * Changed developing status to "production/stable"
-
- -- root <root@runner-f9u6bnzu-project-27-concurrent-0>  Fri, 13 Aug 2021 14:35:19 +0000
-
-uffd (0.3.0) unstable; urgency=medium
-
-  [ nd ]
-  * update link to rocketchat in README
-  * add uffd-admin command, cleanup cronjob and needed /run folder
-  * move package build dependencies to docker image
-  * enable uwsgi app by default and add maintainer script to restart uwsgi
-  * move python dependencies from requirements.txt to setup.py
-  * update changelog for 0.3.0 release
-  * add more warnings against using pip install for production setups
-  * add comment why we ignore the package dependencies extracted by pybild
-  * removed not needed sleep from tests
-
-  [ Julian ]
-  * ensure uffd-admin works with arguments containing whitespace
-
- -- root <root@runner-f9u6bnzu-project-27-concurrent-0>  Sun, 01 Aug 2021 13:27:31 +0000
-
-uffd (0.2.0) unstable; urgency=medium
-
-  [ CCCV ]
-  * Working debian packages
-
- -- root <root@runner-f9u6bnzu-project-26-concurrent-1>  Sat, 31 Jul 2021 19:05:30 +0000
-
-uffd (0.1.2) unstable; urgency=medium
-
-  * Initial release.
-
- -- Andreas Valder <nd@cccv.de>  Fri, 30 Jul 2021 23:02:31 +0200
diff --git a/debian/uffd.cfg b/debian/contrib/uffd.cfg
similarity index 100%
rename from debian/uffd.cfg
rename to debian/contrib/uffd.cfg
diff --git a/debian/create_changelog.py b/debian/create_changelog.py
new file mode 100755
index 0000000000000000000000000000000000000000..4be02a751692dd638a835dc65785491eef2f5f8f
--- /dev/null
+++ b/debian/create_changelog.py
@@ -0,0 +1,88 @@
+#!/usr/bin/python3
+import sys
+import re
+import textwrap
+import datetime
+import email.utils
+
+import git
+
+package_name = 'UNKNOWN'
+
+def print_release(tag=None, commits=tuple(), last_tag=None):
+	release_version = '0.0.0'
+	release_author = git.objects.util.Actor('None', 'undefined@example.com')
+	release_date = 0
+	release_status = 'UNRELEASED'
+	message = ''
+
+	if tag:
+		release_status = 'unstable'
+		release_version = tag.name[1:] # strip leading "v"
+		if isinstance(tag.object, git.TagObject):
+			release_author = tag.object.tagger
+			release_date = tag.object.tagged_date
+			message = tag.object.message.split('-----BEGIN PGP SIGNATURE-----')[0].strip()
+		else:
+			release_author = tag.object.committer
+			release_date = tag.object.committed_date
+	elif commits:
+		release_author = commits[0].committer
+		release_date = commits[0].committed_date
+		date = datetime.datetime.fromtimestamp(release_date).strftime('%Y%m%dT%H%M%S')
+		last_version = '0.0.0'
+		if last_tag:
+			last_version = last_tag.name[1:] # strip leading "v"
+		release_version = f'{last_version}+git{date}-{commits[0].hexsha[:8]}'
+
+	print(f'{package_name} ({release_version}) {release_status}; urgency=medium')
+	print()
+	if message:
+		print(textwrap.indent(message, '  '))
+		print()
+	commit_authors = [] # list of (key, author), sorted by first commit date
+	commit_author_emails = {} # author email -> key
+	commit_author_names = {} # author name -> key
+	commit_author_commits = {} # key -> list of commits
+	for commit in commits:
+		key = commit_author_emails.get(commit.author.email)
+		if key is None:
+			key = commit_author_names.get(commit.author.name)
+		if key is None:
+			key = commit.author.email
+			commit_authors.append((key, commit.author))
+			commit_author_emails[commit.author.email] = key
+			commit_author_names[commit.author.name] = key
+		commit_author_commits[key] = commit_author_commits.get(key, []) + [commit]
+	for key, author in commit_authors:
+		print(f'  [ {author.name} ]')
+		for commit in commit_author_commits[key]:
+			print(f'  * {commit.summary}')
+		print()
+	print(f' -- {release_author.name} <{release_author.email}>  {email.utils.formatdate(release_date)}')
+
+if __name__ == '__main__':
+	repo = git.Repo('.')
+	package_name = sys.argv[1]
+
+	version_commits = {}
+	for tag in repo.tags:
+		if not re.fullmatch('v[0-9]+[.][0-9]+[.][0-9]+.*', tag.name):
+			continue
+		if isinstance(tag.object, git.TagObject):
+			commit_hexsha = tag.object.object.hexsha
+		else:
+			commit_hexsha = tag.object.hexsha
+		version_commits[commit_hexsha] = tag
+
+	tag = None
+	commits = []
+	for commit in repo.iter_commits('HEAD'):
+		if commit.hexsha in version_commits:
+			prev_tag = version_commits[commit.hexsha]
+			print_release(tag, commits, last_tag=prev_tag)
+			print()
+			tag = prev_tag
+			commits = []
+		commits.append(commit)
+	print_release(tag, [])
diff --git a/debian/install b/debian/install
index ac4a56f24427bff23d20b42cfe3541bd101d7666..0bf845aad31438e8fda6b2af62928d4363a03cfc 100644
--- a/debian/install
+++ b/debian/install
@@ -1,4 +1,4 @@
-uwsgi.ini			/etc/uffd/
-nginx.include.conf		/etc/uffd/
-debian/uffd.cfg			/etc/uffd/
-debian/contrib/uffd-admin	/usr/bin/
+uwsgi.ini /etc/uffd/
+nginx.include.conf /etc/uffd/
+debian/contrib/uffd.cfg /etc/uffd/
+debian/contrib/uffd-admin /usr/bin/