Lesson 10: Docker Entrypoints & Init Scripts

Goal: Write production-ready Docker entrypoint scripts with proper signal handling, dependency waiting, and configuration.


Table of Contents

  1. ENTRYPOINT vs CMD
  2. The exec "$@" Pattern
  3. Minimal Entrypoint
  4. Wait for Dependencies
  5. Environment Variable Configuration
  6. Template Config Files
  7. Running as Non-Root — gosu
  8. Signal Handling in Docker
  9. Health Check Scripts
  10. Real-World Entrypoints
  11. Exercises

ENTRYPOINT vs CMD

Dockerfile basics

# CMD — the default command (can be overridden)
CMD ["node", "server.js"]

# ENTRYPOINT — always runs (CMD becomes arguments)
ENTRYPOINT ["./entrypoint.sh"]
CMD ["node", "server.js"]

How they combine

ENTRYPOINT ["./entrypoint.sh"]
CMD ["node", "server.js"]

When you run: docker run myimage Executes: ./entrypoint.sh node server.js

When you run: docker run myimage bash Executes: ./entrypoint.sh bash

The entrypoint runs first, CMD is passed as arguments ($@).

Shell form vs Exec form

# Exec form (recommended) — runs directly, receives signals
ENTRYPOINT ["./entrypoint.sh"]
CMD ["node", "server.js"]

# Shell form (avoid) — wraps in /bin/sh -c, breaks signal handling
ENTRYPOINT ./entrypoint.sh
CMD node server.js

Always use exec form (["..."]) for proper signal handling.


The exec "$@" Pattern

The most important pattern in Docker entrypoints:

#!/bin/bash
# entrypoint.sh

# Do setup work...
echo "Initializing..."

# Replace this shell with the CMD
exec "$@"

Why exec?

# WITHOUT exec:
# PID 1 = bash (entrypoint.sh)
# PID 2 = node server.js (child process)
# Problem: docker stop sends SIGTERM to PID 1 (bash), NOT to your app

# WITH exec:
# PID 1 = node server.js (replaced the shell)
# docker stop sends SIGTERM directly to your app ✓

exec replaces the shell process with the command. Your app becomes PID 1 and receives signals properly.


Minimal Entrypoint

The simplest production entrypoint

#!/bin/bash
set -euo pipefail

echo "Starting application..."
exec "$@"

Dockerfile

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .

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

ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]

Note for Alpine: Alpine uses sh (ash), not bash. Use #!/bin/sh or install bash:

RUN apk add --no-cache bash

Wait for Dependencies

Your app often needs a database or other service to be ready before starting.

Simple TCP wait

#!/bin/bash
set -euo pipefail

wait_for_port() {
    local host="$1"
    local port="$2"
    local timeout="${3:-30}"

    echo "Waiting for $host:$port (timeout: ${timeout}s)..."

    local start
    start=$(date +%s)

    while ! nc -z "$host" "$port" 2>/dev/null; do
        local elapsed=$(( $(date +%s) - start ))
        if (( elapsed >= timeout )); then
            echo "Timeout waiting for $host:$port" >&2
            return 1
        fi
        sleep 1
    done

    echo "$host:$port is available"
}

# Wait for database
wait_for_port "${DB_HOST:-db}" "${DB_PORT:-5432}" 60

exec "$@"

Wait for PostgreSQL specifically

#!/bin/bash
set -euo pipefail

wait_for_postgres() {
    local host="${1:-localhost}"
    local port="${2:-5432}"
    local user="${3:-postgres}"
    local max_attempts="${4:-30}"

    echo "Waiting for PostgreSQL at $host:$port..."

    for ((attempt=1; attempt<=max_attempts; attempt++)); do
        if pg_isready -h "$host" -p "$port" -U "$user" &>/dev/null; then
            echo "PostgreSQL is ready"
            return 0
        fi
        echo "  Attempt $attempt/$max_attempts..."
        sleep 2
    done

    echo "PostgreSQL not ready after $max_attempts attempts" >&2
    return 1
}

wait_for_postgres "$DB_HOST" "$DB_PORT" "$DB_USER"
exec "$@"

Wait for multiple services

#!/bin/bash
set -euo pipefail

wait_for_port() {
    local host="$1" port="$2" timeout="${3:-30}"
    local start=$(date +%s)
    while ! nc -z "$host" "$port" 2>/dev/null; do
        if (( $(date +%s) - start >= timeout )); then
            echo "Timeout: $host:$port" >&2; return 1
        fi
        sleep 1
    done
    echo "Ready: $host:$port"
}

echo "Waiting for dependencies..."
wait_for_port "${DB_HOST:-db}" "${DB_PORT:-5432}" 60
wait_for_port "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" 30
wait_for_port "${RABBITMQ_HOST:-rabbitmq}" "${RABBITMQ_PORT:-5672}" 60

echo "All dependencies ready"
exec "$@"

Environment Variable Configuration

Defaults with validation

#!/bin/bash
set -euo pipefail

# Required variables (fail if missing)
: "${DB_HOST:?DB_HOST is required}"
: "${DB_PASSWORD:?DB_PASSWORD is required}"

# Optional with defaults
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-myapp}"
DB_USER="${DB_USER:-postgres}"
LOG_LEVEL="${LOG_LEVEL:-info}"
NODE_ENV="${NODE_ENV:-production}"

echo "Config:"
echo "  DB:        $DB_HOST:$DB_PORT/$DB_NAME"
echo "  User:      $DB_USER"
echo "  Log level: $LOG_LEVEL"
echo "  Node env:  $NODE_ENV"

exec "$@"

The : "${VAR:?message}" pattern is the standard way to require variables — fails immediately with a clear error.

Export computed variables

#!/bin/bash
set -euo pipefail

# Build connection string from individual vars
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-myapp}"

# Set Node.js environment
export NODE_ENV="${NODE_ENV:-production}"

# Compute max workers based on CPU count
export WORKERS="${WORKERS:-$(nproc)}"

echo "DATABASE_URL: $DATABASE_URL"
echo "Workers: $WORKERS"

exec "$@"

Template Config Files

Replace placeholders in config files with environment variables at startup.

Using envsubst

#!/bin/bash
set -euo pipefail

# Template: /etc/nginx/conf.d/default.conf.template
# Contains: ${SERVER_NAME}, ${PROXY_PASS}, ${LISTEN_PORT}

export SERVER_NAME="${SERVER_NAME:-localhost}"
export PROXY_PASS="${PROXY_PASS:-http://app:3000}"
export LISTEN_PORT="${LISTEN_PORT:-80}"

envsubst '${SERVER_NAME} ${PROXY_PASS} ${LISTEN_PORT}' \
    < /etc/nginx/conf.d/default.conf.template \
    > /etc/nginx/conf.d/default.conf

echo "Nginx config generated for $SERVER_NAME"
exec "$@"

Template file example

# default.conf.template
server {
    listen ${LISTEN_PORT};
    server_name ${SERVER_NAME};

    location / {
        proxy_pass ${PROXY_PASS};
        proxy_set_header Host $host;
    }
}

Using sed for simple replacements

#!/bin/bash
set -euo pipefail

CONFIG="/app/config.yaml"

sed -i "s|__DB_HOST__|${DB_HOST:-localhost}|g" "$CONFIG"
sed -i "s|__DB_PORT__|${DB_PORT:-5432}|g" "$CONFIG"
sed -i "s|__API_KEY__|${API_KEY:-}|g" "$CONFIG"

exec "$@"

Running as Non-Root — gosu

Best practice: start as root for setup, then drop to a regular user.

With gosu

#!/bin/bash
set -euo pipefail

# Run setup as root
echo "Setting up permissions..."
chown -R app:app /app/data

# Drop privileges and run CMD as 'app' user
exec gosu app "$@"

Dockerfile with gosu

FROM node:20-slim

RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
RUN groupadd -r app && useradd -r -g app -d /app app

WORKDIR /app
COPY --chown=app:app . .

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

ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]

Without gosu (simpler)

FROM node:20-alpine

WORKDIR /app
COPY --chown=node:node . .

USER node
CMD ["node", "server.js"]

If you don't need root-level setup, just use USER in the Dockerfile.


Signal Handling in Docker

Why it matters

docker stop sends SIGTERM to PID 1, waits 10 seconds, then sends SIGKILL. Your app must handle SIGTERM for graceful shutdown.

Trap signals in the entrypoint

#!/bin/bash
set -euo pipefail

shutdown() {
    echo "Received shutdown signal, cleaning up..."
    # Perform cleanup
    kill -TERM "$CHILD_PID" 2>/dev/null
    wait "$CHILD_PID"
    echo "Shutdown complete"
    exit 0
}

trap shutdown SIGTERM SIGINT

# Start app in background
"$@" &
CHILD_PID=$!

# Wait for the child process
wait "$CHILD_PID"

For most apps: exec is sufficient

#!/bin/bash
set -euo pipefail

# Setup...

# exec replaces shell — app gets signals directly
exec "$@"

exec "$@" is usually enough. Only use the trap pattern if you need cleanup logic before shutdown.


Health Check Scripts

Docker healthcheck script

#!/bin/bash
# healthcheck.sh

# HTTP health check
curl -sf http://localhost:${APP_PORT:-3000}/health || exit 1

Dockerfile

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD ["./healthcheck.sh"]

Advanced health check

#!/bin/bash
set -euo pipefail

# Check HTTP endpoint
if ! curl -sf http://localhost:3000/health > /dev/null; then
    echo "HTTP check failed" >&2
    exit 1
fi

# Check database connection
if ! pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null; then
    echo "Database check failed" >&2
    exit 1
fi

# Check disk space
USAGE=$(df -h / | awk 'NR==2 {gsub(/%/,""); print $5}')
if (( USAGE > 90 )); then
    echo "Disk usage critical: ${USAGE}%" >&2
    exit 1
fi

exit 0

Real-World Entrypoints

Node.js Application

#!/bin/bash
set -euo pipefail

# --- Defaults ---
export NODE_ENV="${NODE_ENV:-production}"
export PORT="${PORT:-3000}"

# --- Wait for DB ---
if [[ -n "${DB_HOST:-}" ]]; then
    echo "Waiting for database..."
    timeout=30
    while ! nc -z "$DB_HOST" "${DB_PORT:-5432}" 2>/dev/null; do
        ((timeout--)) || { echo "DB timeout" >&2; exit 1; }
        sleep 1
    done
    echo "Database ready"
fi

# --- Run migrations ---
if [[ "${RUN_MIGRATIONS:-false}" == "true" ]]; then
    echo "Running migrations..."
    npm run migrate
fi

# --- Start app ---
echo "Starting $NODE_ENV server on port $PORT"
exec "$@"

PostgreSQL Init Script

#!/bin/bash
set -euo pipefail

# This runs as part of PostgreSQL's init
# Place in /docker-entrypoint-initdb.d/

echo "Creating application database and user..."

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE USER ${APP_DB_USER:-app} WITH PASSWORD '${APP_DB_PASS:-secret}';
    CREATE DATABASE ${APP_DB_NAME:-myapp};
    GRANT ALL PRIVILEGES ON DATABASE ${APP_DB_NAME:-myapp} TO ${APP_DB_USER:-app};
EOSQL

echo "Database initialization complete"

Nginx + envsubst

#!/bin/sh
set -eu

# Generate config from template
envsubst '${SERVER_NAME} ${BACKEND_URL}' \
    < /etc/nginx/templates/default.conf.template \
    > /etc/nginx/conf.d/default.conf

echo "Nginx configured for ${SERVER_NAME:-localhost}"
exec "$@"

Exercises

Exercise 1: Basic entrypoint

# Create entrypoint
cat > entrypoint.sh << 'EOF'
#!/bin/bash
set -euo pipefail

echo "=== Container Starting ==="
echo "Hostname: $(hostname)"
echo "Date:     $(date)"
echo "User:     $(whoami)"
echo "Args:     $*"
echo "=========================="

exec "$@"
EOF

chmod +x entrypoint.sh

# Test it locally
./entrypoint.sh echo "Hello from CMD"
./entrypoint.sh ls -la /tmp
rm entrypoint.sh

Exercise 2: Entrypoint with env config

cat > entrypoint.sh << 'EOF'
#!/bin/bash
set -euo pipefail

# Required
: "${APP_NAME:?APP_NAME is required}"

# Optional with defaults
APP_PORT="${APP_PORT:-3000}"
LOG_LEVEL="${LOG_LEVEL:-info}"
DEBUG="${DEBUG:-false}"

echo "=== $APP_NAME ==="
echo "Port:      $APP_PORT"
echo "Log level: $LOG_LEVEL"
echo "Debug:     $DEBUG"

exec "$@"
EOF

chmod +x entrypoint.sh

# Test with env vars
APP_NAME="MyApp" APP_PORT=8080 ./entrypoint.sh echo "started"

# Test missing required var
./entrypoint.sh echo "will fail" 2>&1 || true

rm entrypoint.sh

Exercise 3: Wait-for pattern

cat > wait-and-start.sh << 'EOF'
#!/bin/bash
set -euo pipefail

wait_for_port() {
    local host="$1" port="$2" timeout="${3:-10}"
    local start=$(date +%s)
    echo -n "Waiting for $host:$port "
    while ! (echo > /dev/tcp/$host/$port) 2>/dev/null; do
        if (( $(date +%s) - start >= timeout )); then
            echo " TIMEOUT"
            return 1
        fi
        echo -n "."
        sleep 1
    done
    echo " OK"
}

# Test with a port you know is open
wait_for_port "localhost" "22" 5 && echo "SSH is available" || echo "SSH not found"

exec "$@"
EOF

chmod +x wait-and-start.sh
./wait-and-start.sh echo "Dependencies ready"
rm wait-and-start.sh

Key Takeaways

  • exec "$@" — the most important pattern, replaces shell with CMD
  • Use exec form in Dockerfile: ENTRYPOINT ["./entrypoint.sh"]
  • set -euo pipefail — strict mode in every entrypoint
  • : "${VAR:?message}" — require environment variables
  • ${VAR:-default} — provide sensible defaults
  • Wait for dependencies before starting your app
  • envsubst — template config files from env vars
  • gosu — drop from root to app user
  • Signal handling: exec passes signals; use trap for cleanup
  • Alpine uses sh — use #!/bin/sh or install bash

Next Lesson: Lesson 11: Real-World Scripts Collection →