diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1596caaa6ccd93c04d3f5af879d081e2f28944ec..87720d088e313e196bc3fd7dbecc9f5488d4c19b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -2,6 +2,9 @@ image: registry.git.cccv.de/uffd/docker-images/buster
 
 variables:
   DEBIAN_FRONTEND: noninteractive 
+  APT_API_URL: https://packages.cccv.de
+  APT_REPO: uffd
+  PYLINT_PIN: pylint~=2.10.0
 
 before_script:
   - python3 -V
@@ -10,6 +13,21 @@ before_script:
   - python3 -m pylint --version
   - python3 -m coverage --version
 
+.build:
+  stage: build
+
+build:apt:
+  extends: .build
+  script:
+  - ./debian/create_changelog.py uffd-nginxauth > debian/changelog
+  - dpkg-buildpackage -us -uc
+  - mv ../*.deb ./
+  - dpkg-deb -I *.deb
+  - dpkg-deb -c *.deb
+  artifacts:
+    paths:
+    - ./*.deb
+
 linter:buster:
   image: registry.git.cccv.de/uffd/docker-images/buster
   stage: test
@@ -79,3 +97,46 @@ unittests:bullseye:
       #cobertura: coverage.xml
       junit: report.xml
   #coverage: '/^TOTAL.*\s+(\d+\%)$/'
+
+test:package:apt:buster:
+  image: registry.git.cccv.de/uffd/docker-images/buster
+  stage: test
+  script:
+  - apt -y install ./*.deb
+  - service uwsgi start uffd-nginxauth || ( service uwsgi status uffd-nginxauth; sleep 15; cat /var/log/uwsgi/app/uffd-nginxauth.log; )
+  - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd-nginxauth/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd-nginxauth.ini
+  - service nginx start || ( service nginx status; nginx -t; exit 1; )
+  - curl -Lv http://127.0.0.1:5000/status
+  dependencies:
+  - build:apt
+
+test:package:apt:bullseye:
+  image: registry.git.cccv.de/uffd/docker-images/bullseye
+  stage: test
+  script:
+  - apt -y install ./*.deb
+  - service uwsgi start uffd-nginxauth || ( service uwsgi status uffd-nginxauth; sleep 15; cat /var/log/uwsgi/app/uffd-nginxauth.log; )
+  - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd-nginxauth/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd-nginxauth.ini
+  - service nginx start || ( service nginx status; nginx -t; exit 1; )
+  - curl -Lv http://127.0.0.1:5000/status
+  dependencies:
+  - build:apt
+
+.publish:
+  stage: deploy
+  rules:
+    - if: '$CI_COMMIT_TAG =~ /v[0-9]+[.][0-9]+[.][0-9]+.*/'
+
+publish:apt:
+  extends: .publish
+  script:
+  - 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}"
+  - curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X DELETE "${APT_API_URL}/api/files/${APT_REPO}-ci-upload-${CI_JOB_ID}"
+  - echo Update published repo for all distros
+  - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/${APT_REPO}/buster"'
+  - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/${APT_REPO}/bullseye"'
+  dependencies:
+  - build:apt
diff --git a/debian/contrib/uffd-nginxauth.cfg b/debian/contrib/uffd-nginxauth.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..6ff5c1b409784d511b649ceff09c768d2d9d3518
--- /dev/null
+++ b/debian/contrib/uffd-nginxauth.cfg
@@ -0,0 +1,7 @@
+FLASK_ENV="production"
+#SECRET=autogenerated by postinst script
+
+# URLs of the OAuth2-based identity provider (must be set!)
+#OAUTH2_AUTH_URL = 'http://localhost:5001/oauth2/authorize'
+#OAUTH2_TOKEN_URL = 'http://localhost:5001/oauth2/token'
+#OAUTH2_USERINFO_URL = 'http://localhost:5001/oauth2/userinfo'
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000000000000000000000000000000000000..758dd041ad2ba93291e76859495c56af089dc993
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,24 @@
+Source: uffd-nginxauth
+Section: python
+Priority: optional
+Maintainer: CCCV <it@cccv.de>
+Build-Depends:
+ debhelper-compat (= 12),
+ dh-python,
+ python3-all,
+ python3-setuptools,
+Standards-Version: 4.5.0
+Homepage: https://git.cccv.de/uffd/uffd-nginxauth
+Vcs-Git: https://git.cccv.de/uffd/uffd-nginxauth.git
+
+Package: uffd-nginxauth
+Architecture: all
+Depends:
+ ${misc:Depends},
+ python3-flask,
+ python3-requests-oauthlib,
+ uwsgi,
+ uwsgi-plugin-python3,
+Recommends:
+ nginx,
+Description: Small web service for adding uffd OAuth2 authentication to nginx
diff --git a/debian/create_changelog.py b/debian/create_changelog.py
new file mode 100755
index 0000000000000000000000000000000000000000..b62f211c24403fbfb2a1465cbf58491b5616f5b6
--- /dev/null
+++ b/debian/create_changelog.py
@@ -0,0 +1,106 @@
+#!/usr/bin/python3
+import sys
+import re
+import textwrap
+import datetime
+import email.utils
+
+import git
+
+package_name = 'UNKNOWN'
+
+alias_names = {
+	'julian': 'Julian Rother',
+	'Julian': 'Julian Rother',
+}
+
+ignore_commit_regexes = [
+	'^fixup!',
+]
+
+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:
+		if any(filter(lambda pattern: re.match(pattern, commit.summary), ignore_commit_regexes)):
+			continue
+		if len(commit.parents) > 1:
+			continue # Ignore merge commits
+		author_name = alias_names.get(commit.author.name, commit.author.name)
+		key = commit_author_emails.get(commit.author.email)
+		if key is None:
+			key = commit_author_names.get(author_name)
+		if key is None:
+			key = commit.author.email
+			commit_authors.append((key, author_name))
+		commit_author_emails[commit.author.email] = key
+		commit_author_names[author_name] = key
+		commit_author_commits[key] = commit_author_commits.get(key, []) + [commit]
+	commit_authors.sort(key=lambda args: len(commit_author_commits[args[0]]))
+	for key, author_name in commit_authors:
+		print(f'  [ {author_name} ]')
+		for commit in commit_author_commits[key]:
+			lines = '\n'.join(textwrap.wrap(commit.summary, 90))
+			lines = '  * ' + textwrap.indent(lines, '    ').strip()
+			print(lines)
+		print()
+	print(f' -- {alias_names.get(release_author.name, 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]
+			if commits:
+				print_release(tag, commits, last_tag=prev_tag)
+				print()
+			tag = prev_tag
+			commits = []
+		commits.append(commit)
+	print_release(tag, commits)
diff --git a/debian/install b/debian/install
new file mode 100644
index 0000000000000000000000000000000000000000..00fab7fe5da70870e5bd3aa9d712ba289dab269e
--- /dev/null
+++ b/debian/install
@@ -0,0 +1,5 @@
+uwsgi.ini /etc/uffd-nginxauth/
+nginx.include.conf /etc/uffd-nginxauth/
+debian/contrib/uffd-nginxauth.cfg /etc/uffd-nginxauth/
+app.py /usr/share/uffd-nginxauth/
+default_config.py /usr/share/uffd-nginxauth/
diff --git a/debian/links b/debian/links
new file mode 100644
index 0000000000000000000000000000000000000000..78050aa3a0db1c494b33e89468bd967f97521ceb
--- /dev/null
+++ b/debian/links
@@ -0,0 +1,3 @@
+/etc/uffd-nginxauth/uffd-nginxauth.cfg /usr/share/uffd-nginxauth/instance/config.cfg
+/etc/uffd-nginxauth/uwsgi.ini /etc/uwsgi/apps-available/uffd-nginxauth.ini
+/etc/uwsgi/apps-available/uffd-nginxauth.ini /etc/uwsgi/apps-enabled/uffd-nginxauth.ini
diff --git a/debian/postinst b/debian/postinst
new file mode 100755
index 0000000000000000000000000000000000000000..176bc6e9597668a4e776b3840f4b1b511a6f6d07
--- /dev/null
+++ b/debian/postinst
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+set -e
+
+case "$1" in
+	configure)
+		getent group uffd-nginxauth >/dev/null 2>&1 || addgroup --system uffd-nginxauth
+		adduser --system --home /var/lib/uffd-nginxauth --quiet uffd-nginxauth --ingroup uffd-nginxauth || true
+
+		chown -R uffd-nginxauth:uffd-nginxauth /var/lib/uffd-nginxauth
+		chmod 0770 /var/lib/uffd-nginxauth
+
+		chown root:uffd-nginxauth /etc/uffd-nginxauth/uffd-nginxauth.cfg
+		chmod 0640 /etc/uffd-nginxauth/uffd-nginxauth.cfg
+
+		invoke-rc.d uwsgi restart uffd-nginxauth
+	;;
+
+	abort-upgrade|abort-remove|abort-deconfigure)
+	;;
+
+	*)
+		echo "postinst called with unknown argument \`$1'" >&2
+		exit 1
+	;;
+esac
+
+#DEBHELPER#
+
+exit 0
diff --git a/debian/postrm b/debian/postrm
new file mode 100755
index 0000000000000000000000000000000000000000..c11868c7f50a7bf9fd18c6c380e48f1a87109f76
--- /dev/null
+++ b/debian/postrm
@@ -0,0 +1,19 @@
+#!/bin/sh
+
+set -e
+
+case "$1" in
+	purge)
+		delgroup uffd-nginxauth || true
+		userdel uffd-nginxauth --remove-home || true
+	;;
+	remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
+	;;
+	*)
+		echo "postrm called with unknown argument \`$1'" >&2
+		exit 1
+esac
+
+#DEBHELPER#
+
+exit 0
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000000000000000000000000000000000000..cbe925d7587131c8ec8761915930894c60c096fd
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,3 @@
+#!/usr/bin/make -f
+%:
+	dh $@
diff --git a/nginx.include.conf b/nginx.include.conf
new file mode 100644
index 0000000000000000000000000000000000000000..910737826091ce9a13e0a9e7df69f12770e89d4f
--- /dev/null
+++ b/nginx.include.conf
@@ -0,0 +1,4 @@
+location / {
+	uwsgi_pass unix:///run/uwsgi/app/uffd-nginxauth/socket;
+	include uwsgi_params;
+}
diff --git a/uwsgi.ini b/uwsgi.ini
new file mode 100644
index 0000000000000000000000000000000000000000..971017a339e43a5b7b95a07b56c107ca633bb2a4
--- /dev/null
+++ b/uwsgi.ini
@@ -0,0 +1,16 @@
+[uwsgi]
+plugin = python3
+manage-script-name = true
+workers = %k*2
+
+uid = uffd-nginxauth
+gid = uffd-nginxauth
+
+vacuum = true
+
+env = PYTHONIOENCODING=UTF-8
+env = LANG=en_GB.utf8
+env = TZ=Europe/Berlin
+env = CONFIG_FILENAME=/etc/uffd-nginxauth/uffd-nginxauth.cfg
+chdir = /usr/share/uffd-nginxauth
+module = app:create_app()