diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index ae16c608285fb3bde7c1f76d5bb4a9cd136252a2..319aa8598547704a36ac7457755aea2d338d98ad 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -53,6 +53,8 @@ deploy:
   stage: deploy
   image: docker.io/library/alpine:3.18.4
   rules: !reference [.upstream-deploy-rules, rules]
+  variables:
+    SSH_PRIVATE_KEY: $PRODUCTION_SSH_PRIVATE_KEY
   before_script:
     - echo "Require SSH_PRIVATE_KEY, SSH_KNOWN_HOSTS, SSH_USER, and SSH_HOST environment variables"
     - apk add --no-cache openssh-client rsync
@@ -64,12 +66,65 @@ deploy:
   script:
     - rsync -ahP site/ "${SSH_USER}@${SSH_HOST}:/opt/course-site"
   cache: {}
+  needs:
+    - build
   dependencies:
     - build
   environment:
     name: production
     url: https://pmpl.cs.ui.ac.id
 
+review-app:
+  stage: deploy
+  image: docker.io/dokku/ci-docker-image:0.9.3
+  rules:
+    - if: $CI_MERGE_REQUEST_ID
+      when: on_success
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+      when: never
+  variables:
+    BRANCH: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
+    COMMAND: deploy
+    REVIEW_APP_NAME: review-pmpl-course-site-$CI_MERGE_REQUEST_ID
+    GIT_REMOTE_URL: ssh://dokku@dokku-ppl.cs.ui.ac.id/$REVIEW_APP_NAME
+    SSH_PRIVATE_KEY: $DOKKU_SSH_PRIVATE_KEY
+    GIT_DEPTH: "0"
+  script:
+    - dokku-deploy
+  after_script:
+    - dokku-unlock
+  cache: {}
+  dependencies: []
+  needs:
+    - build
+  environment:
+    name: review/$CI_MERGE_REQUEST_ID
+    url: http://$REVIEW_APP_NAME.dokku-ppl.cs.ui.ac.id
+    on_stop: stop-review-app
+
+stop-review-app:
+  stage: deploy
+  image: docker.io/dokku/ci-docker-image:0.9.3
+  rules:
+    - if: $CI_MERGE_REQUEST_ID
+      when: manual
+      allow_failure: true
+  variables:
+    COMMAND: review-apps:destroy
+    REVIEW_APP_NAME: review-pmpl-course-site-$CI_MERGE_REQUEST_ID
+    GIT_REMOTE_URL: ssh://dokku@dokku-ppl.cs.ui.ac.id/$REVIEW_APP_NAME
+    SSH_PRIVATE_KEY: $DOKKU_SSH_PRIVATE_KEY
+    GIT_STRATEGY: none
+  script:
+    - dokku-deploy
+  after_script:
+    - dokku-unlock
+  cache: {}
+  dependencies: []
+  environment:
+    name: review/$CI_MERGE_REQUEST_ID
+    action: stop
+
 dast:
   rules: !reference [.upstream-deploy-rules, rules]
   variables:
@@ -79,4 +134,5 @@ dast:
   needs:
     - job: deploy
       optional: true
+  cache: {}
   dependencies: []
diff --git a/Dockerfile b/Dockerfile
index e685a20eb322da3cedd4206f5726a46b1459d61e..87d015139e13835a2a447abba1ab84c0d58f375f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,8 +17,8 @@ COPY . .
 RUN poetry install --only main \
     && poetry run mkdocs build
 
-FROM docker.io/nginxinc/nginx-unprivileged:1.25-alpine
+FROM docker.io/library/nginx:1.24.0-alpine
 
 COPY --from=builder /src/site /usr/share/nginx/html
 
-EXPOSE 8080
+EXPOSE 80
diff --git a/fly.toml b/fly.toml
deleted file mode 100644
index 9302c41713147e1e72fa29ba1d09334d853fc4dd..0000000000000000000000000000000000000000
--- a/fly.toml
+++ /dev/null
@@ -1,38 +0,0 @@
-# fly.toml file generated for pmpl-course-site-dev on 2022-11-16T01:55:32+07:00
-
-app = "pmpl-course-site-dev"
-kill_signal = "SIGINT"
-kill_timeout = 5
-processes = []
-
-[env]
-
-[experimental]
-  allowed_public_ports = []
-  auto_rollback = true
-
-[[services]]
-  http_checks = []
-  internal_port = 80
-  processes = ["app"]
-  protocol = "tcp"
-  script_checks = []
-  [services.concurrency]
-    hard_limit = 25
-    soft_limit = 20
-    type = "connections"
-
-  [[services.ports]]
-    force_https = true
-    handlers = ["http"]
-    port = 80
-
-  [[services.ports]]
-    handlers = ["tls", "http"]
-    port = 443
-
-  [[services.tcp_checks]]
-    grace_period = "1s"
-    interval = "15s"
-    restart_limit = 0
-    timeout = "2s"