diff --git a/src/manage_migration.py b/src/manage_migration.py
new file mode 100755
index 000000000..3221c90e3
--- /dev/null
+++ b/src/manage_migration.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+import os
+import sys
+
+if __name__ == "__main__":
+ os.environ.setdefault(
+ "DJANGO_SETTINGS_MODULE",
+ "paperless_migration.settings",
+ )
+
+ from django.core.management import execute_from_command_line
+
+ execute_from_command_line(sys.argv)
diff --git a/src/paperless/migration_asgi.py b/src/paperless/migration_asgi.py
new file mode 100644
index 000000000..abdc5d567
--- /dev/null
+++ b/src/paperless/migration_asgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless_migration.settings")
+
+application = get_asgi_application()
diff --git a/src/paperless_migration/__init__.py b/src/paperless_migration/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/paperless_migration/apps.py b/src/paperless_migration/apps.py
new file mode 100644
index 000000000..dd1635af7
--- /dev/null
+++ b/src/paperless_migration/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class PaperlessMigrationConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "paperless_migration"
diff --git a/src/paperless_migration/settings.py b/src/paperless_migration/settings.py
new file mode 100644
index 000000000..6037bfc22
--- /dev/null
+++ b/src/paperless_migration/settings.py
@@ -0,0 +1,180 @@
+"""Settings for migration-mode Django instance."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+DEBUG = False
+
+ALLOWED_HOSTS = ["*"]
+
+
+def __get_path(
+ key: str,
+ default: str | Path,
+) -> Path:
+ if key in os.environ:
+ return Path(os.environ[key]).resolve()
+ return Path(default).resolve()
+
+
+DATA_DIR = __get_path("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")
+
+
+def _parse_db_settings() -> dict[str, dict[str, Any]]:
+ databases: dict[str, dict[str, Any]] = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": DATA_DIR / "db.sqlite3",
+ "OPTIONS": {},
+ },
+ }
+ if os.getenv("PAPERLESS_DBHOST"):
+ databases["sqlite"] = databases["default"].copy()
+ databases["default"] = {
+ "HOST": os.getenv("PAPERLESS_DBHOST"),
+ "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
+ "USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
+ "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
+ "OPTIONS": {},
+ }
+ if os.getenv("PAPERLESS_DBPORT"):
+ databases["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT")
+
+ if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
+ engine = "django.db.backends.mysql"
+ options = {
+ "read_default_file": "/etc/mysql/my.cnf",
+ "charset": "utf8mb4",
+ "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
+ "ssl": {
+ "ca": os.getenv("PAPERLESS_DBSSLROOTCERT"),
+ "cert": os.getenv("PAPERLESS_DBSSLCERT"),
+ "key": os.getenv("PAPERLESS_DBSSLKEY"),
+ },
+ }
+ else:
+ engine = "django.db.backends.postgresql"
+ options = {
+ "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
+ "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT"),
+ "sslcert": os.getenv("PAPERLESS_DBSSLCERT"),
+ "sslkey": os.getenv("PAPERLESS_DBSSLKEY"),
+ }
+
+ databases["default"]["ENGINE"] = engine
+ databases["default"]["OPTIONS"].update(options)
+
+ if os.getenv("PAPERLESS_DB_TIMEOUT") is not None:
+ timeout = int(os.getenv("PAPERLESS_DB_TIMEOUT"))
+ if databases["default"]["ENGINE"] == "django.db.backends.sqlite3":
+ databases["default"]["OPTIONS"].update({"timeout": timeout})
+ else:
+ databases["default"]["OPTIONS"].update({"connect_timeout": timeout})
+ databases["sqlite"]["OPTIONS"].update({"timeout": timeout})
+ return databases
+
+
+DATABASES = _parse_db_settings()
+
+SECRET_KEY = os.getenv(
+ "PAPERLESS_SECRET_KEY",
+ "e11fl1oa-*ytql8p)(06fbj4ukrlo+n7k&q5+$1md7i+mge=ee",
+)
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+]
+
+LANGUAGE_CODE = "en-us"
+TIME_ZONE = "UTC"
+USE_I18N = True
+USE_TZ = True
+CSRF_TRUSTED_ORIGINS: list[str] = []
+
+INSTALLED_APPS = [
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "allauth",
+ "allauth.account",
+ "allauth.socialaccount",
+ "allauth.mfa",
+ "paperless_migration",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "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",
+ "allauth.account.middleware.AccountMiddleware",
+]
+
+ROOT_URLCONF = "paperless_migration.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "paperless_migration.wsgi.application"
+
+AUTHENTICATION_BACKENDS = [
+ "django.contrib.auth.backends.ModelBackend",
+ "allauth.account.auth_backends.AuthenticationBackend",
+]
+
+STATIC_URL = "/static/"
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+LOGIN_URL = "/accounts/login/"
+LOGIN_REDIRECT_URL = "/migration/"
+LOGOUT_REDIRECT_URL = "/accounts/login/?loggedout=1"
+
+ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
+ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS = False
+SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
+SOCIALACCOUNT_ENABLED = False
+
+SESSION_ENGINE = "django.contrib.sessions.backends.db"
+
+MIGRATION_EXPORT_PATH = os.getenv(
+ "PAPERLESS_MIGRATION_EXPORT_PATH",
+ "/data/export.json",
+)
+MIGRATION_TRANSFORMED_PATH = os.getenv(
+ "PAPERLESS_MIGRATION_TRANSFORMED_PATH",
+ "/data/export.v3.json",
+)
diff --git a/src/paperless_migration/templates/paperless_migration/migration_home.html b/src/paperless_migration/templates/paperless_migration/migration_home.html
new file mode 100644
index 000000000..4f6726910
--- /dev/null
+++ b/src/paperless_migration/templates/paperless_migration/migration_home.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ Paperless-ngx Migration Mode
+
+
+
+ Migration Mode
+
+ This instance is running in migration mode. Use this interface to run
+ the v2 → v3 migration.
+
+ {% if messages %}
+
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+ Step 1 — Export (v2)
+ Expected export file:
+
+ - Path: {{ export_path }}
+ - Status: {{ export_exists|yesno:"Found,Missing" }}
+
+
+
+
+ Step 2 — Transform
+ Expected transformed file:
+
+ - Path: {{ transformed_path }}
+ - Status: {{ transformed_exists|yesno:"Found,Missing" }}
+
+
+
+
+ Step 3 — Import (v3)
+
+
+
+
+
diff --git a/src/paperless_migration/urls.py b/src/paperless_migration/urls.py
new file mode 100644
index 000000000..38698133b
--- /dev/null
+++ b/src/paperless_migration/urls.py
@@ -0,0 +1,9 @@
+from django.urls import include
+from django.urls import path
+
+from paperless_migration import views
+
+urlpatterns = [
+ path("accounts/", include("allauth.urls")),
+ path("migration/", views.migration_home, name="migration_home"),
+]
diff --git a/src/paperless_migration/views.py b/src/paperless_migration/views.py
new file mode 100644
index 000000000..000822e5f
--- /dev/null
+++ b/src/paperless_migration/views.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseForbidden
+from django.shortcuts import redirect
+from django.shortcuts import render
+from django.views.decorators.http import require_http_methods
+
+from paperless_migration import settings
+
+
+@login_required
+@require_http_methods(["GET", "POST"])
+def migration_home(request):
+ if not request.user.is_superuser:
+ return HttpResponseForbidden("Superuser access required")
+
+ export_path = Path(settings.MIGRATION_EXPORT_PATH)
+ transformed_path = Path(settings.MIGRATION_TRANSFORMED_PATH)
+
+ if request.method == "POST":
+ action = request.POST.get("action")
+ if action == "check":
+ messages.success(request, "Checked export paths.")
+ elif action == "transform":
+ messages.info(
+ request,
+ "Transform step is not implemented yet.",
+ )
+ elif action == "import":
+ messages.info(
+ request,
+ "Import step is not implemented yet.",
+ )
+ else:
+ messages.error(request, "Unknown action.")
+ return redirect("migration_home")
+
+ context = {
+ "export_path": export_path,
+ "export_exists": export_path.exists(),
+ "transformed_path": transformed_path,
+ "transformed_exists": transformed_path.exists(),
+ }
+ return render(request, "paperless_migration/migration_home.html", context)
diff --git a/src/paperless_migration/wsgi.py b/src/paperless_migration/wsgi.py
new file mode 100644
index 000000000..d1dd62f5f
--- /dev/null
+++ b/src/paperless_migration/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless_migration.settings")
+
+application = get_wsgi_application()