Bash scripts can orchestrate Docker operations, but their real power lies in handling the state of your containers and images, not just running commands.

Let’s see it in action. Imagine you have a multi-stage build process for a web application.

#!/bin/bash

# --- Configuration ---
APP_NAME="my-web-app"
BUILD_IMAGE_NAME="my-web-app-builder"
RUN_IMAGE_NAME="my-web-app-runner"
PRODUCTION_TAG="latest"
DEV_TAG="dev"

# --- Functions ---

build_app() {
    echo ">>> Building application image..."
    docker build -t "$BUILD_IMAGE_NAME" -f Dockerfile.builder .
    if [ $? -ne 0 ]; then
        echo "!!! Application build failed."
        exit 1
    fi
    echo ">>> Application build successful."
}

package_app() {
    echo ">>> Packaging application artifact..."
    # This assumes your builder image creates an artifact (e.g., a JAR, a binary)
    # We'll copy it out to a temporary location for the runner image to use.
    CONTAINER_ID=$(docker create "$BUILD_IMAGE_NAME")
    docker cp "$CONTAINER_ID:/app/build/my-app.jar" ./my-app.jar
    docker rm "$CONTAINER_ID" > /dev/null
    echo ">>> Artifact packaged to ./my-app.jar."
}

build_runner_image() {
    echo ">>> Building runner image..."
    docker build -t "$RUN_IMAGE_NAME" -f Dockerfile.runner .
    if [ $? -ne 0 ]; then
        echo "!!! Runner image build failed."
        exit 1
    fi
    echo ">>> Runner image built successfully."
}

tag_production() {
    echo ">>> Tagging production image..."
    docker tag "$RUN_IMAGE_NAME" "$APP_NAME:$PRODUCTION_TAG"
    echo ">>> Tagged as $APP_NAME:$PRODUCTION_TAG."
}

tag_dev() {
    echo ">>> Tagging development image..."
    docker tag "$RUN_IMAGE_NAME" "$APP_NAME:$DEV_TAG"
    echo ">>> Tagged as $APP_NAME:$DEV_TAG."
}

cleanup() {
    echo ">>> Cleaning up intermediate images..."
    docker rmi "$BUILD_IMAGE_NAME" || true
    docker rmi "$RUN_IMAGE_NAME" || true
    rm -f ./my-app.jar || true
    echo ">>> Cleanup complete."
}

# --- Main Execution ---

# Clean up any previous runs first
cleanup

# Perform the build and package steps
build_app
package_app
build_runner_image

# Tag for different environments
tag_production
tag_dev

# Final cleanup
cleanup

echo ">>> All Docker tasks automated successfully!"

This script defines distinct stages: building the application code, packaging its output, and then building a lean runtime image. It uses docker build for image creation, docker create and docker cp to move artifacts between containers, and docker tag for versioning. The if [ $? -ne 0 ] checks are crucial for ensuring each step completes before proceeding, preventing cascading failures.

The mental model here is a pipeline. Each function represents a stage in that pipeline. The Dockerfile.builder might have dependencies like JDK, Maven, or Node.js to compile your code. The Dockerfile.runner would only contain the JRE or a minimal runtime environment, plus the artifact copied from the builder stage. This separation is key to achieving small, secure production images.

You control this pipeline through environment variables (APP_NAME, PRODUCTION_TAG, etc.) and by modifying the Dockerfiles. For instance, changing RUN_IMAGE_NAME to my-app-prod would only affect the name of the final image, not the build logic.

A common pitfall is not realizing that docker build itself can be a multi-stage process within a single Dockerfile. If you’ve defined multiple FROM statements, each one represents a distinct build stage. The docker build command will execute them sequentially, and you can target specific stages using the --target flag. For example, docker build --target builder -t my-builder-image . would only execute up to the FROM instruction labeled builder. This allows you to build intermediate images without needing separate Dockerfiles for each stage, significantly simplifying your workflow when you only need specific parts of a larger build process.

The next logical step is to integrate this script into a CI/CD pipeline, perhaps triggering it on a Git push.

Want structured learning?

Take the full Bash course →