diff --git a/install-sss.sh b/install-sss.sh
index bb2db57649000ba1e701786f56dba575753110eb..a35b43d2a3574e5b05328ed84a12ce6c1f29ea2d 100755
--- a/install-sss.sh
+++ b/install-sss.sh
@@ -1,9 +1,41 @@
+#!/bin/bash
+
 SRC_DIR=$1
 INSTALL_DIR=$2
 
 mkdir -p $INSTALL_DIR
 
-# from here on do your module-wise installing
+
+# Install always ##############################################################
+
+# Scripts for which the whole directory shall be installed
+INSTALL_FULL_DIR="
+    ext_query_to_xlsx
+"
+
+# Scripts for which only *.py files shall be installed
+INSTALL_PY_FILES="
+    ext_file_download
+"
+
+
+# #############################################################################
+# Default implementation follows ##############################################
+
+echo "Installing to $INSTALL_DIR"
+
+for src in $INSTALL_FULL_DIR; do
+    cp -r "$SRC_DIR/$src" "$INSTALL_DIR/"
+    echo "Installed $src"
+done
+
+for src in $INSTALL_PY_FILES; do
+    mkdir -p "$INSTALL_DIR/$src"
+    cp "$SRC_DIR/$src/"*.py "$INSTALL_DIR/$src/"
+    echo "Installed $src"
+done
+
+# Module specific installations ###############################################
 
 # ext_table_preview
 if [ "${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED" ]; then
@@ -11,7 +43,4 @@ if [ "${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED" ]; then
     cp $SRC_DIR/ext_table_preview/*.py $INSTALL_DIR/ext_table_preview/
     echo "installed all server-side scripts for ext_table_preview"
 fi
-# ext_file_download; should always be installed - No build variable
-mkdir -p $INSTALL_DIR/ext_file_download
-cp $SRC_DIR/ext_file_download/*.py $INSTALL_DIR/ext_file_download/
-echo "installed all server-side scripts for ext_file_download"
+
diff --git a/tools/copy_into_docker.py b/tools/copy_into_docker.py
new file mode 100755
index 0000000000000000000000000000000000000000..840bba364ffcf74b7807b81f1a5689fa0db30ce4
--- /dev/null
+++ b/tools/copy_into_docker.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+
+# This file is a part of the LinkAhead project.
+#
+# Copyright (C) 2025 IndiScale GmbH <www.indiscale.com>
+# Copyright (C) 2025 Daniel Hornung <d.hornung@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+"""
+"""
+
+import argparse
+import os
+from pathlib import Path
+from subprocess import run
+from typing import Optional, Union
+
+
+class DockerManager:
+    def __init__(self, container: Optional[str] = None,
+                 webui_root: Optional[str] = None,
+                 basename: Optional[str] = None,
+                 ):
+        """Manages a Docker container.
+
+        Parameters
+        ----------
+        container : str, default="linkahead"
+          The container name
+
+        webui_root : str, default="/opt/caosdb/git/caosdb-server/caosdb-webui"
+          The root directory for the webui on the server.
+
+        basename : str, default="caosdb-webui"
+          Base name of this repository.
+        """
+        if not container:
+            container = "linkahead"
+        if not webui_root:
+            webui_root = "/opt/caosdb/git/caosdb-server/caosdb-webui"
+        if not basename:
+            basename = "caosdb-webui"
+        self.container_name = container
+        self.webui_root = webui_root
+        self.basename = basename
+
+    def copy(self, src: Union[str, Path]):
+        """Copy ``src`` into the Docker container.
+
+        This method tries to guess the correct target inside the webui's root dir.
+
+        Parameters
+        ----------
+        src : Union[str, Path]
+          The directory or file to copy.
+        """
+        if isinstance(src, str):
+            src = Path(src)
+        src = src.resolve()
+        relative_path = Path(*src.parts[src.parts.index(self.basename) + 1:])
+
+        target = Path(self.webui_root) / relative_path
+
+        if src.is_dir():
+            target = target.parent
+        command = ["docker", "cp", str(src), f"{self.container_name}:{target}"]
+        run(command, check=True)
+
+    def make(self):
+        """Run ``make`` inside the container.
+        """
+
+        command = ["docker", "exec", "-u0:0", "-ti",
+                   "-w", self.webui_root, self.container_name, "make"]
+        run(command)
+
+
+def _mangle_sources(sources: list[str]) -> list[str]:
+    """Handle special cases of the sources given on the commandline.
+
+    Parameters
+    ----------
+    sources : list[str]
+      The sources to mangle
+
+    Returns
+    -------
+    out : list[str]
+      The mangled list.
+    """
+    if "ALL" in sources and len(sources) > 1:
+        raise ValueError("If ALL is given, this must be the only source.")
+    if "ALL" in sources:
+        base = Path(__file__).parents[1]
+        result = []
+        for default in (
+                "build.properties.d",
+                "conf",
+                "libs",
+                "node_modules",
+                "src",
+        ):
+            result.append(str(base / default))
+
+        return result
+
+    return sources.copy()
+
+
+def _parse_arguments():
+    """Parse the arguments."""
+    parser = argparse.ArgumentParser(
+        description="Copy stufff into the Docker container, then run the Makefile.")
+    parser.add_argument("source", type=str, nargs="+",
+                        help="""The thing(s) to copy.
+                        Special case: "ALL" copies everything that may be useful."""
+                        )
+    parser.add_argument('--container', type=str, required=False,
+                        help="Container name.  Default is 'linkahead'."
+                        )
+    parser.add_argument('--webui-root', type=str, required=False,
+                        help="Webui root in the container.  Default is "
+                        "'/opt/caosdb/git/caosdb-server/caosdb-webui'."
+                        )
+    parser.add_argument('--repo-basename', type=str, required=False,
+                        help="Basename of this repository.  Default is "
+                        "'caosdb-webui'."
+                        )
+    return parser.parse_args()
+
+
+def main():
+    """The main function of this script."""
+    args = _parse_arguments()
+    mgr = DockerManager(container=args.container,
+                        webui_root=args.webui_root,
+                        basename=args.repo_basename)
+    sources = args.source
+    sources = _mangle_sources(sources)
+    for src in sources:
+        mgr.copy(src)
+    mgr.make()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/copy_into_docker.sh b/tools/copy_into_docker.sh
index d58a75123b1f9a1c2292df8db9efa1ea6d73a09a..cf1f8f1c4f5d3cb53f90f2ce917a32c623011829 100755
--- a/tools/copy_into_docker.sh
+++ b/tools/copy_into_docker.sh
@@ -28,8 +28,10 @@ set -e
 # Copy just the publicly accessible files
 core_dir="$(dirname $0)/../src/core"
 container="linkahead"
-docker_webui_root="/opt/caosdb/git/linkahead-server/linkahead-webui"
+docker_webui_root="/opt/caosdb/git/caosdb-server/caosdb-webui"
 docker_dir="${docker_webui_root}/src/core/"
 
 docker cp "${core_dir}/." "$container:$docker_dir"
 docker exec -ti -w "$docker_webui_root" "$container" make
+
+echo -e "\nThis script is deprecated, use the Python script instead."