diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..3fc88db6c5f45585b338f6f1746dd797e05d9c95
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.venv
+.envrc
+static_files/
+media_files/
+node_modules/
+dist/
+majak_uistyleguide/collectedstatic
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6e89260ba92adabe1cb90bb33d4dd86fec5af834..b46436dc8c7eb25323ba42143fc6f29290b13cc3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,7 +1,11 @@
+stages:
+  - build
+
 image: docker:20.10.9
 
 variables:
   DOCKER_TLS_CERTDIR: "/certs"
+  IMAGE_TAG_APP: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
 
 services:
   - docker:20.10.9-dind
@@ -9,11 +13,9 @@ services:
 before_script:
   - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
 
-build:
+build_app:
   stage: build
   script:
-    - VERSION=`cat VERSION`
-    - docker pull $CI_REGISTRY_IMAGE:latest || true
-    - docker build --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$VERSION --tag $CI_REGISTRY_IMAGE:latest .
-    - docker push $CI_REGISTRY_IMAGE:$VERSION
-    - docker push $CI_REGISTRY_IMAGE:latest
+    - docker pull $CI_REGISTRY_IMAGE:test || true
+    - docker build --cache-from $CI_REGISTRY_IMAGE:test -t $IMAGE_TAG_APP .
+    - docker push $IMAGE_TAG_APP
diff --git a/Dockerfile b/Dockerfile
index 6793d7710d4981bc82535d2b85cb2289dc820f23..838966ae6e5dab129f15a5badbb3c0ecfe079d00 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,36 +1,29 @@
-FROM python:3.11
+FROM node:21
+
+RUN apt-get update \
+    && apt-get install -y python3 python3-pip \
+    && rm -rf /var/lib/apt/lists/*
 
 RUN mkdir /app
 WORKDIR /app
 
-# Install NodeJS
-ENV NODE_MAJOR=20
-RUN apt-get update
-RUN apt-get install -y ca-certificates curl gnupg
-RUN mkdir -p /etc/apt/keyrings
-RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
-RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
-RUN apt-get update
-RUN apt-get install -y nodejs
-RUN rm -rf /var/lib/apt/lists/*
+COPY requirements requirements/
+RUN pip3 install --break-system-packages -r requirements/base.txt -r requirements/prod.txt
 
 COPY . .
 
-RUN pip install -r requirements/base.txt
-RUN npm install
-RUN npm run prod
-
-# Placeholder values so the static files collect
-RUN DJANGO_ALLOWED_HOSTS=x \
-    DJANGO_SECRET_KEY=x \
-    python manage.py collectstatic --noinput --settings=majak_uistyleguide.settings.production
-
-RUN bash -c "adduser --disabled-login --quiet --gecos app app &&  \
-             chmod -R o+r /app/ && \
-             chmod o+x /app/run.sh"
+RUN bash -c 'adduser --disabled-login --quiet --gecos app app &&  \
+             chown -R app:app /app/ && \
+             chmod o+x /app/run.sh'
 USER app
 
+RUN npm i
+RUN npm run prod
+
 ENV DJANGO_SETTINGS_MODULE "majak_uistyleguide.settings.production"
+# fake values for required env variables used to run collectstatic during build
+RUN DJANGO_SECRET_KEY=x DATABASE_URL=postgres://x/x DJANGO_ALLOWED_HOSTS=x \
+    python3 manage.py collectstatic
 
 EXPOSE 8000
 
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..e8b7f73c1fd30f3a9af1112694d0f4afba689984
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,56 @@
+#!/usr/bin/make -f
+
+PYTHON = python
+VENV   = .venv
+PORT   = 8009
+
+help:
+	@echo "Setup:"
+	@echo "  venv           Setup virtual environment"
+	@echo "  install        Install dependencies to venv"
+	@echo "  install-hooks  Install pre-commit hooks"
+	@echo "  hooks          Run pre-commit hooks manually"
+	@echo "  upgrade        Upgrade requirements"
+	@echo ""
+	@echo "Application:"
+	@echo "  run            Run the application on port ${PORT}"
+	@echo "  shell          Run Django shell"
+	@echo ""
+	@echo "Database:"
+	@echo "  migrations     Generate migrations"
+	@echo "  migrate        Run migrations"
+	@echo ""
+
+venv: .venv/bin/python
+.venv/bin/python:
+	${PYTHON} -m venv ${VENV}
+
+install: venv
+	${VENV}/bin/pip install -r requirements/base.txt
+
+install-hooks:
+	pre-commit install --install-hooks
+
+hooks:
+	pre-commit run -a
+
+run: venv
+	${VENV}/bin/python manage.py runserver ${PORT}
+
+shell: venv
+	${VENV}/bin/python manage.py shell_plus
+
+migrations: venv
+	${VENV}/bin/python manage.py makemigrations
+
+migrate: venv
+	${VENV}/bin/python manage.py migrate
+
+upgrade:
+	(cd requirements && pip-compile -U base.in)
+	(cd requirements && pip-compile -U prod.in)
+
+
+.PHONY: help venv install install-hooks hooks run shell upgrade migrations migrate
+
+# EOF
diff --git a/majak_uistyleguide/settings/base.py b/majak_uistyleguide/settings/base.py
index 02b7e98e58b75de417658c65892a0c5b7774a92e..8c363fc5a838ac5437937328e7a9fc2973b777a8 100644
--- a/majak_uistyleguide/settings/base.py
+++ b/majak_uistyleguide/settings/base.py
@@ -17,46 +17,41 @@ WSGI_APPLICATION = "majak_uistyleguide.wsgi.application"
 
 # I18N and L10N
 # ------------------------------------------------------------------------------
-LANGUAGE_CODE = 'cs'
+LANGUAGE_CODE = "cs"
 TIME_ZONE = "Europe/Prague"
 USE_I18N = True
 USE_TZ = True
 
 # DATABASES
 # ------------------------------------------------------------------------------
-DATABASES = {
-    'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': ROOT_DIR / 'db.sqlite3',
-    }
-}
-DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+DATABASES = {"default": env.db("DATABASE_URL")}
+DATABASES["default"]["ATOMIC_REQUESTS"] = True
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
 
 # APPS
 # ------------------------------------------------------------------------------
 INSTALLED_APPS = [
-    'django.contrib.admin',
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
-
-    'django_vite',
-    'pattern_library',
+    "django.contrib.admin",
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.sessions",
+    "django.contrib.messages",
+    "django.contrib.staticfiles",
+    "django_vite",
+    "pattern_library",
 ]
 
 # MIDDLEWARE
 # ------------------------------------------------------------------------------
 MIDDLEWARE = [
-    'django.middleware.security.SecurityMiddleware',
+    "django.middleware.security.SecurityMiddleware",
     "whitenoise.middleware.WhiteNoiseMiddleware",
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.common.CommonMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
-    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.middleware.common.CommonMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",
+    "django.middleware.clickjacking.XFrameOptionsMiddleware",
 ]
 
 # TEMPLATES
@@ -78,7 +73,7 @@ TEMPLATES = [
             ],
             "builtins": [
                 "pattern_library.loader_tags",
-                "majak_uistyleguide.templatetags.math"
+                "majak_uistyleguide.templatetags.math",
             ],
         },
     },
@@ -102,8 +97,8 @@ MEDIA_ROOT = str(ROOT_DIR / "media_files")
 # VITE SETTINGS
 # ------------------------------------------------------------------------------
 # Where ViteJS assets are built.
-DJANGO_VITE_ASSETS_PATH = ROOT_DIR / 'dist'
-STATIC_FILES = PROJECT_DIR / 'static'
+DJANGO_VITE_ASSETS_PATH = ROOT_DIR / "dist"
+STATIC_FILES = PROJECT_DIR / "static"
 
 # If use HMR or not.
 DJANGO_VITE_DEV_MODE = False
@@ -113,7 +108,7 @@ STATIC_ROOT = PROJECT_DIR / "collectedstatic"
 
 # Include DJANGO_VITE_ASSETS_PATH into STATICFILES_DIRS to be copied inside
 # when run command python manage.py collectstatic
-SRC_PATH = ROOT_DIR / 'src'
+SRC_PATH = ROOT_DIR / "src"
 STATICFILES_DIRS = [DJANGO_VITE_ASSETS_PATH, STATIC_FILES, SRC_PATH]
 
 # PATTERN LIBRARY SETTINGS
@@ -131,14 +126,11 @@ PATTERN_LIBRARY = {
         ("organisms", ["patterns/organisms"]),
         ("templates", ["patterns/templates"]),
     ),
-
     # Configure which files to detect as templates.
     "TEMPLATE_SUFFIX": ".html",
-
     # Set which template components should be rendered inside of,
     # so they may use page-level component dependencies like CSS.
     "PATTERN_BASE_TEMPLATE_NAME": "patterns/base.html",
-
     # Any template in BASE_TEMPLATE_NAMES or any template that extends a template in
     # BASE_TEMPLATE_NAMES is a "page" and will be rendered as-is without being wrapped.
     "BASE_TEMPLATE_NAMES": ["patterns/base_page.html"],
diff --git a/majak_uistyleguide/settings/production.py b/majak_uistyleguide/settings/production.py
index d434da474ee7278ecec445d6dadd4841eed1ada3..6cfbf22c459b0814cb8c93ec273e3de80fe14dfb 100644
--- a/majak_uistyleguide/settings/production.py
+++ b/majak_uistyleguide/settings/production.py
@@ -1,6 +1,10 @@
 from .base import *
 from .base import env
 
+# DATABASES
+# ------------------------------------------------------------------------------
+DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60)
+
 # SECURITY
 # ------------------------------------------------------------------------------
 ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS")
diff --git a/majak_uistyleguide/wsgi.py b/majak_uistyleguide/wsgi.py
index 917dbfdb37bd284dab875ac7fc5a817973e7ff9e..aa45fe517c8b1afdf3115bd322ad24ba011fd2bf 100644
--- a/majak_uistyleguide/wsgi.py
+++ b/majak_uistyleguide/wsgi.py
@@ -11,6 +11,6 @@ import os
 
 from django.core.wsgi import get_wsgi_application
 
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'majak_uistyleguide.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "majak_uistyleguide.settings.dev")
 
 application = get_wsgi_application()
diff --git a/manage.py b/manage.py
index 902083c31403355dbb8ba41d98e77ca3886c0c51..494ee951fe02fc049d7ded8b1d8258e2cab5cef3 100755
--- a/manage.py
+++ b/manage.py
@@ -1,22 +1,15 @@
 #!/usr/bin/env python
-"""Django's command-line utility for administrative tasks."""
 import os
 import sys
 
 
 def main():
-    """Run administrative tasks."""
-    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'majak_uistyleguide.settings')
-    try:
-        from django.core.management import execute_from_command_line
-    except ImportError as exc:
-        raise ImportError(
-            "Couldn't import Django. Are you sure it's installed and "
-            "available on your PYTHONPATH environment variable? Did you "
-            "forget to activate a virtual environment?"
-        ) from exc
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "majak_uistyleguide.settings.dev")
+
+    from django.core.management import execute_from_command_line
+
     execute_from_command_line(sys.argv)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/requirements/base.in b/requirements/base.in
new file mode 100644
index 0000000000000000000000000000000000000000..1e8edefd1ed6150fa2fb0736a0c00f5db148531f
--- /dev/null
+++ b/requirements/base.in
@@ -0,0 +1,6 @@
+django<4
+django-pattern-library
+django-environ
+django-vite
+psycopg2-binary
+whitenoise
diff --git a/requirements/base.txt b/requirements/base.txt
index b1925eb0f8890c88caac10fdeab9987917f0a493..08ad028ac9cac4d90a4b503d3ad01c568596c946 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,6 +1,33 @@
-django==4.0
-django-pattern-library==1.0.0
-django-environ==0.9.0
-django-vite==2.0.2
-gunicorn==21.2.0
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    pip-compile base.in
+#
+asgiref==3.7.2
+    # via django
+django==3.2.24
+    # via
+    #   -r base.in
+    #   django-pattern-library
+    #   django-vite
+django-environ==0.11.2
+    # via -r base.in
+django-pattern-library==1.2.0
+    # via -r base.in
+django-vite==3.0.3
+    # via -r base.in
+markdown==3.5.2
+    # via django-pattern-library
+psycopg2-binary==2.9.9
+    # via -r base.in
+pytz==2024.1
+    # via django
+pyyaml==6.0.1
+    # via django-pattern-library
+sqlparse==0.4.4
+    # via django
+typing-extensions==4.9.0
+    # via asgiref
 whitenoise==6.6.0
+    # via -r base.in
diff --git a/requirements/prod.in b/requirements/prod.in
new file mode 100644
index 0000000000000000000000000000000000000000..8f22dccf99affb5ab9b1c65023ec083269269bca
--- /dev/null
+++ b/requirements/prod.in
@@ -0,0 +1 @@
+gunicorn
diff --git a/requirements/prod.txt b/requirements/prod.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f4a88f12b4c6224900fea3972bc4e461fa4b7a51
--- /dev/null
+++ b/requirements/prod.txt
@@ -0,0 +1,10 @@
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    pip-compile prod.in
+#
+gunicorn==21.2.0
+    # via -r prod.in
+packaging==23.2
+    # via gunicorn
diff --git a/run.sh b/run.sh
index 44ce2f937a524e71decc18ee9ea9c8ba7b5d1b5d..e51a2615de12304e43e2df0f1a7c190f446dbb41 100644
--- a/run.sh
+++ b/run.sh
@@ -4,7 +4,7 @@
 set -e
 
 # migrate database
-python manage.py migrate
+python3 manage.py migrate
 
 # start webserver
 exec gunicorn -c gunicorn.conf.py majak_uistyleguide.wsgi