GitHub AI Engineering Academy · Part 8 of 16
GitHub Copilot for Bash: AI-Powered Linux Automation for DevOps Engineers
Academy curriculum (16 lessons)
Every DevOps stack, no matter how much AI tooling sits on top of it, eventually runs a shell command. Bash is the connective tissue of Linux operations: it bootstraps machines, glues CI/CD steps together, drives system administration, drives networking checks, moves and archives files, calls APIs, parses logs, feeds monitoring, ships deployments, and troubleshoots the mess when something breaks. GitHub Copilot lives right where that glue is written — the .sh file, the CI step, the terminal — turning a comment or a prompt into a working first draft. But Bash is uniquely unforgiving, and everything Copilot generates is a draft to read, ShellCheck, and test before it runs anywhere that matters.
This is Part 8 of the GitHub AI Engineering Academy. Part 3, GitHub Copilot CLI, introduced the agentic copilot command and its approval-gated command execution; Part 7, GitHub Copilot with Kubernetes, applied AI to cluster manifests. This lesson focuses entirely on Bash and Linux automation, and keeps one frame throughout: an AI-generated shell script can silently destroy data — Copilot proposes, you read and understand, ShellCheck and a test run validate, a human approves, then it reaches production.
Bash is powerful precisely because it is dangerous. A handful of its everyday traits are also its sharpest edges:
- Implicit strings and word splitting — an unquoted
$varsplits on spaces and expands globs, so one value silently becomes several arguments. - Globbing —
*and?expand against the filesystem, so a pattern in the wrong place matches files you never meant to touch. - Empty variables — an unset variable expands to nothing, turning
rm -rf "$DIR"/intorm -rf /. - Pipelines — by default a pipeline reports only the last command’s exit status, hiding a failure upstream.
- Redirects —
>overwrites without warning; a typo destroys a file. sudo, recursion, and destructive commands —rm -rf,chmod -R,dd, andmkfscombine privilege, recursion, and irreversibility.
None of that makes Bash unusable. It makes review non-negotiable. Picture the pipeline before we start:
Requirement
|
Copilot
|
Bash
|
ShellCheck
|
Test
|
Engineer Review <-- required
|
Production
Copilot assists at the writing step. It does not decide whether the script is safe to run against your systems. That decision comes from ShellCheck, a test run against disposable data, and an engineer who read every line.
What You’ll Learn
- Where Bash still fits in an AI-heavy DevOps world, and when a one-liner, a function, a full script, or Python is the right tool.
- Writing a first Bash script with Copilot — shebang, variables, quoting, conditionals, exit codes — and hardening the naive draft.
- Strict mode (
set -euo pipefail) and variable quoting — the two habits that prevent the most AI-generated shell bugs. - Functions, arguments, and exit codes — logging and retry helpers,
getoptsargument parsing, and script-defined exit meanings. - Safe file operations — dry-run-first patterns around
rm,find -delete, and archiving. - Log analysis and JSON —
grep,awk,sed, and a substantial pass on jq for API and service output. - APIs and secrets — the
curl --failpattern, auth via environment variables, and never echoing a token. - System, Docker, Kubernetes, and Terraform automation — safe, read-only diagnostics and wrappers.
- Reliability — retries,
trapcleanup, andmktemptemp files. - ShellCheck and testing, a “never blindly run AI Bash” checklist, 30 reusable prompts, a CI workflow, and a full health-check lab.
Bash vs One-Line Commands vs Python
Before writing anything, decide the right shape for the job. Copilot will happily produce a 200-line Bash script when a one-liner or a Python module would serve better — the choice is yours, not the model’s.
| Shape | Use it when | Watch out for |
|---|---|---|
| One-liner | A quick, interactive, throwaway command | Hard to review; easy to fat-finger destructively |
| Function | Repeated logic inside a script | Keep it small and single-purpose |
Script (.sh) | Reusable automation, CI steps, ops tasks | Needs strict mode, quoting, ShellCheck |
| Python | Complex data, heavy APIs, real error handling | Overkill for simple CLI orchestration |
Bash is the right tool when you are orchestrating other command-line tools (git, docker, kubectl, terraform, curl, jq), doing simple system tasks (checking disk, restarting a service, rotating a log), or gluing steps together in CI. It is compact, ubiquitous, and needs no runtime beyond the shell.
Python is the right tool when the work outgrows orchestration: nested data structures, non-trivial JSON or CSV transformation, API-heavy logic with pagination and rate limits, extensive error handling, or code a team must maintain for years. Bash’s arrays, string handling, and error model get fragile fast; Python stays readable.
A practical heuristic: if you find yourself simulating data structures in Bash, or writing more than roughly a page of branching logic, stop and reach for Python. That transition is the subject of Part 9, GitHub Copilot for Python (coming soon). For now, this lesson keeps Bash in its lane — orchestration and system automation — and flags the moments where Python is the better answer. The Bash and Python automation guides cover both ends of that spectrum.
🛠️ DevOps Tip — Ask Copilot to recommend the shape before it writes: “Should this be a Bash one-liner, a Bash script, or Python?” It will usually reason about complexity and maintainability. You still decide, but the framing is a useful sanity check against a script that has quietly grown too clever for the shell.
Create a Bash Demo Repository
Work through concrete examples in a small repository so nothing is abstract:
copilot-bash-demo/
scripts/
disk_check.sh root/fs utilization check
service_health.sh systemd service checks
log_report.sh grep/awk/sed log analysis
api_check.sh curl + jq health check
lib/
log.sh shared logging functions
samples/
access.log sanitized web access log
services.json sanitized service status JSON
tests/
test_disk_check.sh disposable-env tests
.github/
workflows/
shellcheck.yml CI: ShellCheck + tests
.gitignore
README.md
Keeping scripts under scripts/, shared helpers under scripts/lib/, sanitized fixtures under samples/, and tests under tests/ mirrors how real automation repos are laid out — and it makes the CI step at the end (shellcheck scripts/*.sh) trivial. Add a .gitignore that excludes local .env files and any generated output so a stray secret never lands in Git.
Generate a First Bash Script with Copilot
Start with a task Copilot handles well. In a new scripts/disk_check.sh, describe the goal in a comment or ask in Copilot Chat:
“Write a Bash script that checks root filesystem utilization and exits 2 if usage is above 90 percent.”
A first draft typically looks like this:
#!/bin/bash
usage=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ $usage -gt 90 ]; then
echo "Disk almost full: $usage%"
exit 2
fi
echo "Disk OK: $usage%"
This runs, and it is a good example of why “it runs” is not the finish line. Read it critically:
- No strict mode. If
dffails or the pipeline breaks, the script sails on with an emptyusage. - Unquoted
$usagein[ $usage -gt 90 ]— ifusageis empty, the test becomes[ -gt 90 ], a syntax error, or worse, silently wrong. #!/bin/bashis less portable than#!/usr/bin/env bash, which finds Bash wherever it lives.- The threshold is hardcoded, so reusing the script for a different limit means editing it.
Ask Copilot to fix the specific problems: “Add strict mode, a shebang using env, quote variables, make the threshold a variable, and explain the exit codes.” A stronger version:
#!/usr/bin/env bash
set -euo pipefail
# Exit codes: 0 healthy, 2 over threshold
THRESHOLD="${THRESHOLD:-90}"
usage="$(df --output=pcent / | tail -1 | tr -dc '0-9')"
if (( usage > THRESHOLD )); then
printf 'Disk over threshold: %s%% (limit %s%%)\n' \
"$usage" "$THRESHOLD"
exit 2
fi
printf 'Disk OK: %s%% (limit %s%%)\n' "$usage" "$THRESHOLD"
What changed and why it matters:
#!/usr/bin/env bashlocates the interpreter portably.set -euo pipefailmakes a faileddfor a broken pipe stop the script instead of continuing with garbage.THRESHOLD="${THRESHOLD:-90}"takes the limit from the environment with a sane default, so the same script covers different hosts.df --output=pcentasksdffor exactly the percentage column, andtr -dc '0-9'keeps only digits — more robust than positionalawkagainst layout differences.(( usage > THRESHOLD ))is arithmetic comparison, andprintf(notecho) formats output predictably.
Read the mechanics once and they recur everywhere: a shebang picks the interpreter; variables hold values and expand where referenced; quoting protects those expansions; conditionals branch on tests; exit codes report the outcome to whatever called the script; printf produces clean output.
✅ Best Practice — Ask Copilot for the naive version to see the shape, then explicitly ask it to harden the specific weaknesses — strict mode, quoting, a configurable threshold, defined exit codes. Finish by running ShellCheck and executing the script against a safe target. Generate, understand, ShellCheck, test — never generate and trust.
Bash Strict Mode
set -euo pipefail near the top of a script turns three classes of silent failure into loud ones. Each flag does one thing:
-e— exit immediately if a command returns a non-zero status that is not explicitly handled. A failedmkdirorcdstops the script instead of letting the next line run in the wrong place.-u— treat a reference to an unset variable as an error rather than an empty string. This alone prevents the classicrm -rf "$DIR"/disaster whenDIRwas never set.-o pipefail— make a pipeline return the status of the first failing stage, not just the last. Without it,curl ... | jq ...reports success even whencurlfailed, becausejqexited 0.
Together they are the single highest-value habit in Bash automation. But strict mode is a seatbelt, not a force field.
❗ Important —
set -ehas surprising exceptions: it does not trigger for commands in anifcondition, in&&/||chains, or (in some cases) inside command substitutions and subshells. It also does nothing to stop a well-formed but destructive command —rm -rf "$VALID_PATH"runs perfectly under strict mode. Strict mode makes scripts fail faster on errors; it does not make them safe. Reading every command still matters more than any flag.
Variable Quoting
The most common bug in AI-generated Bash — and hand-written Bash — is an unquoted variable. When you write $var unquoted, the shell performs word splitting (breaking the value on whitespace) and glob expansion (expanding *, ?, [...] against the filesystem) on the result. Quoting with "$var" disables both.
Consider a variable that holds a path with a space:
file="my report.txt"
rm $file # runs: rm my report.txt (two args!)
rm "$file" # runs: rm "my report.txt" (one arg)
Unquoted, rm $file tries to remove two files, my and report.txt, neither of which exists — and if a file named my did exist, it would be deleted. Quoted, it removes the one file you meant.
Globbing is the more dangerous case:
pattern="*"
echo $pattern # prints every filename in the directory
echo "$pattern" # prints a literal *
And the empty-variable trap:
target="" # forgot to set it
rm -rf "$target"/logs # rm -rf /logs — catastrophic
The rules are simple and worth making reflexive:
- Always quote expansions:
"$var","${array[@]}","$(command)". - Use
"${var:?message}"to fail loudly when a required variable is empty. - Reserve unquoted
$varfor the rare, deliberate case where you want splitting or globbing.
ShellCheck flags unquoted variables automatically — which is exactly why it belongs on every script Copilot writes.
Functions and Arguments
When the same logic appears more than once — logging, validating input, retrying, cleaning up — refactor it into a function. Ask Copilot to “extract the repeated log lines into logging functions,” and it will produce something like:
log_info() { printf '[INFO] %s\n' "$*"; }
log_warn() { printf '[WARN] %s\n' "$*" >&2; }
log_error() { printf '[ERROR] %s\n' "$*" >&2; }
require_cmd() {
# Fail early if a needed tool is missing
command -v "$1" >/dev/null 2>&1 || {
log_error "required command not found: $1"
exit 3
}
}
log_info prints to stdout; log_warn and log_error print to stderr (>&2) so real output stays separable from diagnostics. require_cmd guards against the “works on my machine” failure where jq or dig is not installed. Shared helpers like these live well in scripts/lib/log.sh and get pulled in with source.
For scripts that take options, use getopts rather than hand-parsing $1, $2. A deployment wrapper that accepts an environment and a version:
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: deploy.sh -e <env> -v <version> [-h]
-e target environment (staging|prod)
-v version to deploy (e.g. 1.4.2)
-h show this help
EOF
}
environment=""
version=""
while getopts ":e:v:h" opt; do
case "$opt" in
e) environment="$OPTARG" ;;
v) version="$OPTARG" ;;
h) usage; exit 0 ;;
:) echo "Missing value for -$OPTARG" >&2; usage; exit 1 ;;
\?) echo "Unknown option -$OPTARG" >&2; usage; exit 1 ;;
esac
done
# Validate rather than trusting input
if [[ -z "$environment" || -z "$version" ]]; then
echo "Both -e and -v are required" >&2
usage
exit 1
fi
case "$environment" in
staging|prod) ;;
*) echo "Invalid environment: $environment" >&2; exit 1 ;;
esac
log_info() { printf '[INFO] %s\n' "$*"; }
log_info "Deploying version $version to $environment"
Invoked as ./deploy.sh -e staging -v 1.4.2, it parses options, prints --help on -h, and — crucially — validates rather than trusting input: both flags are required, and environment must be staging or prod. That validation is what stops a typo from deploying to the wrong place.
✅ Best Practice — Never hardcode an environment name or a target host inside a script’s logic. Take it as a validated argument or an environment variable with an allow-list check, as above. A script that always deploys to
prodbecause the value was baked in is one careless run away from an incident. Ask Copilot to add argument validation, then confirm the allow-list matches your real environments.
Exit Codes
Every command returns an exit code: 0 means success, any non-zero value means failure. The shell exposes the last one as $?. Automation depends on this — a CI step, a Makefile, or a monitoring wrapper decides what to do next based on whether your script exited 0.
./scripts/disk_check.sh
status=$?
if (( status != 0 )); then
echo "disk check failed with code $status"
fi
The important discipline is to define what each non-zero code means inside your script, because beyond 0-is-success there is no universal standard for what 1 or 2 signify:
# Exit codes for this script:
# 0 healthy
# 1 warning (approaching threshold)
# 2 over threshold (action needed)
# 3 usage/precondition error
Document the meanings in a comment, use them consistently, and let callers branch on them. In CI, a non-zero exit fails the job — which is exactly how a ShellCheck step or a health check gates a pipeline. Do not reuse 1 for six unrelated failures if a caller needs to tell them apart; give distinct conditions distinct codes and write them down.
File Operations Safely
File automation is where AI-generated Bash does the most damage, because the dangerous commands look exactly like the safe ones until they run. The rule for anything that deletes or overwrites: dry-run first, delete second.
Finding and reporting is always safe:
# Largest files under a path (read-only)
find /var/log -type f -printf '%s %p\n' \
| sort -rn | head -20
# Files older than 30 days — PRINT them, don't delete yet
find /var/log -type f -mtime +30 -print
# Disk usage of top-level directories
du -h --max-depth=1 /var/log | sort -rh
Only after you have read the -print output and confirmed it lists what you expect should you consider deletion — and even then, prefer archiving to removing:
# Archive old logs instead of deleting them
archive_dir="$(mktemp -d)"
find /var/log -type f -mtime +30 -print0 \
| xargs -0 -I{} mv {} "$archive_dir/"
⚠️ Warning —
rm,rm -rf, andfind ... -deleteare irreversible and have no undo. Copilot will readily suggestfind /path -mtime +30 -deletewhen you ask it to “clean up old files.” Always run the-delete. An empty or wrong variable in the path —find "$DIR" -deletewithDIRunset — can traverse far more than you intended. There is no recovering deleted files from a script that ran too fast.
Log Analysis with grep, awk, sed
Three classic tools cover most log work, and each has a distinct job:
grep— match lines by pattern.awk— split lines into fields and compute over them.sed— transform a stream, typically substitutions.
Copilot is excellent at drafting these from a description, and just as useful at explaining a dense one-liner you inherited. Work against the sanitized samples/access.log so no real data is at risk.
Top 10 client IPs for HTTP 500 responses. Ask Copilot: “From this Apache-style access log, show the 10 IPs with the most 500 responses.”
# Field 1 = IP, field 9 = status code
awk '$9 == 500 { print $1 }' samples/access.log \
| sort | uniq -c | sort -rn | head -10
awk selects lines whose status field is 500 and prints the IP; sort | uniq -c counts occurrences; sort -rn | head ranks the top 10. Read it as a pipeline of small steps rather than one incantation.
Count ERROR lines by hour. Useful for spotting a spike:
grep ' ERROR ' samples/app.log \
| awk '{ print substr($2, 1, 2) }' \
| sort | uniq -c
grep keeps only error lines; awk extracts the hour from the timestamp field with substr; uniq -c tallies per hour. Adjust the field and offset to match your log format.
Failed SSH logins in the last day. On a host with an auth log:
grep 'Failed password' /var/log/auth.log \
| awk '{ print $(NF-3) }' \
| sort | uniq -c | sort -rn
This counts source addresses of failed SSH attempts. sed earns its place when you need to reshape lines — for example, normalizing a timestamp before counting:
# Strip milliseconds from ISO timestamps
sed -E 's/(T[0-9:]+)\.[0-9]+/\1/' samples/app.log
🔍 Troubleshooting — When Copilot hands you a dense
awkorsedone-liner, ask it to explain each part before running it, then test on a small sample withhead. Field numbers ($9,$(NF-3)) depend entirely on your log’s exact format, and Copilot is guessing at that format. A pipeline that “works” on the wrong field silently reports the wrong thing. Never pipe log analysis into a delete or a remediation step until you have eyeballed its output.
Copilot with jq
Modern infrastructure speaks JSON — cloud CLIs, kubectl -o json, docker inspect, and REST APIs all return it — and jq is the standard tool for querying and reshaping that JSON from the shell. Its filter language is compact and easy to forget, which makes it a sweet spot for Copilot. Work against samples/services.json:
{
"services": [
{ "name": "api", "status": "running", "restarts": 0 },
{ "name": "worker", "status": "failed", "restarts": 7 },
{ "name": "cache", "status": "running", "restarts": 1 },
{ "name": "billing", "status": "failed", "restarts": 3 }
]
}
Select failed services. Ask Copilot: “With jq, list the services whose status is failed.”
jq '.services[] | select(.status == "failed")' \
samples/services.json
.services[] iterates the array; select(...) keeps only matching elements. To get just the names as plain strings:
jq -r '.services[] | select(.status == "failed") | .name' \
samples/services.json
-r produces raw output (no quotes), so the result is a clean list you can loop over. Example output:
worker
billing
Transform to CSV for a report or a spreadsheet:
jq -r '.services[]
| [.name, .status, (.restarts|tostring)]
| @csv' samples/services.json
@csv formats an array as a CSV row; tostring coerces the numeric field so it joins cleanly. Summarize — for instance, the total restart count across failed services:
jq '[.services[]
| select(.status=="failed")
| .restarts] | add' samples/services.json
This filters to failed services, projects their restarts, collects them into an array, and adds them. The same patterns — filter arrays, select fields, transform shapes, aggregate — cover most of what you need from API output.
🛠️ DevOps Tip — When an API or CLI returns JSON you do not fully understand, paste a sample into Copilot Chat and ask it to “write a jq filter that extracts X and explain each step.” Then run the filter with
-ron real output and confirm the fields exist and are named as Copilot assumed. jq fails loudly on a bad path, which is a feature — but a filter that returns nothing usually means the structure differs from what Copilot guessed, not that the data is empty.
curl, APIs, and Auth Safety
Shell scripts call HTTP APIs constantly — health checks, status polls, webhook triggers. Raw curl is too forgiving by default: it returns success on an HTTP 500 and hangs forever on a dead endpoint. The robust pattern:
curl --fail --silent --show-error --max-time 10 "$API_URL"
Each option earns its place:
--fail— return a non-zero exit code on HTTP errors (4xx/5xx) instead of printing the error body and exiting0. This is what makesset -eand pipelines actually catch a failed request.--silent— suppress the progress meter so output is clean and parseable.--show-error— still print the error message when--silentis on, so failures are not invisible.--max-time 10— cap the whole request at 10 seconds so a hung endpoint cannot stall the script forever.
A complete health check with a retry (more on the retry helper later) looks like:
check_endpoint() {
local url="$1"
if curl --fail --silent --show-error \
--max-time 10 "$url" >/dev/null; then
log_info "healthy: $url"
return 0
fi
log_error "unhealthy: $url"
return 1
}
Authentication is where scripts leak secrets. Pass tokens through environment variables, never as literals in the file:
# Token comes from the environment / a secret store,
# never hardcoded and never printed.
curl --fail --silent --show-error --max-time 10 \
-H "Authorization: Bearer ${API_TOKEN:?API_TOKEN not set}" \
"$API_URL"
${API_TOKEN:?...} fails immediately if the token is not set, and the value never appears in the script text.
❗ Important — Environment variables are safer than hardcoded literals but are not invisible. They can leak through shell history (a command typed with the token inline), through
/proc/<pid>/environto processes on the host, throughsetorenvoutput, and into logs. Neverecho "$API_TOKEN"to debug, never pass a secret as a command-line argument (it shows inps), and never commit a.envthat contains one. For production, source tokens from a real secret manager or your CI provider’s secret store, keep local.envfiles in.gitignore, and use placeholders in every file you commit.
Copilot for systemd and Networking
Bash is the front end to Linux service and network management, and Copilot knows the current tools. Keep these diagnostics read-only until you understand what is wrong.
Services with systemd:
# Status of one service (read-only)
systemctl status docker --no-pager
# Everything that failed to start
systemctl --failed --no-pager
# Recent logs for a unit, no follow
journalctl -u docker --since '1 hour ago' --no-pager
systemctl status and systemctl --failed show state; journalctl -u <unit> shows a unit’s logs. --no-pager keeps output flowing in a script rather than opening a pager.
⚠️ Warning — Do not let a script auto-restart a critical service the moment a check fails. Copilot will cheerfully suggest
systemctl restart <service>as a “fix.” A blind restart can mask a real fault, interrupt in-flight work, or trigger a cascade. Diagnose first —status,--failed,journalctl— decide deliberately, and gate any restart behind a human or an explicit, reviewed policy. Restarting is an action with consequences, not a health check.
Networking:
# Which process owns TCP port 8080?
ss -tulpn | grep ':8080'
# Interface addresses and default route
ip -brief address
ip route show default
# DNS resolution (getent is usually preinstalled)
getent hosts example.com
# dig gives more detail but may need installing
dig +short example.com
# Basic reachability
ping -c 3 example.com
ss -tulpn lists listening TCP/UDP sockets with the owning process — the direct answer to “which process owns port 8080.” ip address and ip route replace the older ifconfig/route. For DNS, getent hosts uses the system resolver and is almost always present; dig is richer but ships in a package (dnsutils/bind-utils) that may need installing.
❗ Important — These tools vary across distributions: package names differ (
dnsutilsvsbind-utils), some commands are not installed by default, and paths and defaults change. A script that runs on Ubuntu may fail on RHEL or Alpine. Guard external tools withcommand -v, prefer widely available commands (getentoverdig,ssovernetstat), and note the target distro. Ask Copilot which tools are portable, then verify on your actual systems.
Practical Scripts: Disk, Services, and Health
Combine the pieces into scripts you would actually run. Ask Copilot for a naive version, then improve it — the pattern from the disk check scales up.
Disk monitoring across all real filesystems. A stronger version of the opening example checks every mounted filesystem, excludes pseudo-filesystems, takes a configurable threshold, prints clearly, and exits non-zero if anything is over:
#!/usr/bin/env bash
set -euo pipefail
# Exit codes: 0 all OK, 2 one or more filesystems over threshold
THRESHOLD="${THRESHOLD:-90}"
over=0
# -x excludes pseudo/virtual filesystems from the report
while read -r pct mount; do
pct="${pct%\%}"
if (( pct > THRESHOLD )); then
printf 'OVER %3s%% %s\n' "$pct" "$mount"
over=1
else
printf 'ok %3s%% %s\n' "$pct" "$mount"
fi
done < <(df -x tmpfs -x devtmpfs -x squashfs \
--output=pcent,target | tail -n +2)
if (( over != 0 )); then
printf 'One or more filesystems over %s%%\n' "$THRESHOLD" >&2
exit 2
fi
df -x tmpfs -x devtmpfs -x squashfs excludes virtual filesystems so you only see real storage; --output=pcent,target asks for exactly the percentage and mount point; the while read loop compares each against the configurable THRESHOLD and sets over if any exceeds it. The process-substitution form < <(...) keeps the loop in the main shell so over survives.
Service health across a configurable list. Check a set of systemd services and fail if any is not active:
#!/usr/bin/env bash
set -euo pipefail
# Space-separated list, overridable via env
read -ra services <<< "${SERVICES:-docker ssh cron}"
unhealthy=0
for svc in "${services[@]}"; do
if systemctl is-active --quiet "$svc"; then
printf 'ok %s\n' "$svc"
else
printf 'INACTIVE %s\n' "$svc" >&2
unhealthy=1
fi
done
(( unhealthy == 0 )) || exit 2
systemctl is-active --quiet returns success only when a unit is active, so the loop needs no output parsing. The list defaults to docker ssh cron but is overridable, so the same script serves different hosts.
❗ Important — Service names are not portable.
sshissshdon some distributions;croniscrondelsewhere; a service may not exist at all on a given host. Make the list configurable (as above), verify the exact unit names on your target systems withsystemctl list-units, and have Copilot note where names differ across distributions rather than assuming one set.
Bash for Docker, Kubernetes, and Terraform
Bash shines as glue around other CLIs. Keep AI-generated automation read-only by default — inspecting and reporting is safe; deleting and applying is not.
Docker (read-only diagnostics):
# Container status in a compact table
docker ps --format 'table {{.Names}}\t{{.Status}}'
# Tail logs from one container (no follow in a script)
docker logs --tail 100 "$container"
# Names of unhealthy containers
docker ps --filter health=unhealthy \
--format '{{.Names}}'
Kubernetes (read-only diagnostics):
# Pods that are not Running, across all namespaces
kubectl get pods -A \
--field-selector=status.phase!=Running
# Pods sorted by restart count (needs jq)
kubectl get pods -A -o json \
| jq -r '.items[]
| [.metadata.namespace, .metadata.name,
(.status.containerStatuses[0].restartCount
// 0 | tostring)]
| @tsv' \
| sort -k3 -rn | head
# Quick namespace health: non-running pod count
kubectl get pods -n "$namespace" \
--field-selector=status.phase!=Running \
--no-headers | wc -l
These list problem Pods and rank restart counts without changing anything. Part 7, GitHub Copilot with Kubernetes, goes deep on the manifests and the diagnostic ladder behind these commands; the Kubernetes and Helm guides go broader.
Terraform (a validation wrapper):
#!/usr/bin/env bash
set -euo pipefail
workdir="${1:?usage: tf_check.sh <dir>}"
cd "$workdir"
# Format check, then validate, then a plan — no apply
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
terraform plan -input=false
This wrapper checks formatting, validates syntax, and produces a plan — all read-only — and stops there. It takes the working directory as a required argument (no hardcoded path) and relies on strict mode so any failing step aborts the chain. Notably it never runs apply or destroy.
⚠️ Warning — Never let a script run broad automated deletion or a blind production
apply.docker system prune -af,kubectl delete -fagainst the wrong context, andterraform apply -auto-approvein CI are exactly the commands Copilot suggests to “automate” a task — and exactly the ones that cause incidents. Keep generated automation to inspection and validation; put a human approval gate in front of anything that mutates infrastructure.
Reliability: Retries, Traps, and Temp Files
Robust scripts handle transient failures and clean up after themselves. Copilot drafts these patterns well once you ask for them specifically.
A reusable retry function for flaky network calls:
retry() {
# retry <max_attempts> <delay_seconds> <command...>
local max="$1" delay="$2"; shift 2
local attempt=1
until "$@"; do
if (( attempt >= max )); then
log_error "failed after $attempt attempts: $*"
return 1
fi
log_warn "attempt $attempt failed; retry in ${delay}s"
sleep "$delay"
(( attempt++ ))
delay=$(( delay * 2 )) # exponential backoff
done
}
# Usage: up to 5 attempts, starting at 2s, backing off
retry 5 2 curl --fail --silent --show-error \
--max-time 10 "$API_URL"
It attempts the command, and on failure waits and doubles the delay (exponential backoff) up to a maximum number of attempts. The key discipline: retry idempotent, read-only operations — a health check, a GET, a status poll. Never wrap a destructive or non-idempotent command (a POST that charges money, a delete, a deploy) in a blind retry; retrying it could do the damage twice.
Guaranteed cleanup with trap and mktemp:
#!/usr/bin/env bash
set -euo pipefail
# mktemp generates a safe, unpredictable name
tmp_file="$(mktemp)"
# Remove it however the script exits — success or error
trap 'rm -f "$tmp_file"' EXIT
curl --fail --silent --show-error --max-time 10 \
"$API_URL" > "$tmp_file"
jq '.status' "$tmp_file"
mktemp creates a temporary file with a random name; trap '... EXIT runs the cleanup on any exit path, so the temp file never lingers.
❗ Important — Predictable temp names are a real hazard. Writing to
/tmp/mydatainvites collisions between concurrent runs and symlink attacks where an attacker pre-creates the path. Always usemktemp(ormktemp -dfor a directory), quote the result, and remove it with atrap ... EXIT. Do not hardcode temp paths, even for “throwaway” data.
Finally, have Copilot harden a naive command. Given a fragile one-liner like curl "$URL"; docker restart "$CONTAINER", ask it to “add validation, a timeout, error handling, and logging.” A safer result checks the endpoint before acting, times out, logs, and does not restart blindly:
if retry 3 2 curl --fail --silent --show-error \
--max-time 10 "$URL" >/dev/null; then
log_info "endpoint healthy: $URL"
else
log_error "endpoint failed; not restarting automatically"
exit 1
fi
ShellCheck and Testing
ShellCheck is the deterministic backbone of this whole lesson. It is a static analyzer that reads a shell script and reports the bugs humans and AI both miss:
- Unquoted variables and word-splitting/globbing hazards.
- Misused arrays and
"$@"vs"$*"confusion. - Command substitution mistakes and unnecessary subshells.
- Unused variables and variables used before assignment.
- Broken test expressions (
[ ]vs[[ ]], string vs numeric comparison).
Run it on everything Copilot writes:
# Lint every script in the repo
shellcheck scripts/*.sh
# Syntax-only check (no logic analysis)
bash -n scripts/disk_check.sh
shellcheck performs deep static analysis; bash -n only parses for syntax errors. Optionally, shfmt formats shell scripts consistently (shfmt -d scripts/ shows diffs), which keeps diffs clean and reviews focused on logic.
Testing shell scripts is easier than it looks if you isolate side effects:
- Run against a temp directory created with
mktemp -d, never a real path. - Use a disposable environment — a container or a throwaway VM — for anything that touches services or the network.
- Mock dangerous commands by shadowing them with a function in the test (for example, redefining
rmto log instead of delete). - For structured tests,
bats(the Bash Automated Testing System) gives you a proper test-file format.
The workflow is the same discipline as the rest of the academy:
Copilot writes
|
ShellCheck
|
Engineer reads
|
test (safe env)
|
approve
✅ Best Practice — Make ShellCheck a required step, not an optional one. Run it locally before committing and enforce it in CI so no script merges with an unaddressed warning. When ShellCheck flags a line Copilot wrote, read the specific rule (each has a wiki page) rather than silencing it — the warning is usually pointing at a real quoting or expansion bug that would bite in production.
Never Blindly Run AI-Generated Bash
This is the section to internalize. Copilot writes confident, plausible shell — and confident, plausible shell is exactly what causes outages when it is wrong. Some commands are irreversible, and the shell will not stop you.
The high-risk commands to slow down and scrutinize whenever they appear in generated code:
rm -rf— recursive, irreversible deletion, catastrophic with an empty or wrong variable.chmod -R/chown -R— recursive permission and ownership changes that can break a system or open it up.dd— writes raw blocks; a wrongof=overwrites a disk.mkfs— formats a filesystem, destroying its contents.curl ... | bash/wget ... | sh— executes a remote script sight-unseen with your privileges.iptables/ firewall changes — can lock you out of a host instantly.- Package removal —
apt remove,yum removecan pull critical dependencies. - Process killing —
kill -9,pkillagainst the wrong pattern. - Filesystem overwrite —
>onto an important file,mvover an existing path. - Docker volume deletion —
docker volume rm,docker system prune -af. - Kubernetes deletion —
kubectl deleteagainst the wrong context or namespace. terraform destroy— tears down real infrastructure.
A second, subtler risk is shell injection. Never build a command from untrusted input and hand it to eval:
# NEVER do this — arbitrary command execution
eval "$USER_INPUT"
If USER_INPUT contains ; rm -rf ~, eval runs it. Avoid eval almost entirely; pass data through variables and arrays, not by constructing command strings. The same care applies to unquoted expansions inside commands built from external data.
Before running anything Copilot produced, run it through a checklist:
Safe Command Review Checklist
- What files does it touch? Are the paths correct, absolute where needed, and never derived from a possibly-empty variable?
- Does it use
sudo? Why, and is the elevated scope truly necessary? - Does it delete or overwrite? Is there a dry-run I ran first?
- Does it recurse?
-R,-rf, andfindtraversals multiply mistakes. - Does it fetch and run a remote script? Read the script before piping to a shell.
- Does it change firewall or network state? Could it lock me out?
- Does it use unsafe expansion or
eval? Are all variables quoted? - Is a dry-run available? Use it.
- Is there a rollback? If not, treat the command as one-way and test elsewhere first.
⚠️ Warning — The single most dangerous habit is piping an AI-suggested command straight into a privileged shell:
curl https://example.com/install.sh | sudo bash. You are executing code you have not read, as root. Download it, read it, ShellCheck it, and only then decide. This applies equally to commands Copilot suggests in the terminal — the agenticcopilotCLI is approval-gated for exactly this reason, so read each command before you approve it.
Copilot for Bash Refactoring
Copilot is genuinely strong at improving existing shell. Point it at a working-but-rough script and ask for targeted refactors:
- “Remove the duplicated logging lines and extract logging functions.” Consolidates repeated
echocalls intolog_info/log_warn/log_error. - “Add argument validation with getopts and a —help usage.” Replaces positional
$1/$2guessing with parsed, validated options. - “Add a cleanup trap so temp files are removed on any exit.” Wires
trap 'rm -f "$tmp"' EXITaroundmktemp. - “Use mktemp instead of a hardcoded /tmp path.” Removes predictable-temp-name risk.
- “Add retries with backoff around the network request.” Wraps a flaky
curlin the retry helper. - “Add structured logging with levels and timestamps.” Standardizes output for machine parsing.
Each is a small, reviewable change — apply them one at a time, run ShellCheck after each, and confirm behavior is unchanged.
There is a limit, though. When a refactor keeps running into Bash’s weaknesses — you are parsing nested JSON by hand, juggling associative arrays, building real error-handling flows, or the script has grown past a page of branching — the right refactor is not better Bash but a rewrite in Python. Ask Copilot: “Is this getting too complex for Bash?” and weigh the answer. Bash orchestrates; Python handles complexity and data. That handoff is Part 9’s subject.
GitHub Copilot + Bash + GitHub Actions
Scripts that matter should be linted and tested in CI, the same way every other change is. Ask Copilot to “write a GitHub Actions workflow that runs ShellCheck on all scripts, optionally checks formatting with shfmt, and runs the test suite, with least-privilege permissions.” The verified building blocks:
name: shellcheck
on: [push, pull_request]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run ShellCheck
# ShellCheck is preinstalled on ubuntu-latest
run: shellcheck scripts/*.sh scripts/lib/*.sh
- name: Check formatting (optional)
run: |
if command -v shfmt >/dev/null; then
shfmt -d scripts/
else
echo "shfmt not installed; skipping"
fi
- name: Run tests
run: bash tests/test_disk_check.sh
What to verify in any Copilot-generated Bash workflow:
- ShellCheck runs directly. GitHub-hosted
ubuntu-latestrunners ship with ShellCheck preinstalled, sorun: shellcheck ...works with no third-party action or install step. Reject a workflow that adds an unnecessary marketplace action for it. actions/checkout@v4is the current major version — confirm it against the action’s repository and reject invented or unversioneduses:.- Least-privilege
permissions:. A lint-and-test job only needscontents: read. Copilot often omits the block entirely; add it, and grant nothing more than the job requires. - The job actually fails on findings.
shellcheckexits non-zero on warnings, which fails the step — a lint job that always passes is theater.
❗ Important — Do not give a Bash CI job write permissions or secrets it does not need, and never let a workflow triggered by an untrusted pull request run scripts with elevated access. A script step in CI executes with whatever the job is granted; keep that surface minimal, and gate anything that deploys behind trusted events and human review.
30 GitHub Copilot Prompts for Bash and Linux Engineers
Reusable starting prompts. Each produces a draft to read, ShellCheck, and test before you run it.
Script structure and safety
- “Write a Bash script with strict mode, a usage function, and getopts argument parsing.”
- “Add
set -euo pipefailand explain what each flag protects against here.” - “Quote every variable in this script and fix any word-splitting bugs.”
- “Define clear exit codes for this script and document them in a comment.”
- “Extract the repeated logic into functions with structured logging.”
- “Add argument validation with an allow-list for the environment name.”
Files and cleanup
- “List files older than 30 days under this path without deleting anything.”
- “Rewrite this cleanup to dry-run first, then delete only after confirmation.”
- “Use mktemp and a trap to clean up temp files on any exit.”
- “Archive old logs to a compressed tarball instead of deleting them.”
- “Report the 20 largest files under a directory, read-only.”
Text, logs, and JSON
- “From this access log, show the top 10 IPs returning HTTP 500.”
- “Count ERROR lines per hour in this application log.”
- “Explain what each part of this awk one-liner does.”
- “Write a jq filter that lists services whose status is failed.”
- “Convert this JSON array to CSV with jq and explain the filter.”
- “Summarize total restarts across failed services with jq.”
APIs and networking
- “Write a curl health check with —fail, —max-time, and error handling.”
- “Add a retry with exponential backoff around this network request.”
- “Read an API token from an environment variable, never hardcoded or echoed.”
- “Which process owns TCP port 8080? Give me a portable command.”
- “Check DNS resolution for a host using a preinstalled tool.”
System and infrastructure
- “Check all real filesystems against a configurable disk threshold.”
- “Verify a configurable list of systemd services is active.”
- “Show recent journalctl logs for a unit without following.”
- “List Kubernetes pods that are not Running across all namespaces.”
- “List Docker containers marked unhealthy.”
- “Write a terraform fmt-check, validate, and plan wrapper with no apply.”
Quality and CI
- “Fix every ShellCheck warning in this script and explain each fix.”
- “Write a GitHub Actions workflow that runs ShellCheck with least-privilege permissions.”
Lab: Build a Production-Style Linux Health Check with GitHub Copilot
Put the whole lesson together by building a real host health-check script with Copilot as your assistant — the point is the cycle, not the exact output. Work each step through the same loop: Copilot proposes → you read → ShellCheck and a test run validate → you approve.
- Describe the requirement — ask Copilot for a script that checks disk usage, a list of systemd services, and an HTTP endpoint, and exits non-zero if any check fails.
- Draft — let Copilot produce the first version. Do not run it yet.
- Inspect — read every line. Note missing strict mode, unquoted variables, hardcoded values, and any destructive or auto-remediating commands.
- Strict mode — add
#!/usr/bin/env bashandset -euo pipefail. - Quote variables — quote every expansion; add
"${var:?}"guards for required inputs. - Functions — extract
log_info/log_warn/log_errorand arequire_cmdguard. - CLI arguments — add
getoptsfor the threshold, the service list, and the endpoint URL, with--help. - Thresholds — make the disk threshold and service list configurable, with sensible defaults and validation.
- Exit codes — define and document them (0 healthy, 1 warning, 2 failure, 3 usage error).
- Retry — wrap the endpoint check in the retry-with-backoff helper (idempotent GET only).
- Cleanup — use
mktempfor any temp output and atrap ... EXITto remove it. - ShellCheck — run
shellcheckand fix every finding; read each rule rather than silencing it. - Test failure cases — in a disposable environment, force each failure (fill a temp filesystem, stop a service, point at a dead URL) and confirm the exit codes.
- Document — have Copilot draft a README section covering usage, options, exit codes, and portability caveats; verify it against the script.
- Actions CI — add the
shellcheck.ymlworkflow so the script is linted and tested on every push and PR, withpermissions: { contents: read }.
The end-to-end shape you have practiced:
Natural Language
|
Copilot
|
Bash
|
ShellCheck
|
Test Cases
|
PR
|
Human Review <-- required
Commit each piece only after you have read it, run ShellCheck, and tested it against safe targets. By the end you will have used Copilot to write, refactor, harden, and ship a real Linux automation script — while keeping ShellCheck and a test run as the source of truth.
🛠️ DevOps Tip — Add a repo-level custom instructions file so Copilot defaults to your shell conventions — strict mode, quoted variables,
getoptsparsing,mktemptemp files, no destructive defaults, ShellCheck-clean — across the whole project. Verify the current custom-instructions mechanism in the VS Code docs, since it evolves. The Linux administration guides and the Ubuntu AI Infrastructure series go deeper on the systems these scripts run against.
What’s Next
You now have Copilot working across Linux automation: writing and hardening scripts, strict mode and quoting, functions and getopts, defined exit codes, safe file operations, grep/awk/sed and jq, the curl --fail pattern with secret-safe auth, systemd and networking diagnostics, Docker/Kubernetes/Terraform glue, retries and traps, ShellCheck and testing, and a CI workflow — all under the same discipline that an AI-generated script which runs once is not automatically safe, portable, or correct.
The next lesson, Part 9: GitHub Copilot for Python (coming soon), picks up exactly where Bash runs out of room. The bridge is worth holding onto: Bash is for orchestration and system automation — chaining CLIs, simple system tasks, CI glue — while Python takes over as complexity and data handling increase: nested data structures, heavy API logic, real error handling, and code a team maintains for years. Recognizing that handoff is itself an engineering skill.
To revisit the agentic terminal, return to Part 3, GitHub Copilot CLI; for the cluster side of these scripts, see Part 7, GitHub Copilot with Kubernetes. The GitHub AI Engineering Academy home has the full path, and the Bash and Python automation guides, the Linux administration guides, the Docker guides, the Kubernetes and Helm guides, the Docker Academy, and the Ubuntu AI Infrastructure series all go deeper on the systems these scripts automate.
Recommended GitHub Books
GitHub Copilot Unleashed
A deeper dive into AI-assisted development with GitHub Copilot — prompting, workflows, and getting more from the tool.
- Copilot
- AI-assisted development
- Productivity
Learning GitHub Actions
A guide to automating build, test, and deploy with GitHub Actions — workflows, jobs, runners, and secrets.
- GitHub Actions
- CI/CD
- Automation
Affiliate Disclosure: Some links on this page are affiliate links. If you purchase through one of these links, DevOps AI Toolkit may earn a commission at no additional cost to you. See our affiliate disclosure.
Frequently asked questions
Can GitHub Copilot write Bash scripts?
Yes. Copilot drafts Bash quickly from a comment or a chat prompt — a health check, a log parser, a deployment wrapper. What it produces is a first draft, not a finished script: early drafts often skip strict mode, leave variables unquoted, assume a package is installed, and reach for destructive commands with no dry-run. Treat the draft as a starting point to read line by line, run through ShellCheck, and test in a disposable environment. A script that runs once on your machine is not automatically safe, portable, or correct — ShellCheck and a test run are the source of truth; Copilot accelerates the typing.
Is AI-generated Bash safe?
Not on its own — and Bash is unusually unforgiving. A single unquoted variable, a stray space, an empty variable in an rm path, or a misplaced glob can delete the wrong files or wipe a directory with no undo. Copilot writes plausible shell that often works on the happy path and fails dangerously at the edges. Read every command before you run it, run ShellCheck to catch quoting and expansion bugs, and test against throwaway data first. Never pipe an AI-generated command straight into a root shell.
Can Copilot explain Linux commands?
Yes, and this is one of its best uses. Paste an opaque one-liner — a dense awk program, a chain of pipes, a find with -exec — and ask Copilot what each part does and what it would touch. It is a fast way to understand inherited scripts and to decode commands before you run them. Verify the explanation against the man page for anything destructive or privileged; Copilot is a strong reading aid, not an authority on your exact system's flags and versions.
Can Copilot help with grep, awk, sed, and jq?
Yes. These tools have terse, easy-to-forget syntax, which is exactly where an AI pair programmer helps. Copilot can draft a grep pattern, an awk field extraction, a sed substitution, or a jq filter from a plain-English description, and — just as valuable — explain a cryptic one-liner you inherited. Always test the result on a sanitized sample of real data and confirm it selects what you expect. A filter that returns rows is not necessarily returning the right rows.
Can Copilot write system administration scripts?
It can draft them — disk checks, service health checks, log rotation, backup wrappers, user and network diagnostics — and it is a real time-saver for the boilerplate. But sysadmin scripts run with privilege and touch shared systems, so the review bar is higher, not lower. Watch for auto-restarting critical services without diagnostics, destructive cleanup with no dry-run, and commands that assume one distro's paths and package manager. Read, ShellCheck, and test on a non-production host before you trust anything Copilot writes here.
Should Bash scripts use set -euo pipefail?
For most automation scripts, yes — it turns silent failures into loud ones. `-e` exits on an unhandled error, `-u` treats an unset variable as an error instead of an empty string, and `-o pipefail` makes a pipeline fail if any stage fails, not just the last. It is a strong default, but not a safety guarantee: `-e` has surprising exceptions (commands in conditionals, some subshells) and does not stop a well-formed but destructive command. Use strict mode and still read every line.
Can GitHub Copilot troubleshoot Bash errors?
Yes — it is good at interpreting 'unbound variable', 'command not found', a bad substitution, or an exit code, and proposing a hypothesis. It is not a substitute for the actual signals. Reproduce the failure, read the error and the line it points to, run `bash -n` for syntax and ShellCheck for logic, and give Copilot that context. Verify its explanation against what the shell reports. Use it to narrow the search, not to pronounce the verdict, especially before rerunning anything that writes or deletes.
Can ShellCheck validate Copilot-generated scripts?
Yes, and you should run it on everything Copilot writes. ShellCheck is a static analyzer that catches the exact classes of bug AI-generated shell tends to introduce: unquoted variables, word-splitting and globbing hazards, misused command substitution, unhandled arrays, and broken test expressions. It runs locally (`shellcheck script.sh`) and in CI — and it is preinstalled on GitHub-hosted `ubuntu-latest` runners, so you can call it directly in a workflow with no third-party action. ShellCheck is deterministic; it is the source of truth Copilot's suggestions must pass.
When should I use Python instead of Bash?
Reach for Python when the job outgrows orchestrating commands: complex data structures, non-trivial JSON or CSV processing, heavy API logic, real error handling and retries, or code that a team must maintain for years. Bash is excellent glue for chaining CLIs, simple system tasks, and CI steps — but as branching, parsing, and state grow, a Bash script becomes fragile and hard to test. A good rule: if you are simulating data structures or writing more than a page of conditional logic in Bash, it is probably time for Python.
Does GitHub Copilot replace Linux knowledge?
No. Copilot lowers the syntax barrier — it remembers awk fields and getopts boilerplate so you do not have to — but it cannot tell you whether deleting those files is safe on your system, whether restarting that service will page someone, or whether a command behaves differently on your distro. Those judgments require understanding Linux: filesystems, permissions, processes, signals, and networking. Copilot makes a knowledgeable engineer faster and a careless one more dangerous. Use it to write more quickly, not to skip understanding what you run.
← Back to GitHub AI Engineering Academy