How to Fix Docker Unexpected Operator Error: A Complete Troubleshooting Guide

How to Fix Docker Unexpected Operator Error: A Complete Troubleshooting Guide

If you’ve spent any meaningful time building Docker images, you’ve probably run into this cryptic failure:

/bin/sh: scripts/entrypoint.sh: line 7: [: !=: unexpected operator

Or maybe:

sh: 1: [[: not found

These messages are frustrating because they rarely point to the actual problem. Your script works perfectly on your local machine, passes every manual test, and then explodes the moment it runs inside a container. If you’re searching for how to fix docker unexpected operator error, you’re in the right place. This guide walks through every common cause, from the most frequent offender to genuinely obscure edge cases, with copy-paste-ready fixes.

What Triggers the “Unexpected Operator” Error in Docker?

Despite what the error message suggests, this isn’t a Docker bug. It’s a shell scripting error that surfaces inside containers. The message “unexpected operator” (or “unexpected token”) comes from /bin/sh, which is the default shell used when Docker executes RUN, CMD, or ENTRYPOINT instructions.

The root cause is almost always the same: your script uses Bash-specific syntax, but the container is running it with a POSIX-compliant shell like dash or BusyBox ash.

This is why the error is so common with Alpine-based images. Alpine doesn’t ship with Bash installed by default. It uses BusyBox ash, which adheres strictly to the POSIX shell standard. Any Bash-ism in your script will cause ash to choke.

The Shell Compatibility Matrix

Base Image Default /bin/sh Supports Bash Syntax?
Alpine BusyBox ash No
Ubuntu dash No
Debian dash No
CentOS / RHEL bash (symlinked) Yes
Fedora bash Yes

This table explains why a Dockerfile that works on a CentOS base suddenly fails when you switch to Alpine. The shell changed underneath you.

Root Cause Analysis: Why Your Script Breaks

Let’s look at the specific patterns that trigger the error. Understanding these helps you fix the problem at its source rather than papering over it.

Bash-Specific Comparison Operators

The single most common trigger is using == for string comparison inside [ ]:

# Works in Bash, fails in sh/dash/ash
if [ "$ENV" == "production" ]; then
    echo "Production mode"
fi

Run this in Alpine and you’ll see:

[: ==: unexpected operator

POSIX shells only recognize = for string comparison inside [ ]. The == operator is a Bash extension.

Double Bracket Syntax

Another frequent offender is the [[ ]] construct:

# Bash-only syntax
if [[ "$VAR" == "test" ]]; then
    echo "Match found"
fi

This produces:

[[: not found

The [[ ]] syntax doesn’t exist in POSIX shells at all.

The source Command

# Works in Bash, fails in sh
source /etc/profile.d/env.sh

POSIX shells use . (a single dot) instead:

. /etc/profile.d/env.sh

Process Substitution

# Bash-only
while read -r line; do
    echo "$line"
done < <(cat config.yaml)

Process substitution <() is a Bash feature that POSIX shells don’t support. You’ll need to rewrite this using pipes or temporary files.

Arrays

# Bash-only
SERVERS=("db" "cache" "queue")
for s in "${SERVERS[@]}"; do
    echo "Starting $s"
done

POSIX shells have no concept of arrays. This will fail immediately.

Step-by-Step Solutions: From Most Common to Edge Cases

Solution 1: Replace == with = in Test Conditions

This fix alone resolves the vast majority of unexpected operator errors. Go through every [ ] test in your script and replace == with =:

Before (broken in Alpine):

FROM alpine:3.20
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/sh

ENV=$1

if [ "$ENV" == "production" ]; then
    echo "Starting in production mode"
elif [ "$ENV" == "staging" ]; then
    echo "Starting in staging mode"
else
    echo "Starting in development mode"
fi

After (works everywhere):

#!/bin/sh

ENV="$1"

if [ "$ENV" = "production" ]; then
    echo "Starting in production mode"
elif [ "$ENV" = "staging" ]; then
    echo "Starting in staging mode"
else
    echo "Starting in development mode"
fi

Note the change from == to = and the added quotes around $1 to handle empty arguments safely.

Solution 2: Replace [[ ]] with [ ]

If you’re using double brackets for pattern matching or complex conditions, rewrite them:

Before:

#!/bin/sh

if [[ "$DEBUG" == "true" && "$VERBOSE" == "true" ]]; then
    echo "Full debug output enabled"
fi

After:

#!/bin/sh

if [ "$DEBUG" = "true" ] && [ "$VERBOSE" = "true" ]; then
    echo "Full debug output enabled"
fi

Chain conditions with separate [ ] blocks connected by && or ||. Don’t try to put multiple conditions inside a single [ ].

Solution 3: Fix Your Shebang Line

Sometimes the script itself is fine, but the shebang line is wrong or missing. If your Dockerfile invokes a script through /bin/sh but the script has a Bash shebang, there’s a conflict.

Problematic Dockerfile:

FROM alpine:3.20
COPY setup.sh /setup.sh
RUN chmod +x /setup.sh
RUN /bin/sh /setup.sh   # <-- Forces sh, ignoring the shebang
#!/bin/bash
# setup.sh — expects Bash but is invoked with sh

if [ "$1" == "init" ]; then
    echo "Initializing..."
fi

Fix Option A — Execute the script directly (respects the shebang):

FROM alpine:3.20
RUN apk add --no-cache bash
COPY setup.sh /setup.sh
RUN chmod +x /setup.sh
RUN /setup.sh init

Fix Option B — Run with Bash explicitly:

FROM alpine:3.20
RUN apk add --no-cache bash
COPY setup.sh /setup.sh
RUN bash /setup.sh init

Solution 4: Install Bash in Alpine Images

If your script genuinely needs Bash features (arrays, associative arrays, process substitution, or [[ ]] with regex), the cleanest solution is to install Bash:

FROM alpine:3.20

RUN apk add --no-cache bash

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]

This adds roughly 2-3 MB to your image. For most use cases, that’s an acceptable tradeoff if your script is complex.

Here’s a real example of an entrypoint script that needs Bash:

#!/bin/bash
set -euo pipefail

# Associative array — Bash only
declare -A CONFIG=(
    ["db_host"]="postgres"
    ["db_port"]="5432"
    ["redis_host"]="redis"
)

for key in "${!CONFIG[@]}"; do
    export "${key^^}"="${CONFIG[$key]}"
done

exec "$@"

If you try to run this with ash, every line will fail. Installing Bash is the correct fix here.

Solution 5: Replace source with .

A quick but easy-to-miss fix:

Before:

#!/bin/sh
source /app/.env

After:

#!/bin/sh
. /app/.env

The error message for this one is particularly unhelpful:

/app/entrypoint.sh: line 2: source: not found

Many developers mistake this for a missing file when it’s actually a missing command.

Solution 6: Rewrite Loops That Use Bash Features

A common pattern in entrypoint scripts is waiting for a database to be ready. Here’s a Bash version that fails in Alpine:

Before:

#!/bin/sh

# Wait for Postgres — uses Bash array syntax
TIMEOUT=30
ELAPSED=0

while [[ "$ELAPSED" -lt "$TIMEOUT" ]]; do
    if nc -z postgres 5432 2>/dev/null; then
        echo "Postgres is ready"
        break
    fi
    sleep 1
    ((ELAPSED++))
done

The ((ELAPSED++)) arithmetic is Bash-specific, and [[ ]] won’t work either.

After:

#!/bin/sh

TIMEOUT=30
ELAPSED=0

while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
    if nc -z postgres 5432 2>/dev/null; then
        echo "Postgres is ready"
        break
    fi
    sleep 1
    ELAPSED=$((ELAPSED + 1))
done

if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
    echo "Timeout waiting for Postgres"
    exit 1
fi

Key changes:
[[ ]][ ]
((ELAPSED++))ELAPSED=$((ELAPSED + 1))

Solution 7: Use ShellCheck to Catch Issues Before Building

Rather than discovering these errors during a Docker build, catch them locally with ShellCheck. Install it and run it against your scripts:

# Install ShellCheck on macOS
brew install shellcheck

# Install on Ubuntu/Debian
sudo apt-get install shellcheck

# Install on Alpine (for CI pipelines)
apk add shellcheck

Then check your script:

shellcheck --shell=sh entrypoint.sh

The --shell=sh flag tells ShellCheck to validate against POSIX shell, which matches what Alpine and Debian use as /bin/sh. Output looks like this:

In entrypoint.sh line 5:
if [ "$ENV" == "production" ]; then
               ^-- SC2086: Double quote to prevent globbing
                   ^-- SC2278: In POSIX sh, == in place of = is undefined.

Integrate this into your CI pipeline:

# .github/workflows/lint.yml
name: Shell Script Lint
on: [push, pull_request]

jobs:
  shellcheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run ShellCheck
        uses: ludeeus/action-shellcheck@2.0.0
        with:
          severity: warning
          check_to: all
          additional_files: '*.sh'

Solution 8: Debug a Failing Build Step by Step

When the error occurs during a RUN instruction rather than at runtime, you need a different debugging approach. Here’s a technique to inspect what’s happening at each build layer:

FROM alpine:3.20

# Add this to see what shell you're actually using
RUN echo "Shell: $0" && ls -la /bin/sh && /bin/sh --version 2>&1 || true

COPY scripts/ /scripts/
RUN chmod +x /scripts/*.sh

# Run with explicit error output
SHELL ["/bin/sh", "-x"]
RUN /scripts/build.sh

The -x flag makes the shell print each command before executing it, which shows you exactly which line fails:

+ ENV=production
+ [ production == production ]
/bin/sh: scripts/build.sh: line 3: [: ==: unexpected operator

You can also use SHELL instruction in the Dockerfile to temporarily switch to Bash for a specific step:

FROM alpine:3.20
RUN apk add --no-cache bash

# Use Bash for this step
SHELL ["/bin/bash", "-c"]
RUN complex_bash_logic.sh

# Switch back to sh
SHELL ["/bin/sh", "-c"]
RUN simple_posix_command

Solution 9: Handle the set -e Interaction

Here’s an edge case that trips up even experienced developers. You add set -e to your script for safety, and suddenly a perfectly valid [ ] test causes the container to exit:

#!/bin/sh
set -e

ENV="production"

# This works, but...
[ "$ENV" = "production" ]
echo "This line may or may not run"

The issue is that [ "$ENV" = "production" ] returns exit code 1 when the test is false, and set -e interprets any non-zero exit as a failure. The fix is to use if or combine with ||:

#!/bin/sh
set -e

ENV="$1"

if [ "$ENV" = "production" ]; then
    echo "Production mode"
elif [ "$ENV" = "staging" ]; then
    echo "Staging mode"
fi

Or use the short-circuit pattern:

[ "$ENV" = "production" ] && echo "Production mode"
[ "$ENV" = "staging" ] && echo "Staging mode"

The && construct handles the non-zero exit gracefully without triggering set -e.

Solution 10: Address Multi-Arch Build Issues (Edge Case)

If you’re building multi-architecture images with docker buildx, you might encounter the unexpected operator error only on certain platforms. This happens when a base image provides different /bin/sh implementations per architecture:

# Build for multiple architectures
docker buildx build \
    --platform linux/amd64,linux/arm64,linux/arm/v7 \
    -t myapp:latest \
    .

On arm/v7, some base images use a different BusyBox version with subtle POSIX differences. The fix is to be explicit about your shell:

FROM alpine:3.20

# Pin a specific shell rather than relying on the default
RUN apk add --no-cache bash

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Always invoke with bash, never rely on /bin/sh
ENTRYPOINT ["/bin/bash", "-euo", "pipefail", "/entrypoint.sh"]

This eliminates ambiguity across architectures.

Prevention Tips

Fixing the error is good. Preventing it from happening again is better. Here are the practices I’ve adopted after years of containerizing applications:

1. Standardize on POSIX Shell for Simple Scripts

For entrypoint scripts that do basic setup work (exporting variables, waiting for services, conditional logic), write everything in POSIX-compatible shell. This makes your scripts portable across every base image:

#!/bin/sh
set -eu

# Wait for a service to be ready
wait_for() {
    host="$1"
    port="$2"
    timeout=30
    elapsed=0

    while [ "$elapsed" -lt "$timeout" ]; do
        if nc -z "$host" "$port" 2>/dev/null; then
            echo "$host:$port is ready"
            return 0
        fi
        sleep 1
        elapsed=$((elapsed + 1))
    done

    echo "Timeout waiting for $host:$port" >&2
    return 1
}

wait_for "${DB_HOST:-localhost}" "${DB_PORT:-5432}"

exec "$@"

This script works identically on Alpine, Ubuntu, Debian, and CentOS. No surprises.

2. Use a Dockerfile Linter

Hadolint catches common Dockerfile issues, including shell compatibility problems:

# Install Hadolint
brew install hadolint    # macOS
# or download from GitHub releases

# Lint your Dockerfile
hadolint Dockerfile

Example output:

Dockerfile:3 SC2086info: Double quote to prevent globbing and word splitting
Dockerfile:7 DL3018warning: Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`

3. Create a Shell Template

Maintain a POSIX-safe entrypoint template and reuse it across projects:

“`bash

!/bin/sh

set -eu

— Environment defaults —

APP_ENV=”${APP_ENV:-development}”
APP_PORT=”${APP_PORT:-8080}”
LOG_LEVEL=”${LOG_LEVEL:-info}”

— Functions —

Leave a Reply

Your email address will not be published. Required fields are marked *