diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cc46aa8803..1910a3f68e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -277,6 +277,19 @@ jobs:
         run: |
           poetry run pytest app --cov=app --cov-report term-missing
 
+  shellcheck:
+    name: ShellCheck
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Run ShellCheck
+        uses: ludeeus/action-shellcheck@master
+        with:
+          ignore_paths: >-
+            **/open-api/**
+            **/openapi/**
+            **/node_modules/**
+
   generated-api-up-to-date:
     name: OpenAPI Clients
     runs-on: ubuntu-latest
diff --git a/install.sh b/install.sh
index 8f8d3b0c18..1625fa080c 100755
--- a/install.sh
+++ b/install.sh
@@ -1,15 +1,13 @@
+#!/usr/bin/env bash
+
 echo "Starting Immich installation..."
 
 ip_address=$(hostname -I | awk '{print $1}')
 
-RED='\033[0;31m'
-GREEN='\032[0;31m'
-NC='\033[0m' # No Color
-
 create_immich_directory() {
   echo "Creating Immich directory..."
   mkdir -p ./immich-app/immich-data
-  cd ./immich-app
+  cd ./immich-app || exit
 }
 
 download_docker_compose_file() {
@@ -34,7 +32,7 @@ replace_env_value() {
 populate_upload_location() {
   echo "Populating default UPLOAD_LOCATION value..."
   upload_location=$(pwd)/immich-data
-  replace_env_value "UPLOAD_LOCATION" $upload_location
+  replace_env_value "UPLOAD_LOCATION" "$upload_location"
 }
 
 start_docker_compose() {
@@ -45,7 +43,7 @@ start_docker_compose() {
   elif docker-compose > /dev/null 2>&1; then
     docker_bin="docker-compose"
   else
-    echo 'Cannot find `docker compose` or `docker-compose`.'
+    echo "Cannot find \`docker compose\` or \`docker-compose\`."
     exit 1
   fi
 
diff --git a/machine-learning/ann/build.sh b/machine-learning/ann/build.sh
index d90fa1ae1b..219c0ef1b1 100644
--- a/machine-learning/ann/build.sh
+++ b/machine-learning/ann/build.sh
@@ -1 +1,3 @@
-g++ -shared -O3 -o libann.so -fuse-ld=gold -std=c++17 -I$ARMNN_PATH/include -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -L$ARMNN_PATH ann.cpp
+#!/usr/bin/env sh
+
+g++ -shared -O3 -o libann.so -fuse-ld=gold -std=c++17 -I"$ARMNN_PATH"/include -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -L"$ARMNN_PATH" ann.cpp
diff --git a/machine-learning/ann/export/build-converter.sh b/machine-learning/ann/export/build-converter.sh
index 0deb2e7ed5..94e9ebec2b 100755
--- a/machine-learning/ann/export/build-converter.sh
+++ b/machine-learning/ann/export/build-converter.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env sh
 
-cd armnn-23.11/
+cd armnn-23.11/ || exit
 g++ -o ../armnnconverter -O1 -DARMNN_ONNX_PARSER -DARMNN_SERIALIZER -DARMNN_TF_LITE_PARSER -fuse-ld=gold -std=c++17 -Iinclude -Isrc/armnnUtils -Ithird-party -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -larmnnSerializer -L../armnn src/armnnConverter/ArmnnConverter.cpp
diff --git a/machine-learning/start.sh b/machine-learning/start.sh
index d522f11435..082bf205c4 100755
--- a/machine-learning/start.sh
+++ b/machine-learning/start.sh
@@ -1,6 +1,7 @@
 #!/usr/bin/env sh
 
-export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
+lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
+export LD_PRELOAD="$lib_path"
 export LD_BIND_NOW=1
 
 : "${MACHINE_LEARNING_HOST:=0.0.0.0}"
@@ -10,8 +11,8 @@ export LD_BIND_NOW=1
 
 gunicorn app.main:app \
 	-k app.config.CustomUvicornWorker \
-	-w $MACHINE_LEARNING_WORKERS \
-	-b $MACHINE_LEARNING_HOST:$MACHINE_LEARNING_PORT \
-	-t $MACHINE_LEARNING_WORKER_TIMEOUT \
+	-w "$MACHINE_LEARNING_WORKERS" \
+	-b "$MACHINE_LEARNING_HOST":"$MACHINE_LEARNING_PORT" \
+	-t "$MACHINE_LEARNING_WORKER_TIMEOUT" \
 	--log-config-json log_conf.json \
 	--graceful-timeout 0
diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh
index 3342372fc6..0cd4e5b478 100755
--- a/misc/release/pump-version.sh
+++ b/misc/release/pump-version.sh
@@ -1,4 +1,4 @@
-#/bin/bash
+#!/usr/bin/env bash
 
 #
 # Pump one or both of the server/mobile versions in appropriate files
@@ -25,10 +25,10 @@ while getopts 's:m:' flag; do
   esac
 done
 
-CURRENT_SERVER=$(cat server/package.json | jq -r '.version')
-MAJOR=$(echo $CURRENT_SERVER | cut -d '.' -f1)
-MINOR=$(echo $CURRENT_SERVER | cut -d '.' -f2)
-PATCH=$(echo $CURRENT_SERVER | cut -d '.' -f3)
+CURRENT_SERVER=$(jq -r '.version' server/package.json)
+MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
+MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
+PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
 
 if [[ $SERVER_PUMP == "major" ]]; then
   MAJOR=$((MAJOR + 1))
@@ -48,7 +48,7 @@ fi
 
 NEXT_SERVER=$MAJOR.$MINOR.$PATCH
 
-CURRENT_MOBILE=$(cat mobile/pubspec.yaml | grep "^version: .*+[0-9]\+$" | cut -d "+" -f2)
+CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
 NEXT_MOBILE=$CURRENT_MOBILE
 if [[ $MOBILE_PUMP == "true" ]]; then
   set $((NEXT_MOBILE++))
@@ -61,9 +61,10 @@ fi
 
 if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
   echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
-  npm --prefix server version $SERVER_PUMP
+  npm --prefix server version "$SERVER_PUMP"
+  npm --prefix web version "$SERVER_PUMP"
   make open-api
-  poetry --directory machine-learning version $SERVER_PUMP
+  poetry --directory machine-learning version "$SERVER_PUMP"
 fi
 
 if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
@@ -75,4 +76,4 @@ sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/
 sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
 sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
 
-echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV
+echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
diff --git a/mobile/ios/ci_scripts/ci_post_clone.sh b/mobile/ios/ci_scripts/ci_post_clone.sh
index ea244484bb..fe282ae3fe 100755
--- a/mobile/ios/ci_scripts/ci_post_clone.sh
+++ b/mobile/ios/ci_scripts/ci_post_clone.sh
@@ -1,10 +1,10 @@
 #!/usr/bin/env sh
 
 # The default execution directory of this script is the ci_scripts directory.
-cd $CI_WORKSPACE/mobile
+cd "$CI_WORKSPACE"/mobile || exit
 
 # Install Flutter using git.
-git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter
+git clone https://github.com/flutter/flutter.git --depth 1 -b stable "$HOME"/flutter
 export PATH="$PATH:$HOME/flutter/bin"
 
 # Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms.
@@ -14,7 +14,7 @@ flutter precache --ios
 flutter pub get
 
 # Install CocoaPods using Homebrew.
-HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
+export HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
 brew install cocoapods
 
 # Install CocoaPods dependencies.
diff --git a/mobile/scripts/fdroid_build_isar.sh b/mobile/scripts/fdroid_build_isar.sh
index f3fe423cb4..44f59c69ae 100644
--- a/mobile/scripts/fdroid_build_isar.sh
+++ b/mobile/scripts/fdroid_build_isar.sh
@@ -1,4 +1,6 @@
-cd .isar
+#!/usr/bin/env sh
+
+cd .isar || exit
 bash tool/build_android.sh x86
 bash tool/build_android.sh x64
 bash tool/build_android.sh armv7
diff --git a/server/bin/immich b/server/bin/immich
index 053e87313b..6b7dc3aa3f 100755
--- a/server/bin/immich
+++ b/server/bin/immich
@@ -1,2 +1,3 @@
 #!/usr/bin/env bash
+
 node /usr/src/app/node_modules/.bin/immich "$@"
diff --git a/server/bin/immich-admin b/server/bin/immich-admin
index 0634eae4bc..5f5e89ca57 100755
--- a/server/bin/immich-admin
+++ b/server/bin/immich-admin
@@ -1,2 +1,3 @@
 #!/usr/bin/env sh
-/usr/src/app/start.sh immich-admin $1
+
+/usr/src/app/start.sh immich-admin "$1"
diff --git a/server/bin/immich-dev b/server/bin/immich-dev
index fcf064bf79..177455d037 100755
--- a/server/bin/immich-dev
+++ b/server/bin/immich-dev
@@ -1,2 +1,3 @@
 #!/usr/bin/env bash
+
 node /usr/src/app/node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@"
diff --git a/server/bin/immich-test b/server/bin/immich-test
index 302f6765c4..93b104f136 100755
--- a/server/bin/immich-test
+++ b/server/bin/immich-test
@@ -1,2 +1,3 @@
 #!/usr/bin/env bash
-node /usr/src/app/node_modules/.bin/jest --config e2e/$1/jest-e2e.json --runInBand
+
+node /usr/src/app/node_modules/.bin/jest --config e2e/"$1"/jest-e2e.json --runInBand
diff --git a/server/start.sh b/server/start.sh
index 7aa0bc20dd..268989dda0 100755
--- a/server/start.sh
+++ b/server/start.sh
@@ -1,35 +1,42 @@
 #!/usr/bin/env sh
 
-export LD_PRELOAD=/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2
+lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
+export LD_PRELOAD="$lib_path"
 
 if [ "$DB_URL_FILE" ]; then
-	export DB_URL=$(cat $DB_URL_FILE)
+	DB_URL_CONTENT=$(cat "$DB_URL_FILE")
+	export DB_URL="$DB_URL_CONTENT"
 	unset DB_URL_FILE
 fi
 
 if [ "$DB_HOSTNAME_FILE" ]; then
-	export DB_HOSTNAME=$(cat $DB_HOSTNAME_FILE)
+	DB_HOSTNAME_CONTENT=$(cat "$DB_HOSTNAME_FILE")
+	export DB_HOSTNAME="$DB_HOSTNAME_CONTENT"
 	unset DB_HOSTNAME_FILE
 fi
 
 if [ "$DB_DATABASE_NAME_FILE" ]; then
-	export DB_DATABASE_NAME=$(cat $DB_DATABASE_NAME_FILE)
+	DB_DATABASE_CONTENT=$(cat "$DB_DATABASE_NAME_FILE")
+	export DB_DATABASE_NAME="$DB_DATABASE_CONTENT"
 	unset DB_DATABASE_NAME_FILE
 fi
 
 if [ "$DB_USERNAME_FILE" ]; then
-	export DB_USERNAME=$(cat $DB_USERNAME_FILE)
+	DB_USERNAME_CONTENT=$(cat "$DB_USERNAME_FILE")
+	export DB_USERNAME="$DB_USERNAME_CONTENT"
 	unset DB_USERNAME_FILE
 fi
 
 if [ "$DB_PASSWORD_FILE" ]; then
-	export DB_PASSWORD=$(cat $DB_PASSWORD_FILE)
+	DB_PASSWORD_CONTENT=$(cat "$DB_PASSWORD_FILE")
+	export DB_PASSWORD="$DB_PASSWORD_CONTENT"
 	unset DB_PASSWORD_FILE
 fi
 
 if [ "$REDIS_PASSWORD_FILE" ]; then
-	export REDIS_PASSWORD=$(cat $REDIS_PASSWORD_FILE)
+	REDIS_PASSWORD_CONTENT=$(cat "$REDIS_PASSWORD_FILE")
+	export DB_PASSWORD="$REDIS_PASSWORD_CONTENT"
 	unset REDIS_PASSWORD_FILE
 fi
 
-exec node /usr/src/app/dist/main $@
+exec node /usr/src/app/dist/main "$@"