diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2a7978404b04d1af026daf687db80d1bc42544e7..9eb033357a233408c864b06c81efeb8b9933b290 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -8,16 +8,19 @@ stages:
 
 workflow:
   rules:
+    # Allow manual pipeline runs
     - if: $FORCE_PIPELINE_RUN == 'true'
+    # Tags with format "prod-yyyy-mm-dd_HH-MM" will be tagged as production and latest
     - if: $CI_COMMIT_TAG
     - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
-    - if: ($CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "production")
+    - if: $CI_COMMIT_BRANCH == "develop"
+    # prevent duplicate pipeline runs for merge requests
     - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
       when: never
     - if: $CI_COMMIT_BRANCH
 .default-rules:
   rules:
-    - if: ($CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "production")
+    - if: $CI_COMMIT_BRANCH == "develop"
     - if: $CI_COMMIT_TAG
     - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
     - if: $FORCE_PIPELINE_RUN == 'true'
@@ -375,42 +378,60 @@ test_nginx_static:
     - crane delete $CI_REGISTRY_IMAGE/ci/hub:ci-${CI_PIPELINE_ID}
     - crane delete $CI_REGISTRY_IMAGE/ci/nginx:ci-${CI_PIPELINE_ID}
 
-publish:
+publish-commit:
   extends:
     - .publish-clean
   script:
-    - crane copy $CI_REGISTRY_IMAGE/ci/hub:ci-${CI_PIPELINE_ID} $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
-    - crane copy $CI_REGISTRY_IMAGE/ci/nginx:ci-${CI_PIPELINE_ID} $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_NAME
+    - crane tag $CI_REGISTRY_IMAGE/ci/hub:ci-${CI_PIPELINE_ID} ${CI_COMMIT_SHORT_SHA}
+    - crane tag $CI_REGISTRY_IMAGE/ci/nginx:ci-${CI_PIPELINE_ID} ${CI_COMMIT_SHORT_SHA}
   rules:
-    - if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "production"'
+    - if: '$CI_COMMIT_BRANCH == "develop" ||  $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_TAG'
     - when: never
 
-publish_latest:
+publish-mr:
+  extends:
+    - .publish-image
   needs:
-    - publish
+    - publish-commit
+  script:
+    - crane tag $CI_REGISTRY_IMAGE/ci/hub:${CI_COMMIT_SHORT_SHA} mr-${CI_MERGE_REQUEST_ID}
+    - crane tag $CI_REGISTRY_IMAGE/ci/nginx:${CI_COMMIT_SHORT_SHA} mr-${CI_MERGE_REQUEST_ID}
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+    - when: never
+
+publish-develop:
   extends:
     - .publish-image
+  needs:
+    - publish-commit
   script:
-    - crane tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME latest
-    - crane tag $CI_REGISTRY_IMAGE/nginx:$CI_COMMIT_REF_NAME latest
+    - crane copy $CI_REGISTRY_IMAGE/ci/hub:${CI_COMMIT_SHORT_SHA} $CI_REGISTRY_IMAGE:development
+    - crane copy $CI_REGISTRY_IMAGE/ci/nginx:${CI_COMMIT_SHORT_SHA} $CI_REGISTRY_IMAGE/nginx:development
   rules:
-    - if: '$CI_COMMIT_BRANCH == "production"'
+    - if: $CI_COMMIT_BRANCH == 'develop'
     - when: never
 
-publish_merge_requests:
+# Publish to production and add latest tag
+publish-production:
   extends:
-    - .publish-clean
+    - .publish-image
+  needs:
+    - publish-commit
   script:
-    - crane tag $CI_REGISTRY_IMAGE/ci/hub:ci-${CI_PIPELINE_ID} mr-$CI_MERGE_REQUEST_ID
-    - crane tag $CI_REGISTRY_IMAGE/ci/nginx:ci-${CI_PIPELINE_ID} mr-$CI_MERGE_REQUEST_ID
+    - crane copy $CI_REGISTRY_IMAGE/ci/hub:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:production
+    - crane tag $CI_REGISTRY_IMAGE:production latest
+    - crane copy $CI_REGISTRY_IMAGE/ci/nginx:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE/nginx:production
+    - crane tag $CI_REGISTRY_IMAGE/nginx:production latest
   rules:
-    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
+    - if: '$CI_COMMIT_TAG =~ /^prod-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}$/'
+    - when: never
 
 deploy_develop:
   stage: deploy
   allow_failure: true
   needs:
-    - publish
+    - publish-develop
   image: python:3.11-bookworm
   script:
     - 'curl -X POST "$DEPLOYMENT_SERVICEWEBHOOK_URL_DEVELOP"'
@@ -434,7 +455,7 @@ deploy_production:
   stage: deploy
   allow_failure: true
   needs:
-    - publish
+    - publish-production
   image: python:3.11-bookworm
   script:
     - 'curl -X POST "$DEPLOYMENT_SERVICEWEBHOOK_URL_PRODUCTION"'
@@ -447,8 +468,8 @@ deploy_production:
     - if: '$DEPLOYMENT_SERVICEWEBHOOK_URL_PRODUCTION == null || $DEPLOYMENT_SERVICEWEBHOOK_URL_PRODUCTION == ""'
       when: never
 
-    # only attempt to deploy on correct branch
-    - if: '$CI_COMMIT_BRANCH == "production"'
+    # only attempt to deploy on correct tag
+    - if: '$CI_COMMIT_TAG =~ /^prod-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}$/'
       when: on_success
 
     # otherwise, skip this