Skip to content

04 · DevOps Automation

Shell is the glue language of DevOps: it's what runs inside container entrypoints and what stitches together the steps of a CI/CD pipeline. This module covers writing robust Docker entrypoint scripts and pipeline-scripting patterns that make builds fail fast, fail loud, and stay debuggable.

Anatomy of a Docker entrypoint script

An entrypoint script's job is to prepare the container's runtime environment, then hand off to the real process — usually via exec so the process becomes PID 1 and receives signals correctly.

#!/usr/bin/env bash
# entrypoint.sh
set -euo pipefail

echo "==> starting container for ${APP_NAME:-app}"

# wait for a dependency (e.g. a database) to be reachable before continuing
wait_for() {
    local host="$1" port="$2" retries=30
    until nc -z "$host" "$port" 2>/dev/null; do
        ((retries--)) || { echo "Timed out waiting for $host:$port" >&2; exit 1; }
        echo "waiting for $host:$port..."
        sleep 1
    done
}

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

# run one-time setup (migrations, config templating) before the main process
if [[ "${RUN_MIGRATIONS:-false}" == "true" ]]; then
    echo "==> running database migrations"
    ./manage.py migrate --noinput
fi

# hand off to the main process; exec replaces this script's PID so
# signals (SIGTERM from `docker stop`) reach the real app directly
exec "$@"
# Dockerfile (relevant lines)
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["gunicorn", "app:server", "--bind", "0.0.0.0:8000"]

The CMD becomes "$@" inside the entrypoint — this lets users override the command at docker run time while still going through your setup logic first.

Why exec "$@" matters

# WRONG: the script stays as PID 1, the app runs as a child process;
# `docker stop` sends SIGTERM to the script, not the app, and the app
# may not shut down gracefully within the grace period
"$@"

# RIGHT: exec replaces the current process image entirely — the app
# becomes PID 1 and receives signals directly
exec "$@"

Handling signals for graceful shutdown

#!/usr/bin/env bash
set -euo pipefail

cleanup() {
    echo "==> received shutdown signal, stopping gracefully"
    kill -TERM "$child_pid" 2>/dev/null
    wait "$child_pid"
}
trap cleanup SIGTERM SIGINT

"$@" &
child_pid=$!
wait "$child_pid"

This variant is needed when you must run cleanup logic before the main process exits (flushing a queue, deregistering from a load balancer) — otherwise prefer the simpler exec "$@" form above.

Environment-driven configuration templating

Entrypoints often need to render a config file from environment variables before the app starts:

# render nginx config from a template using envsubst
: "${BACKEND_HOST:?BACKEND_HOST must be set}"
: "${BACKEND_PORT:=8080}"

envsubst '${BACKEND_HOST} ${BACKEND_PORT}' \
    < /etc/nginx/nginx.conf.template \
    > /etc/nginx/nginx.conf

exec nginx -g 'daemon off;'

CI/CD pipeline scripting

Pipeline scripts (whether invoked from GitHub Actions, GitLab CI, or Jenkins) benefit from the same discipline as any other production script: strict mode, clear failure messages, and steps that can be run locally to reproduce a CI failure.

#!/usr/bin/env bash
# ci/run-tests.sh — callable identically from CI and from a dev machine
set -euo pipefail

echo "::group::Installing dependencies"
pip install -r requirements.txt -r requirements-dev.txt
echo "::endgroup::"

echo "::group::Linting"
if ! flake8 src/; then
    echo "::error::Linting failed — run 'flake8 src/' locally to see details"
    exit 1
fi
echo "::endgroup::"

echo "::group::Running tests"
pytest --tb=short --junitxml=test-results.xml
echo "::endgroup::"

echo "All checks passed."

::group::/::endgroup:: are GitHub Actions log-folding markers — they degrade harmlessly to plain text on other runners, so the script stays portable.

Failing fast and reporting clearly in pipelines

set -euo pipefail

run_step() {
    local name="$1"; shift
    echo "==> $name"
    if ! "$@"; then
        echo "FAILED: $name" >&2
        exit 1
    fi
}

run_step "unit tests"        pytest tests/unit
run_step "integration tests" pytest tests/integration
run_step "build image"       docker build -t myapp:ci .

A pipeline that names each step as it fails saves a debugging round-trip compared to scrolling through raw tool output looking for the first error.

Passing data between pipeline stages

CI systems often need a script to set output variables for later stages:

# GitHub Actions: write to $GITHUB_OUTPUT so later steps can read it
version=$(git describe --tags --always)
echo "version=$version" >> "$GITHUB_OUTPUT"
# generic pattern: write JSON artifacts other stages/tools can consume
jq -n --arg version "$version" --arg status "success" \
    '{version: $version, status: $status}' > build-metadata.json

How It Actually Works

Passing data between pipeline stages in a CI/CD system almost always means serializing shell variables to a file and re-sourcing (or explicitly parsing) that file in the next stage's process, because — as covered throughout this course — each pipeline stage typically runs as its own forked (often containerized) process with no shared memory to the previous stage; there is no live variable-passing channel between them at the OS level, only whatever the CI system's runner explicitly persists to disk (or an artifact store) and re-injects.

A deployment script that ssh host 'commands' is running those commands in a completely separate shell process on a remote machine — ssh forwards your local terminal's stdin/stdout over an encrypted channel but the remote side does its own independent fork()/execve() for each command, inheriting the remote machine's environment and $PATH, not anything from your local shell, which is the same root cause behind "works locally, fails in deploy" as the cron and CI $PATH issues covered earlier in this course.

Idempotent automation (safe to re-run a deploy script) again comes down to which syscalls each step maps to: an operation built on mkdir -p, symlink-and-atomic-rename(2) swaps, or checked diff-before-write is naturally safe to repeat, while one built on blind >> appends or unconditional mkdir (without -p, which errors on existing dirs) is not — the safety is a property of the underlying filesystem call, not of "automation" as a concept.

Cheat sheet

Pattern Purpose
exec "$@" hand off to the main process as PID 1 (correct signal handling)
wait_for host port block until a dependency is reachable
trap cleanup SIGTERM SIGINT run cleanup logic before a monitored child exits
envsubst < tmpl > conf render config files from environment variables
: "${VAR:?msg}" fail fast on missing required environment variables
echo "::group::x" fold CI log output (GitHub Actions)
echo "k=v" >> "$GITHUB_OUTPUT" pass values between CI pipeline stages
run_step "name" cmd... name-and-fail-fast wrapper for pipeline steps

Exercise

Write entrypoint.sh for a fictional web app: it should wait for a DB_HOST/DB_PORT (default db/5432) to become reachable using a wait_for function, fail with a clear error if APP_SECRET is unset, print a startup banner, and finish with exec "$@" so it can be used as a Docker ENTRYPOINT with any CMD. Test it locally by running ./entrypoint.sh echo "app started" both with and without APP_SECRET set, and confirm the failure path exits non-zero with a clear message.