diff --git a/.github/workflows/lint-shellcheck.yml b/.github/workflows/lint-shellcheck.yml
new file mode 100644
index 000000000000..0e5be595295b
--- /dev/null
+++ b/.github/workflows/lint-shellcheck.yml
@@ -0,0 +1,25 @@
+on:
+  push:
+    paths:
+      - '**/*.{sh,bash,fish}'
+      - './bin/*'
+      - './scripts/git/pre-commit'
+      - '.github/workflows/lint-shellcheck.yml'
+  pull_request:
+    paths:
+      - '**/*.{sh,bash,fish}'
+      - './bin/*'
+      - './scripts/git/pre-commit'
+      - '.github/workflows/lint-shellcheck.yml'
+
+name: "Lint Shellcheck"
+permissions: {}
+
+jobs:
+  shellcheck:
+    name: Shellcheck
+    runs-on: ubuntu-24.04
+    steps:
+      - run: shellcheck --version
+      - uses: actions/checkout@v4
+      - run: make lint-shellcheck
diff --git a/Makefile b/Makefile
index 00fb74ae5386..5aade3c5919f 100644
--- a/Makefile
+++ b/Makefile
@@ -133,6 +133,12 @@ interpreter_spec: $(O)/interpreter_spec$(EXE) ## Run interpreter specs
 smoke_test: ## Build specs as a smoke test
 smoke_test: $(O)/std_spec$(EXE) $(O)/compiler_spec$(EXE) $(O)/$(CRYSTAL_BIN)
 
+SHELLCHECK_SOURCES := $(wildcard **/*.sh) bin/crystal bin/ci bin/check-compiler-flag scripts/git/pre-commit
+
+.PHONY: lint-shellcheck
+lint-shellcheck:
+	shellcheck --severity=warning $(SHELLCHECK_SOURCES)
+
 .PHONY: all_spec
 all_spec: $(O)/all_spec$(EXE) ## Run all specs (note: this builds a huge program; `test` recipe builds individual binaries and is recommended for reduced resource usage)
 	$(O)/all_spec$(EXE) $(SPEC_FLAGS)
diff --git a/bin/ci b/bin/ci
index c2ffba8f341d..529b5cb2e079 100755
--- a/bin/ci
+++ b/bin/ci
@@ -1,14 +1,14 @@
 #!/bin/sh
 
 fail() {
-  echo "${@}" >&2
+  echo "${*}" >&2
   exit 1
 }
 
 on_tag() {
   if [ -n "$CURRENT_TAG" ]; then
-    echo "${@}"
-    eval "${@}"
+    echo "${*}"
+    eval "${*}"
     return $?
   else
     return 0
@@ -20,7 +20,7 @@ fail_on_error() {
 
   exit=$?
   if [ "$exit" -ne "0" ]; then
-    fail "${@} exited with $exit"
+    fail "${*} exited with $exit"
   fi
 
   return 0
@@ -50,8 +50,8 @@ on_os() {
     verify_environment
 
     if [ "$TRAVIS_OS_NAME" = "$os" ]; then
-      echo "${@}"
-      eval "${@}"
+      echo "${*}"
+      eval "${*}"
       return $?
     else
       return 0
@@ -62,17 +62,17 @@ on_os() {
 }
 
 on_linux() {
-  fail_on_error on_os "linux" "${@}"
+  fail_on_error on_os "linux" "${*}"
 }
 
 on_osx() {
-  fail_on_error on_os "osx" "${@}"
+  fail_on_error on_os "osx" "${*}"
 }
 
 on_nix_shell_eval() {
   if [ -n "$CI_NIX_SHELL" ]; then
-    echo "${@}"
-    eval "${@}"
+    echo "${*}"
+    eval "${*}"
     return $?
   else
     return 0
@@ -80,12 +80,12 @@ on_nix_shell_eval() {
 }
 
 on_nix_shell() {
-  fail_on_error on_nix_shell_eval "${@}"
+  fail_on_error on_nix_shell_eval "${*}"
 }
 
 on_github() {
   if [ "$GITHUB_ACTIONS" = "true" ]; then
-    eval "${@}"
+    eval "${*}"
     return $?
   else
     return 0
@@ -153,7 +153,7 @@ prepare_build() {
 
   # Install a recent bash version for nix-shell.
   # macos ships with an ancient one.
-  if [ `uname` = "Darwin" ]; then
+  if [ "$(uname)" = "Darwin" ]; then
     on_nix_shell "brew install bash"
   fi
   # initialize nix environment
@@ -172,7 +172,7 @@ prepare_build() {
 
 verify_version() {
   # If building a tag, check it matches with file
-  FILE_VERSION=`cat ./src/VERSION`
+  FILE_VERSION=$(cat ./src/VERSION)
 
   if [ "$FILE_VERSION" != "$CURRENT_TAG" ]
   then
@@ -205,8 +205,8 @@ with_build_env() {
 
   on_linux docker run \
     --rm -t \
-    -u $(id -u) \
-    -v $PWD:/mnt \
+    -u "$(id -u)" \
+    -v "$PWD":/mnt \
     -v /etc/passwd:/etc/passwd \
     -v /etc/group:/etc/group \
     -w /mnt \
@@ -222,6 +222,7 @@ with_build_env() {
     CRYSTAL_CACHE_DIR="/tmp/crystal" \
     /bin/sh -c "'$command'"
 
+  # shellcheck disable=SC2086
   on_nix_shell nix-shell --pure $CI_NIX_SHELL_ARGS --run "'TZ=$TZ $command'"
 
   on_github echo "::endgroup::"
@@ -254,7 +255,7 @@ case $command in
     prepare_build
     ;;
   with_build_env)
-    target_command="${@}"
+    target_command="${*}"
     with_build_env "$target_command"
     ;;
   build)
diff --git a/bin/crystal b/bin/crystal
index ad5e3357c985..ef8710c4ff93 100755
--- a/bin/crystal
+++ b/bin/crystal
@@ -175,7 +175,7 @@ fi
 # CRYSTAL_PATH has all symlinks resolved. In order to avoid issues with duplicate file
 # paths when the working directory is a symlink, we cd into the current directory
 # with symlinks resolved as well (see https://github.com/crystal-lang/crystal/issues/12969).
-cd "$(realpath "$(pwd)")"
+cd "$(realpath "$(pwd)")" || exit
 
 case "$(uname -s)" in
   CYGWIN*|MSYS_NT*|MINGW32_NT*|MINGW64_NT*)
diff --git a/scripts/git/pre-commit b/scripts/git/pre-commit
index 2624b25a1fd7..f2f1b011667b 100755
--- a/scripts/git/pre-commit
+++ b/scripts/git/pre-commit
@@ -22,7 +22,9 @@ changed_cr_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.cr$
 
 if [ -x bin/crystal ]; then
   # use bin/crystal wrapper when available to run local compiler build
+  # shellcheck disable=SC2086
   exec bin/crystal tool format --check $changed_cr_files >&2
 else
+  # shellcheck disable=SC2086
   exec crystal tool format --check $changed_cr_files >&2
 fi
diff --git a/scripts/release-update.sh b/scripts/release-update.sh
index ed2f8c111685..32d46efa0295 100755
--- a/scripts/release-update.sh
+++ b/scripts/release-update.sh
@@ -44,13 +44,13 @@ sed -i -E "s|crystal: \"[0-9.]+\"|crystal: \"$CRYSTAL_VERSION\"|g" .github/workf
 
 # Edit shell.nix latestCrystalBinary using nix-prefetch-url --unpack <url>
 darwin_url="https://github.com/crystal-lang/crystal/releases/download/$CRYSTAL_VERSION/crystal-$CRYSTAL_VERSION-1-darwin-universal.tar.gz"
-darwin_sha=$(nix-prefetch-url --unpack $darwin_url)
+darwin_sha=$(nix-prefetch-url --unpack "$darwin_url")
 
 sed -i -E "s|https://github.com/crystal-lang/crystal/releases/download/[0-9.]+/crystal-[0-9.]+-[0-9]-darwin-universal.tar.gz|$darwin_url|" shell.nix
 sed -i -E "/darwin-universal\.tar\.gz/ {n;s|sha256:[^\"]+|sha256:$darwin_sha|}" shell.nix
 
 linux_url="https://github.com/crystal-lang/crystal/releases/download/$CRYSTAL_VERSION/crystal-$CRYSTAL_VERSION-1-linux-x86_64.tar.gz"
-linux_sha=$(nix-prefetch-url --unpack $linux_url)
+linux_sha=$(nix-prefetch-url --unpack "$linux_url")
 
 sed -i -E "s|https://github.com/crystal-lang/crystal/releases/download/[0-9.]+/crystal-[0-9.]+-[0-9]-linux-x86_64.tar.gz|$linux_url|" shell.nix
 sed -i -E "/linux-x86_64\.tar\.gz/ {n;s|sha256:[^\"]+|sha256:$linux_sha|}" shell.nix
diff --git a/scripts/update-changelog.sh b/scripts/update-changelog.sh
index 763e63670f43..f38ac4b8362c 100755
--- a/scripts/update-changelog.sh
+++ b/scripts/update-changelog.sh
@@ -35,10 +35,10 @@ branch="changelog/$VERSION"
 current_changelog="CHANGELOG.$VERSION.md"
 
 echo "Generating $current_changelog..."
-scripts/github-changelog.cr $VERSION > $current_changelog
+scripts/github-changelog.cr "$VERSION" > "$current_changelog"
 
 echo "Switching to branch $branch"
-git switch $branch 2>/dev/null || git switch -c $branch;
+git switch "$branch" 2>/dev/null || git switch -c "$branch";
 
 # Write release version into src/VERSION
 echo "${VERSION}" > src/VERSION
@@ -49,8 +49,8 @@ sed -i -E "s/version: .*/version: ${VERSION}/" shard.yml
 git add shard.yml
 
 # Write release date into src/SOURCE_DATE_EPOCH
-release_date=$(head -n1 $current_changelog | grep -o -P '(?<=\()[^)]+')
-echo "$(date --utc --date="${release_date}" +%s)" > src/SOURCE_DATE_EPOCH
+release_date=$(head -n1 "$current_changelog" | grep -o -P '(?<=\()[^)]+')
+date --utc --date="${release_date}" +%s > src/SOURCE_DATE_EPOCH
 git add src/SOURCE_DATE_EPOCH
 
 if grep --silent -E "^## \[$VERSION\]" CHANGELOG.md; then
@@ -70,7 +70,7 @@ else
 
   git add CHANGELOG.md
   git commit -m "Add changelog for $VERSION"
-  git push -u upstream $branch
+  git push -u upstream "$branch"
 
   gh pr create --draft --base "$base_branch" \
     --body "Preview: https://github.com/crystal-lang/crystal/blob/$branch/CHANGELOG.md" \
diff --git a/scripts/update-distribution-scripts.sh b/scripts/update-distribution-scripts.sh
index 467e5b036de5..bccf44acd060 100755
--- a/scripts/update-distribution-scripts.sh
+++ b/scripts/update-distribution-scripts.sh
@@ -35,14 +35,14 @@ branch="${2:-"ci/update-distribution-scripts"}"
 git switch -C "$branch" master
 
 old_reference=$(sed -n "/distribution-scripts-version:/{n;n;n;p}" .circleci/config.yml | grep -o -P '(?<=default: ")[^"]+')
-echo $old_reference..$reference
+echo "$old_reference".."$reference"
 
 sed -i -E "/distribution-scripts-version:/{n;n;n;s/default: \".*\"/default: \"$reference\"/}" .circleci/config.yml
 
 git add .circleci/config.yml
 
 message="Updates \`distribution-scripts\` dependency to https://github.com/crystal-lang/distribution-scripts/commit/$reference"
-log=$($GIT_DS log $old_reference..$reference --format="%s" | sed "s/.*(/crystal-lang\/distribution-scripts/;s/^/* /;s/)$//")
+log=$($GIT_DS log "$old_reference".."$reference" --format="%s" | sed "s/.*(/crystal-lang\/distribution-scripts/;s/^/* /;s/)$//")
 message=$(printf "%s\n\nThis includes the following changes:\n\n%s" "$message" "$log")
 
 git commit -m "Update distribution-scripts" -m "$message"
diff --git a/scripts/update-shards.sh b/scripts/update-shards.sh
index 47dde35056a2..05b8b6cc5f73 100755
--- a/scripts/update-shards.sh
+++ b/scripts/update-shards.sh
@@ -20,4 +20,4 @@ if [ -z "$SHARDS_VERSION" ]; then
 fi
 
 # Update shards ref in mingw64 and win-msvc build actions
-sed -i "/repository: crystal-lang\/shards/{n;s/ref: .*/ref: ${shards_version}/}" .github/workflows/mingw-w64.yml .github/workflows/win_build_portable.yml
+sed -i "/repository: crystal-lang\/shards/{n;s/ref: .*/ref: ${SHARDS_VERISON}/}" .github/workflows/mingw-w64.yml .github/workflows/win_build_portable.yml
diff --git a/spec/debug/test.sh b/spec/debug/test.sh
index f2fd7cb8ac3e..473ff5c77d10 100755
--- a/spec/debug/test.sh
+++ b/spec/debug/test.sh
@@ -36,11 +36,11 @@ BUILD_DIR=$SCRIPT_ROOT/../../.build
 crystal=${CRYSTAL_SPEC_COMPILER_BIN:-$SCRIPT_ROOT/../../bin/crystal}
 debugger=${1:-lldb}
 driver=$BUILD_DIR/debug_driver
-mkdir -p $BUILD_DIR
-"$crystal" build $SCRIPT_ROOT/driver.cr -o $driver
+mkdir -p "$BUILD_DIR"
+"$crystal" build "$SCRIPT_ROOT"/driver.cr -o "$driver"
 
-$driver $SCRIPT_ROOT/top_level.cr $debugger
-$driver $SCRIPT_ROOT/strings.cr $debugger
-$driver $SCRIPT_ROOT/arrays.cr $debugger
-$driver $SCRIPT_ROOT/blocks.cr $debugger
-$driver $SCRIPT_ROOT/large_enums.cr $debugger
+$driver "$SCRIPT_ROOT"/top_level.cr "$debugger"
+$driver "$SCRIPT_ROOT"/strings.cr "$debugger"
+$driver "$SCRIPT_ROOT"/arrays.cr "$debugger"
+$driver "$SCRIPT_ROOT"/blocks.cr "$debugger"
+$driver "$SCRIPT_ROOT"/large_enums.cr "$debugger"
diff --git a/spec/generate_wasm32_spec.sh b/spec/generate_wasm32_spec.sh
index a6388e0cc8e4..8f77a9da21fb 100755
--- a/spec/generate_wasm32_spec.sh
+++ b/spec/generate_wasm32_spec.sh
@@ -24,7 +24,7 @@ set +x
 
 WORK_DIR=$(mktemp -d)
 function cleanup {
-  rm -rf $WORK_DIR
+  rm -rf "$WORK_DIR"
 }
 trap cleanup EXIT
 
@@ -39,8 +39,8 @@ echo
 
 for spec in $(find "spec/std" -type f -iname "*_spec.cr" | LC_ALL=C sort); do
   require="require \"./${spec##spec/}\""
-  target=$WORK_DIR"/"$spec".wasm"
-  mkdir -p $(dirname "$target")
+  target="$WORK_DIR/$spec.wasm"
+  mkdir -p "$(dirname "$target")"
 
   if ! output=$(bin/crystal build "$spec" -o "$target" --target wasm32-wasi 2>&1); then
     if [[ "$output" =~ "execution of command failed" ]]; then
diff --git a/spec/llvm-ir/test.sh b/spec/llvm-ir/test.sh
index e38a0ca601df..9090edf0096f 100755
--- a/spec/llvm-ir/test.sh
+++ b/spec/llvm-ir/test.sh
@@ -9,31 +9,32 @@ SCRIPT_PATH="$(realpath "$0")"
 SCRIPT_ROOT="$(dirname "$SCRIPT_PATH")"
 
 BUILD_DIR=$SCRIPT_ROOT/../../.build
-LLVM_CONFIG="$(basename $($SCRIPT_ROOT/../../src/llvm/ext/find-llvm-config.sh))"
+LLVM_CONFIG="$(basename $("$SCRIPT_ROOT"/../../src/llvm/ext/find-llvm-config.sh))"
 FILE_CHECK=FileCheck-"${LLVM_CONFIG#llvm-config-}"
 crystal=${CRYSTAL_SPEC_COMPILER_BIN:-$SCRIPT_ROOT/../../bin/crystal}
 
-mkdir -p $BUILD_DIR
+mkdir -p "$BUILD_DIR"
 
 function test() {
-  echo "test: $@"
+  echo "test: $*"
 
   input_cr="$SCRIPT_ROOT/$1"
   output_ll="$BUILD_DIR/${1%.cr}.ll"
-  compiler_options="$2"
+  # FIXME: unused variable
+  # compiler_options="$2"
   check_prefix="${3+--check-prefix $3}"
 
   # $BUILD_DIR/test-ir is never used
   # pushd $BUILD_DIR + $output_ll is a workaround due to the fact that we can't control
   # the filename generated by --emit=llvm-ir
-  "$crystal" build --single-module --no-color --emit=llvm-ir $2 -o $BUILD_DIR/test-ir $input_cr
-  $FILE_CHECK $input_cr --input-file $output_ll $check_prefix
+  "$crystal" build --single-module --no-color --emit=llvm-ir "$2" -o "$BUILD_DIR"/test-ir "$input_cr"
+  $FILE_CHECK "$input_cr" --input-file "$output_ll" "$check_prefix"
 
-  rm $BUILD_DIR/test-ir.o
-  rm $output_ll
+  rm "$BUILD_DIR"/test-ir.o
+  rm "$output_ll"
 }
 
-pushd $BUILD_DIR >/dev/null
+pushd "$BUILD_DIR" >/dev/null
 
 test argless-initialize-debug-loc.cr "--cross-compile --target x86_64-unknown-linux-gnu --prelude=empty"
 test proc-call-debug-loc.cr "--cross-compile --target x86_64-unknown-linux-gnu --prelude=empty"
diff --git a/src/llvm/ext/find-llvm-config.sh b/src/llvm/ext/find-llvm-config.sh
index 5aa381aaf13b..eaceefda81be 100755
--- a/src/llvm/ext/find-llvm-config.sh
+++ b/src/llvm/ext/find-llvm-config.sh
@@ -2,15 +2,16 @@
 
 if ! LLVM_CONFIG=$(command -v "$LLVM_CONFIG"); then
   llvm_config_version=$(llvm-config --version 2>/dev/null)
-  for version in $(cat "$(dirname $0)/llvm-versions.txt"); do
+  # shellcheck disable=SC2013
+  for version in $(cat "$(dirname "$0")/llvm-versions.txt"); do
     LLVM_CONFIG=$(
-    ([ "${llvm_config_version#$version}" != "$llvm_config_version" ] && command -v llvm-config) || \
-    command -v llvm-config-${version%.*} || \
-    command -v llvm-config-$version || \
-    command -v llvm-config${version%.*}${version#*.} || \
-    command -v llvm-config${version%.*} || \
-    command -v llvm-config$version || \
-    command -v llvm${version%.*}-config)
+    ([ "${llvm_config_version#"$version"}" != "$llvm_config_version" ] && command -v llvm-config) || \
+    command -v llvm-config-"${version%.*}" || \
+    command -v llvm-config-"$version" || \
+    command -v "llvm-config${version%.*}${version#*.}" || \
+    command -v llvm-config"${version%.*}" || \
+    command -v llvm-config"$version" || \
+    command -v llvm"${version%.*}"-config)
     [ "$LLVM_CONFIG" ] && break
   done
 fi
@@ -26,6 +27,6 @@ if [ "$LLVM_CONFIG" ]; then
   esac
 else
   printf "Error: Could not find location of llvm-config. Please specify path in environment variable LLVM_CONFIG.\n" >&2
-  printf "Supported LLVM versions: $(cat "$(dirname $0)/llvm-versions.txt" | sed 's/\.0//g')\n" >&2
+  printf "Supported LLVM versions: %s\n" "$(sed 's/\.0//g' "$(dirname "$0")/llvm-versions.txt")" >&2
   exit 1
 fi