Learn

/

Build Scripts

Build Scripts

4 patterns

Makefiles, Ant build files, Gradle tasks, and shell scripts for container workflows. You'll hit this when your team needs a single command to build, test, and deploy containers.

Avoid
#!/bin/bash
# build.sh - hard to discover, no help

docker build -t myapp:latest .
docker run --rm -p 3000:3000 myapp:latest

# Different scripts for different tasks
# No dependency tracking
# No tab completion
#!/bin/bash
# build.sh - hard to discover, no help

docker build -t myapp:latest .
docker run --rm -p 3000:3000 myapp:latest

# Different scripts for different tasks
# No dependency tracking
# No tab completion

Prefer
# Makefile - self-documenting
.PHONY: build run test clean

build: ## Build the container image
	docker build -t myapp:latest .

run: build ## Run the app locally
	docker run --rm -p 3000:3000 myapp:latest

test: build ## Run tests in container
	docker run --rm myapp:latest npm test

clean: ## Remove images and volumes
	docker compose down -v
	docker rmi myapp:latest

help: ## Show available targets
	@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | sort | \
	  awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'
# Makefile - self-documenting
.PHONY: build run test clean

build: ## Build the container image
	docker build -t myapp:latest .

run: build ## Run the app locally
	docker run --rm -p 3000:3000 myapp:latest

test: build ## Run tests in container
	docker run --rm myapp:latest npm test

clean: ## Remove images and volumes
	docker compose down -v
	docker rmi myapp:latest

help: ## Show available targets
	@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | sort | \
	  awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'
Why avoid

Scattered shell scripts are hard to discover and have no built-in dependency tracking. New team members don't know which script to run. There's no way to list available commands or ensure prerequisites are met before running a task.

Why prefer

Makefiles provide discoverable, self-documenting commands with dependency tracking. make run automatically builds first if needed. The help target documents all available commands. Make is pre-installed on most Unix systems and supports tab completion.

GNU Make manual
Avoid
<!-- build.xml - manual process -->
<project name="myapp" default="build">
  <target name="build">
    <javac srcdir="src"
           destdir="build/classes"
           includeantruntime="false"/>
    <jar destfile="build/myapp.jar"
         basedir="build/classes"/>
  </target>

  <!-- Developer must manually
       docker build after ant build -->
</project>
<!-- build.xml - manual process -->
<project name="myapp" default="build">
  <target name="build">
    <javac srcdir="src"
           destdir="build/classes"
           includeantruntime="false"/>
    <jar destfile="build/myapp.jar"
         basedir="build/classes"/>
  </target>

  <!-- Developer must manually
       docker build after ant build -->
</project>

Prefer
<!-- build.xml - integrated pipeline -->
<project name="myapp" default="docker-build">
  <target name="compile">
    <javac srcdir="src"
           destdir="build/classes"
           includeantruntime="false"/>
  </target>

  <target name="jar" depends="compile">
    <jar destfile="build/myapp.jar"
         basedir="build/classes"/>
  </target>

  <target name="docker-build" depends="jar">
    <exec executable="docker" failonerror="true">
      <arg line="build -t myapp:latest ."/>
    </exec>
  </target>

  <target name="docker-push" depends="docker-build">
    <exec executable="docker" failonerror="true">
      <arg line="push registry.io/myapp:latest"/>
    </exec>
  </target>
</project>
<!-- build.xml - integrated pipeline -->
<project name="myapp" default="docker-build">
  <target name="compile">
    <javac srcdir="src"
           destdir="build/classes"
           includeantruntime="false"/>
  </target>

  <target name="jar" depends="compile">
    <jar destfile="build/myapp.jar"
         basedir="build/classes"/>
  </target>

  <target name="docker-build" depends="jar">
    <exec executable="docker" failonerror="true">
      <arg line="build -t myapp:latest ."/>
    </exec>
  </target>

  <target name="docker-push" depends="docker-build">
    <exec executable="docker" failonerror="true">
      <arg line="push registry.io/myapp:latest"/>
    </exec>
  </target>
</project>
Why avoid

Separating the Ant build from the Docker build means developers must remember to run both in the right order. Forgetting to rebuild the JAR before docker build results in deploying stale code. There's no single command for the full pipeline.

Why prefer

Integrating Docker commands into Ant's dependency chain ensures the JAR is always built before the image. depends enforces the order: compile, then JAR, then Docker build. failonerror="true" stops the pipeline if any step fails.

Ant docs: exec task
Avoid
# README.md says:
# 1. Install Node 20
# 2. Install PostgreSQL 16
# 3. Create database "myapp"
# 4. Copy .env.example to .env
# 5. Run npm install
# 6. Run npm run migrate
# 7. Run npm run dev
# Good luck!
# README.md says:
# 1. Install Node 20
# 2. Install PostgreSQL 16
# 3. Create database "myapp"
# 4. Copy .env.example to .env
# 5. Run npm install
# 6. Run npm run migrate
# 7. Run npm run dev
# Good luck!

Prefer
# Makefile
.PHONY: dev setup test

setup: ## First-time setup
	cp -n .env.example .env 2>/dev/null || true
	docker compose build

dev: ## Start development environment
	docker compose up

test: ## Run tests
	docker compose run --rm app npm test

# docker-compose.yml handles:
# - Node.js version
# - PostgreSQL setup
# - Database creation
# - Hot reloading
# Makefile
.PHONY: dev setup test

setup: ## First-time setup
	cp -n .env.example .env 2>/dev/null || true
	docker compose build

dev: ## Start development environment
	docker compose up

test: ## Run tests
	docker compose run --rm app npm test

# docker-compose.yml handles:
# - Node.js version
# - PostgreSQL setup
# - Database creation
# - Hot reloading
Why avoid

Manual setup instructions are error-prone, platform-specific, and quickly become outdated. Different developers end up with different versions of Node, PostgreSQL, and other tools. 'Works on my machine' becomes the default state.

Why prefer

Docker Compose encapsulates the entire development environment. New developers run make setup && make dev instead of following a multi-step guide. Everyone gets the same versions, same database, same configuration. The setup is reproducible and version-controlled.

Docker docs: Compose use cases
Avoid
# Dockerfile for Java app
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./gradlew build

FROM eclipse-temurin:21-jre
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

# Needs Docker daemon
# Rebuilds entire fat JAR layer
# No layer optimization
# Dockerfile for Java app
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./gradlew build

FROM eclipse-temurin:21-jre
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

# Needs Docker daemon
# Rebuilds entire fat JAR layer
# No layer optimization

Prefer
// build.gradle.kts
plugins {
  id("com.google.cloud.tools.jib") version "3.4.4"
}

jib {
  from { image = "eclipse-temurin:21-jre" }
  to { image = "registry.io/myapp" }
  container {
    jvmFlags = listOf("-Xms256m", "-Xmx512m")
    ports = listOf("8080")
    mainClass = "com.example.App"
  }
}

// Build: ./gradlew jib
// No Docker daemon required
// Optimized layer caching
// build.gradle.kts
plugins {
  id("com.google.cloud.tools.jib") version "3.4.4"
}

jib {
  from { image = "eclipse-temurin:21-jre" }
  to { image = "registry.io/myapp" }
  container {
    jvmFlags = listOf("-Xms256m", "-Xmx512m")
    ports = listOf("8080")
    mainClass = "com.example.App"
  }
}

// Build: ./gradlew jib
// No Docker daemon required
// Optimized layer caching
Why avoid

A traditional Dockerfile packages everything into a fat JAR in a single layer. Any code change rebuilds the entire layer, including unchanged dependencies (which are often 100+ MB). It also requires a running Docker daemon in CI.

Why prefer

Jib builds optimized container images directly from your build tool without a Dockerfile or Docker daemon. It separates dependencies, resources, and classes into distinct layers, so code changes only rebuild the thin classes layer. Builds are faster and reproducible.

GitHub: Jib