Lesson 10: Docker Entrypoints & Init Scripts
Goal: Write production-ready Docker entrypoint scripts with proper signal handling, dependency waiting, and configuration.
Table of Contents
- ENTRYPOINT vs CMD
- The exec "$@" Pattern
- Minimal Entrypoint
- Wait for Dependencies
- Environment Variable Configuration
- Template Config Files
- Running as Non-Root — gosu
- Signal Handling in Docker
- Health Check Scripts
- Real-World Entrypoints
- 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), notbash. Use#!/bin/shor 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 varsgosu— drop from root to app user- Signal handling:
execpasses signals; usetrapfor cleanup - Alpine uses
sh— use#!/bin/shor install bash
Next Lesson: Lesson 11: Real-World Scripts Collection →