diff --git a/doc/api.rst b/doc/api.rst
index 9d0b02c0c9aece7a7647ef95c8ea80827948ad87..186f9eeab1aebde1081615f24fabd9971a70777d 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -97,6 +97,7 @@ Reusable auxiliary functions.
    :toctree: api/utils
 
    mednet.utils.checkpointer
+   mednet.utils.gitlab
    mednet.utils.rc
    mednet.utils.resources
    mednet.utils.tensorboard
diff --git a/doc/catalog.json b/doc/catalog.json
index 529a23b480af03f8020f89720ff522d6d9f14d7f..40189913389912fc08ab55351365bc76f7f5da67 100644
--- a/doc/catalog.json
+++ b/doc/catalog.json
@@ -32,5 +32,14 @@
     "sources": {
       "readthedocs": "tabulate"
     }
+  },
+  "python-gitlab": {
+    "versions": {
+      "latest": "https://python-gitlab.readthedocs.io/en/latest/",
+      "stable": "https://python-gitlab.readthedocs.io/en/stable/"
+    },
+    "sources": {
+      "environment": "python-gitlab"
+    }
   }
 }
diff --git a/doc/conf.py b/doc/conf.py
index f547b0b0938ab095df1a074426d75cb88665d65e..4f6770a65c54efea2b2cbadf0b6df6ba50970088 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -71,7 +71,7 @@ main_doc = "index"
 project = "mednet"
 package = distribution(project)
 
-copyright = "%s, Idiap Research Institute" % time.strftime("%Y")  # noqa: A001
+copyright = f"{time.strftime("%Y")}, Idiap Research Institute"  # noqa
 
 # The short X.Y version.
 version = package.version
@@ -126,6 +126,7 @@ auto_intersphinx_packages = [
     "tensorboardx",
     ("clapper", "latest"),
     ("python", "3"),
+    "python-gitlab",
 ]
 auto_intersphinx_catalog = "catalog.json"
 
diff --git a/src/mednet/engine/evaluator.py b/src/mednet/engine/evaluator.py
index 42b1308a6714336b9b4f01d392d4500317342043..acbf79b3fcdd9db808a6ee28e6ded77c42d887fb 100644
--- a/src/mednet/engine/evaluator.py
+++ b/src/mednet/engine/evaluator.py
@@ -509,7 +509,7 @@ def _precision_recall_canvas() -> (
         y = f_score * x / (2 * x - f_score)
         plt.plot(x[y >= 0], y[y >= 0], color="green", alpha=0.1)
         tick_locs.append(y[-1])
-        tick_labels.append("%.1f" % f_score)
+        tick_labels.append(f"{f_score:.1f}")
     axes2.tick_params(axis="y", which="both", pad=0, right=False, left=False)
     axes2.set_ylabel("iso-F", color="green", alpha=0.3)
     axes2.set_ylim([0.0, 1.0])
diff --git a/src/mednet/scripts/upload.py b/src/mednet/scripts/upload.py
index bed4802f8863ef4bc1d445b56f3c0c2d45fb623d..615b81b25b08d9c8c07ae9e1dbaff1a9753f195e 100644
--- a/src/mednet/scripts/upload.py
+++ b/src/mednet/scripts/upload.py
@@ -3,6 +3,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 import pathlib
+
 import click
 from clapper.click import ResourceOption, verbosity_option
 from clapper.logging import setup
@@ -12,55 +13,6 @@ from .click import ConfigCommand
 logger = setup(__name__.split(".")[0], format="%(levelname)s: %(message)s")
 
 
-def _get_gitlab_config():
-    """Return an instance of the Gitlab object for remote operations.
-
-    Returns
-    -------
-        Gitlab entry and credential.
-    """
-    import gitlab
-    import configparser
-
-    cfg = pathlib.Path("~/.python-gitlab.cfg").expanduser()
-    if cfg.exists():
-        gl = gitlab.Gitlab.from_config("idiap", [str(cfg)])
-        config = configparser.ConfigParser()
-        config.read(cfg)
-    else:  # ask the user for a token or use one from the current runner
-        server = "https://gitlab.idiap.ch"
-        token = input(f"{server} (user or project) token: ")
-        gl = gitlab.Gitlab(server, private_token=token, api_version="4")
-        config = {"idiap": {"private_token": token}}
-    # test authentication with given credential.
-    gl.auth()
-
-    return gl, config
-
-
-def _create_temp_copy(source, target):
-    """Create a copy of original file in temp folder.
-
-    Parameters
-    ----------
-    source
-        Source file.
-    target
-        Target file.
-
-    Returns
-    -------
-        Path to target file in temp folder.
-    """
-    import shutil
-    import tempfile
-
-    temp_dir = pathlib.Path(tempfile.gettempdir())
-    target = temp_dir / target
-    shutil.copy2(source, target)
-    return target
-
-
 @click.command(
     entry_point_group="mednet.config",
     cls=ConfigCommand,
@@ -84,14 +36,24 @@ def _create_temp_copy(source, target):
 
       mednet upload --experiment-folder=/path/to/results --run-name=run-1
 
-4. Upload an existing experiment result with defining a size limit of 20MB for each file (set 0 for no limit):
+4. Upload an existing experiment result with defining a size limit of 20MB for each file:
 
    .. code:: sh
 
-      mednet upload --experiment-folder=/path/to/results --file-size=20
+      mednet upload --experiment-folder=/path/to/results --upload-limit-mb=20
 
 """,
 )
+@click.option(
+    "--project-path",
+    "-p",
+    help="Path to the project where to upload model entries",
+    required=True,
+    type=str,
+    default="biosignal/software/mednet",
+    show_default=True,
+    cls=ResourceOption,
+)
 @click.option(
     "--experiment-folder",
     "-f",
@@ -103,12 +65,13 @@ def _create_temp_copy(source, target):
         path_type=pathlib.Path,
     ),
     default="results",
+    show_default=True,
     cls=ResourceOption,
 )
 @click.option(
     "--experiment-name",
     "-e",
-    help='A string indicating the experiment name (e.g. "exp-pasa_mc" or "exp-densenet_mc-ch")',
+    help='A string indicating the experiment name (e.g. "exp-pasa-mc" or "exp-densenet-mc-ch")',
     cls=ResourceOption,
 )
 @click.option(
@@ -118,9 +81,9 @@ def _create_temp_copy(source, target):
     cls=ResourceOption,
 )
 @click.option(
-    "--file-limit",
+    "--upload-limit-mb",
     "-l",
-    help='Limit file size to be uploaded in MB (set 0 for no limit).',
+    help="Maximim upload size in MB (set to 0 for no limit).",
     show_default=True,
     required=True,
     default=10,
@@ -129,84 +92,119 @@ def _create_temp_copy(source, target):
 )
 @verbosity_option(logger=logger, cls=ResourceOption, expose_value=False)
 def upload(
+    project_path: str,
     experiment_folder: pathlib.Path,
     experiment_name: str,
     run_name: str,
-    file_limit: int,
+    upload_limit_mb: int,
     **_,  # ignored
 ) -> None:  # numpydoc ignore=PR01
-    """Upload results from an experiment folder."""
-    import os
+    """Upload results from an experiment folder to GitLab's MLFlow server."""
+
     import json
+    import os
+    import tempfile
+
     import mlflow
 
-    logger.info("Getting Gitlab credentials for accessing to MLFlow server...")
-    gitlab, config = _get_gitlab_config()
-    project = gitlab.projects.get("biosignal/software/mednet")
-    os.environ["MLFLOW_TRACKING_TOKEN"] = config["idiap"]["private_token"]
+    from ..utils.checkpointer import get_checkpoint_to_run_inference
+    from ..utils.gitlab import (
+        gitlab_instance_and_token,
+        sanitize_filename,
+        size_in_mb,
+    )
+
+    logger.info(
+        "Retrieving GitLab credentials for access to hosted MLFlow server..."
+    )
+    gitlab, token = gitlab_instance_and_token()
+    project = gitlab.projects.get(project_path)
+    os.environ["MLFLOW_TRACKING_TOKEN"] = token
     os.environ["MLFLOW_TRACKING_URI"] = (
         gitlab.api_url + f"/projects/{project.id}/ml/mlflow"
     )
 
-    # prepare train files
+    # get train files
     train_folder = experiment_folder / "model"
     train_meta_file = train_folder / "meta.json"
     train_log_file = train_folder / "trainlog.pdf"
-    train_model_file = [f for f in train_folder.glob("*lowest*")][0]
-    train_model_temp_file = train_model_file.parts[-1].replace("=", "_")
-    train_model_temp_file = _create_temp_copy(
-        train_model_file, train_model_temp_file
-    )
-    with train_meta_file.open("r") as f:
-        train_data = json.load(f)
-    train_files = [train_meta_file, train_log_file, train_model_temp_file]
+    train_model_file = get_checkpoint_to_run_inference(train_folder)
+    train_files = [train_meta_file, train_model_file, train_log_file]
 
-    # prepare evaluation files
+    # get evaluation files
     evaluation_file = experiment_folder / "evaluation.json"
     evaluation_meta_file = experiment_folder / "evaluation.meta.json"
     evaluation_log_file = experiment_folder / "evaluation.pdf"
-    with evaluation_file.open("r") as f:
-        evaluation_data = json.load(f)
-    evaluation_data = evaluation_data["test"]
-    evaluation_files = [evaluation_file, evaluation_meta_file, evaluation_log_file]
-
-    # check for file sizes.
-    for f in train_files + evaluation_files:
-        file_size = f.stat().st_size / (1024**2)
-        if file_limit != 0 and file_size > file_limit:
-            raise RuntimeError(
-                    f"Size of {f} ({file_size:.2f} MB) must be less than or equal to {file_limit} MB."
-            )
+    evaluation_files = [
+        evaluation_file,
+        evaluation_meta_file,
+        evaluation_log_file,
+    ]
+
+    # checks for maximum upload limit
+    total_size_mb = sum([size_in_mb(f) for f in train_files + evaluation_files])
+    if upload_limit_mb != 0 and total_size_mb > upload_limit_mb:
+        raise RuntimeError(
+            f"Total size of upload ({total_size_mb:.2f} MB) exceeds "
+            f"permitted maximum ({upload_limit_mb:.2f} MB)."
+        )
 
     # prepare experiment and run names
+    with train_meta_file.open("r") as meta_file:
+        train_data = json.load(meta_file)
+
+    with evaluation_file.open("r") as meta_file:
+        evaluation_data = json.load(meta_file)
+    evaluation_data = evaluation_data["test"]
+
     experiment_name = (
         experiment_name
-        if experiment_name
-        else f'{train_data["model-name"]}_{train_data["database-name"]}'
+        or f"{train_data['model-name']}-{train_data['database-name']}"
     )
-    run_name = run_name if run_name else train_data["datetime"]
+    run_name = run_name or train_data["datetime"]
 
-    logger.info("Setting experiment and run names on the MLFlow server...")
-    mlflow.set_experiment(experiment_name=experiment_name)
+    click.secho(
+        f"Uploading entry `{run_name}` to experiment `{experiment_name}` "
+        f"on GitLab project {project_path} (id: {project.id})...",
+        bold=True,
+        fg="green",
+    )
+    exp_meta = mlflow.set_experiment(experiment_name=experiment_name)
     with mlflow.start_run(run_name=run_name):
-        # upload metrics
-        logger.info("Uploading metrics to MLFlow server...")
-        mlflow.log_metric("threshold", evaluation_data["threshold"])
-        mlflow.log_metric("precision", evaluation_data["precision"])
-        mlflow.log_metric("recall", evaluation_data["recall"])
-        mlflow.log_metric("f1_score", evaluation_data["f1_score"])
-        mlflow.log_metric(
-            "average_precision_score", evaluation_data["average_precision_score"]
-        )
-        mlflow.log_metric("specificity", evaluation_data["specificity"])
-        mlflow.log_metric("auc_score", evaluation_data["auc_score"])
-        mlflow.log_metric("accuracy", evaluation_data["accuracy"])
-        mlflow.log_param("version", train_data["package-version"])
-        # upload artifacts
-        logger.info("Uploading artifacts to MLFlow server...")
-        for f in train_files:
-            mlflow.log_artifact(f)
-        for f in evaluation_files:
-            mlflow.log_artifact(f)
-    # delete temporary file as no need it after logging.
-    train_model_temp_file.unlink()
+        click.echo("Uploading package metadata...")
+        click.echo(f"  -> `version` ({train_data['package-version']})")
+        mlflow.log_param("package version", train_data["package-version"])
+
+        click.echo("Uploading metrics...")
+
+        for k in [
+            "threshold",
+            "precision",
+            "recall",
+            "f1_score",
+            "average_precision_score",
+            "specificity",
+            "auc_score",
+            "accuracy",
+        ]:
+            click.secho(f"  -> `{k}` ({evaluation_data[k]:.3g})")
+            mlflow.log_metric(k, evaluation_data[k])
+
+        click.echo("Uploading artifacts (files)...")
+
+        with tempfile.TemporaryDirectory() as tmpdir_name:
+            tmpdir = pathlib.Path(tmpdir_name)
+            for f in train_files + evaluation_files:
+                assert f.exists(), f"File `{f}` does not exist - cannot upload!"
+                clean_path = str(sanitize_filename(tmpdir, f))
+                click.secho(f"  -> `{clean_path}` ({size_in_mb(f):.2f} MB)")
+                mlflow.log_artifact(clean_path)
+
+    click.secho(
+        f"Uploaded {total_size_mb:.2f} MB to server.", bold=True, fg="green"
+    )
+    click.secho(
+        f"Visit {gitlab.url}/{project.path_with_namespace}/-/ml/experiments/{exp_meta.experiment_id}",
+        bold=True,
+        fg="blue",
+    )
diff --git a/src/mednet/utils/checkpointer.py b/src/mednet/utils/checkpointer.py
index 27cf9a3673c86bbc5412781751ec470e8d7a644c..4f529de8eb250ed771518ef8a5018c409e6e8003 100644
--- a/src/mednet/utils/checkpointer.py
+++ b/src/mednet/utils/checkpointer.py
@@ -66,19 +66,23 @@ def _get_checkpoint_from_alias(
     ), f"Template `{str(template)}` does not contain the keyword `{{epoch}}`"
 
     pattern = re.compile(
-        template.name.replace("{epoch}", r"epoch(=|_|-)(?P<epoch>\d+)"),
+        template.name.replace(
+            "{epoch}", r"epoch(?P<separator>=|-|_)(?P<epoch>\d+)"
+        ),
     )
     highest = -1
+    separator = "="
     for f in template.parent.iterdir():
         match = pattern.match(f.name)
         if match is not None:
             value = int(match.group("epoch"))
             if value > highest:
                 highest = value
+                separator = match.group("separator")
 
     if highest != -1:
         return template.with_name(
-            template.name.replace("{epoch}", f"epoch={highest}"),
+            template.name.replace("{epoch}", f"epoch{separator}{highest}"),
         )
 
     raise FileNotFoundError(
diff --git a/src/mednet/utils/gitlab.py b/src/mednet/utils/gitlab.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0b111ddcf1f54ec995ffb0544c0199aa3d4f7b4
--- /dev/null
+++ b/src/mednet/utils/gitlab.py
@@ -0,0 +1,92 @@
+# SPDX-FileCopyrightText: Copyright © 2023 Idiap Research Institute <contact@idiap.ch>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import configparser
+import logging
+import pathlib
+import shutil
+
+import gitlab
+
+logger = logging.getLogger(__name__)
+
+
+def gitlab_instance_and_token() -> tuple[gitlab.Gitlab, str]:
+    """Return an instance of the Gitlab object for remote operations, and the
+    user token.
+
+    Returns
+    -------
+        Gitlab main object and user token
+    """
+
+    cfg = pathlib.Path("~/.python-gitlab.cfg").expanduser()
+    if cfg.exists():
+        gl = gitlab.Gitlab.from_config("idiap", [str(cfg)])
+        config = configparser.ConfigParser()
+        config.read(cfg)
+        token = config["idiap"]["private_token"]
+
+    else:  # ask the user for a token or use one from the current runner
+        server = "https://gitlab.idiap.ch"
+        token = input(f"{server} (user or project) token: ")
+        gl = gitlab.Gitlab(server, private_token=token, api_version="4")
+
+    # tests authentication with given credential.
+    gl.auth()
+
+    return gl, token
+
+
+def sanitize_filename(tmpdir: pathlib.Path, path: pathlib.Path) -> pathlib.Path:
+    """Sanitize the name of a file to be logged.
+
+    This function sanitizes the basename of a file to be logged on the GitLab
+    MLflow server. It removes unsupported characters (such as ``=``) by
+    creating a copy of the file to be uploaded, with a modified name, on the
+    provided temporary directory.  It then returns the name of such a temporary
+    file.
+
+    If the input file path does not need sanitization, it is returned as is.
+
+    Parameters
+    ----------
+    tmpdir
+        The temporary directory where a copy of the input path, with a
+        sanitized name will be created.
+    path
+        The file that needs its name sanitized.
+
+    Returns
+    -------
+        Path to the temporary folder, and the sanitized copy of the input file
+        in said temporary folder, or the input ``path``, in case its name does not
+        need sanitization.
+    """
+
+    sanitized_filename = path.parts[-1].replace("=", "-")
+    if path.parts[-1] == sanitized_filename:
+        return path
+
+    absolute_sanitized_filename = tmpdir / sanitized_filename
+    logger.info(
+        f"Sanitazing filename `{path}` -> `{absolute_sanitized_filename}`"
+    )
+    shutil.copy2(path, absolute_sanitized_filename)
+    return absolute_sanitized_filename
+
+
+def size_in_mb(path: pathlib.Path) -> float:
+    """Return the size in megabytes of a file.
+
+    Parameters
+    ----------
+    path
+        Input path to calculate file size from.
+
+    Returns
+    -------
+        A floating point number for the size of the object in MB.
+    """
+    return path.stat().st_size / (1024**2)