GitHub Copilot for Linux Administration
GitHub Copilot for Bash in Cluster 3 covers
the language: quoting, expansion, set -euo pipefail, and why a shell script has
no safety net. This lesson assumes that and covers the other half — the machine.
Services, logs, processes, sockets, filesystems, and the diagnostic sequences you
run at three in the morning.
The framing is different too. In Cluster 3 the question was how to write a correct script. Here it is how to use a model during an incident without letting it change anything, and the honest answer is that the most valuable thing Copilot does on a Linux host is not run commands at all.
This lesson has one result at its centre, and it is a failure rather than a
success. A read-only health-check script — linted clean by ShellCheck, running
under set -euo pipefail — silently checked nothing at all, and reported success
while doing it.
Key takeaways
- ShellCheck reported six findings on a draft that contained
rm -rf /tmp/*andsystemctl restartinside a “health check”. It flagged neither. set -euo pipefaildoes not catch a failure inside a process substitution. Adfinvocation that refused to run took an entire check with it, and the script still exited 0.- Never suppress stderr on a command you have not verified.
2>/dev/nullon a command that rejects its own arguments produces a silent no-op. - Diagnostic scripts report; they do not repair. The moment a health check can restart a service, it is a deployment script wearing a disguise.
- The safe prompt shape for troubleshooting: ask for non-destructive commands first, ask what each one establishes, and forbid a fix until the diagnostics come back.
Where Copilot genuinely helps on a host
Explaining a command. This is the highest-value and lowest-risk use, and it
is worth doing before running anything you did not write. A pipeline of awk,
sed, xargs and find with six flags is exactly the kind of thing a model
reads accurately and a human skims.
Explaining a unit file. systemd has a large vocabulary — Type=notify versus
Type=simple, Restart=on-failure versus always, After= versus Requires=
— and the difference between two of them is frequently the reason a service does
not come back after a reboot.
Reading logs. journalctl output is dense, structured and voluminous. Asking
which of several hundred lines indicate a distinct failure mode is a genuine
reading task.
Building the diagnostic sequence. Not the fix — the ordered list of non-destructive commands that narrows down where a problem is.
Writing the reporting script. Turning a manual sequence into something that runs on a schedule and emits machine-readable output.
Where it needs the most correction is the same everywhere in this cluster: the
default is to act. Ask why a service is down and a suggestion will offer
systemctl restart as step one, which destroys the evidence you needed.
The failure worth studying
The example below is a read-only health check. Its first working version passed
ShellCheck with no findings, ran under set -euo pipefail, and produced this:
host www
kernel 6.8.0-86-generic
uptime_seconds 145403
load_1min 0.27
cpu_count 8
memory_used_pct 4
listening_tcp_ports 8
systemd_failed_units 0
warnings 0Every check present except the disk ones. No error, no warning, exit code 0.
The cause was a single flag combination:
df -P -x tmpfs --output=pcent,target 2>/dev/null-P and --output are mutually exclusive in GNU coreutils. df printed a usage
error and exited non-zero. Three things then conspired:
2>/dev/nulldiscarded the message. The error existed and nobody saw it.- The call was inside a process substitution —
while read … done < <(df …)— which is a separate process whose exit status the shell does not inspect. Soset -ehad nothing to act on. - The
whileloop read zero lines, executed zero iterations, and returned successfully. A loop over nothing is a successful loop.
The result is the worst outcome a monitoring tool can produce: a green report from a check that did not run.
Practical project: a read-only health-check toolkit
Practical example
A host health check that reports and never repairs
Text, JSON and file output; a non-zero exit when anything warns; and nothing on the host modified outside the report directory.
- Status
- Tested implementation
- Runtime
- GNU bash 5.2.21, ShellCheck 0.11.0, Ubuntu 24.04.3 LTS, kernel 6.8.0-86
- Command
bash -n health-check.sh && shellcheck health-check.sh && ./health-check.sh --unit nginx && ./health-check.sh --json | jq -e .- Result
- bash -n parsed; ShellCheck clean, including with --enable=all minus three style rules; text run exited 0 with warnings 0; --disk-pct 3 exited 1 with two findings; --json produced valid JSON confirmed by jq -e. The draft script produced 6 ShellCheck findings.
- Run on
- August 21, 2026
Files
copilot-linux-demo/ ├── health-check.sh ├── draft/health-check-draft.sh the first suggestion, kept for comparison ├── README.md └── .github/ └── instructions/ └── shell.instructions.md
The prompt
Write a read-only Linux host health check in bash.
It must report and never repair. No rm, no systemctl start/stop/restart, no package operations, no writes outside a report directory the caller names.
Check: hostname, kernel, distribution, uptime, 1-minute load against CPU count, memory used percentage, disk and inode usage per real filesystem, count of listening TCP sockets, count of failed systemd units, and the state of any unit named with a —unit flag.
Support —json for machine-readable output including a findings array.
Exit 0 when everything passes and 1 when anything warns, so it can gate a pipeline step.
Use ss rather than netstat, journalctl rather than log files, and systemctl is-active rather than ps | grep.
The first paragraph is the important one. Without it you get a script that restarts things.
Reading the machine, not the tools
check_load() {
local one cpus
read -r one _ < /proc/loadavg
cpus=$(nproc)
printf 'load_1min %s\ncpu_count %s\n' "$one" "$cpus"
# The shell cannot compare the fractional value in /proc/loadavg, so awk does
# the comparison and its exit status drives the branch. Written as an `if`,
# not `awk ... && warn`: under `set -e` a trailing `&&` whose left side is
# false makes the enclosing function return non-zero and kills the script.
if awk -v l="$one" -v n="$cpus" 'BEGIN { exit !(l > n) }'; then
warn "1-minute load ${one} exceeds ${cpus} CPUs"
fi
}Reading /proc/loadavg and /proc/meminfo directly rather than parsing uptime
or free is deliberate: the proc files have a stable format, while the
human-facing tools change their output between versions and locales. A script
that parses free -m breaks when someone sets a different locale.
Load compared against CPU count, not a constant
A generated health check almost always hard-codes a load threshold. Load average
is a queue length, so the meaningful comparison is against the number of CPUs — a
load of 6 is idle on a 32-core machine and severe on a 2-core one. nproc costs
nothing and makes the check portable across the fleet.
Failed units, not individual services
failed=$(systemctl list-units --state=failed --no-legend --plain | wc -l)This is the check that catches problems nobody thought to look for. Asking whether nginx is running finds a known failure; asking systemd what it considers failed finds the timer that has been erroring for three weeks.
Observed output
$ shellcheck draft/health-check-draft.sh
6 findings: SC2086 (x3), SC2181, SC2153, SC2006
$ shellcheck health-check.sh
(no output, exit 0)
$ ./health-check.sh --unit nginx
host www
kernel 6.8.0-86-generic
os Ubuntu 24.04.3 LTS
uptime_seconds 145428
load_1min 0.24
cpu_count 8
memory_used_pct 4
disk_used_pct_ 5
disk_used_pct_boot 15
inodes_used_pct_ 1
inodes_used_pct_boot 2
listening_tcp_ports 8
systemd_failed_units 0
unit_nginx active
warnings 0
(exit 0)
$ ./health-check.sh --disk-pct 3
…
WARN filesystem / is 5% full
WARN filesystem /boot is 15% full
warnings 2
(exit 1)
$ ./health-check.sh --json | jq -c '{warnings, findings}'
{"warnings":0,"findings":[]}What the linter said about the draft
The draft contained this:
if [ $DISK -gt 90 ]; then
echo "Disk usage high: $DISK%"
# free up space
rm -rf /tmp/*
fi
for SERVICE in $SERVICES; do
systemctl status $SERVICE > /dev/null
if [ $? != 0 ]; then
systemctl restart $SERVICE
fi
doneShellCheck’s six findings were about quoting, backticks and checking $?
indirectly. It said nothing about a “health check” that deletes files and
restarts services — nor that $SERVICES is never assigned, so the loop silently
does nothing. (It did flag SC2153, suggesting SERVICES might be a misspelling
of SERVICE, which is the closest it came.)
Validation
Validate before deploying
Linux: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Parse | bash -n health-check.sh | PASSExit 0; no syntax errors |
| Lint | shellcheck health-check.sh | PASSNo findings on the final script, including with --enable=all; 6 findings on the first draft |
| Run read-only | ./health-check.sh --unit nginx | PASSExit 0 with 0 warnings; exit 1 and two findings when a threshold was lowered; --json output parsed by jq -e |
The commands that matter, and the ones to stop using
Generated Linux advice skews old, because the corpus spans thirty years. Four substitutions are worth making in every suggestion.
ss, not netstat. net-tools is not installed by default on current
distributions. ss -ltnp reads the kernel’s socket tables directly and is
faster.
journalctl, not files under /var/log. On a systemd host the journal is
the log. journalctl -u nginx --since "10 minutes ago" and
journalctl -p err -b are the two invocations worth memorising —
the second shows every error-or-worse since boot, which is a good opening move
during an incident.
systemctl is-active, not ps | grep. Grepping a process table finds the
grep, misses a service that is running under a different name, and says nothing
about whether systemd considers it healthy.
ip, not ifconfig. Same net-tools problem. ip -br addr and
ip route get 8.8.8.8 are the compact forms.
Two more worth knowing because they answer questions people reach for the wrong
tool for: lsof -i :443 or ss -ltnp sport = :443 to find what holds a port,
and systemd-analyze blame to find what is making boot slow.
systemd units
Copilot writes unit files competently and gets three things wrong often enough to be worth checking every time.
Type=. simple means systemd considers the service started as soon as the
process forks — so a dependent unit can start before the service is actually
ready. notify requires the service to signal readiness and is correct for
anything with dependents. exec, forking and oneshot each have a specific
meaning, and a generated unit almost always says simple regardless.
After= versus Requires=. After= is ordering only; Requires= is a
dependency. A unit that says After=postgresql.service without Requires= will
start happily when PostgreSQL is not running at all.
Restart=. on-failure restarts on a non-zero exit; always restarts even
after a clean stop, which fights you during maintenance. Neither is complete
without RestartSec= — the default is 100ms, which turns a crash into a tight
loop that fills the journal.
The hardening directives are worth asking for by name, because they are how a
unit gets most of the isolation a container would have given it:
NoNewPrivileges=yes, ProtectSystem=strict, ProtectHome=yes,
PrivateTmp=yes, and a ReadWritePaths= listing exactly what it may write.
systemd-analyze security <unit> scores a unit against these and is a genuinely
useful review command.
Troubleshooting with a model, safely
The prompt shape from the pillar, in the form worth copying:
An Ubuntu server is not listening on TCP 443.
Give me non-destructive diagnostic commands first, in the order you would run them, and explain what each one establishes and what result would rule it out.
Do not change firewall rules, packages, services or network configuration. Do not propose a fix until I tell you what the diagnostics returned.
Four clauses, each doing specific work. Non-destructive first stops
systemctl restart appearing as step one. In the order you would run them
produces a narrowing sequence rather than a list. What would rule it out turns
each command into a decision point. And no fix yet prevents a confident remedy
for a cause nobody has established.
The follow-up matters as much:
Here is the output of each command you suggested.
Based only on this output, which hypotheses are now ruled out and which remain? Quote the specific line that eliminates each one.
If more than one remains, give me the next single command that distinguishes them.
“Quote the line that eliminates each one” is the clause that makes the reasoning checkable in seconds instead of plausible in paragraphs.
Performance investigation
“The server is slow” is the least specific incident report there is, and it is where a model is most useful as a structuring device rather than as a source of answers.
The useful framing is that slowness has four possible resources behind it — CPU, memory, disk and network — and the first job is to eliminate three of them. That is a well-defined narrowing problem, which is exactly the shape to hand to chat.
CPU. Load average from /proc/loadavg against nproc gives the queue depth.
top or pidstat attributes it to a process. The distinction worth drawing, and
the one generated advice usually skips, is between a process burning CPU and
processes waiting — a high load with low CPU utilisation means the queue is
full of tasks blocked on something else, usually disk.
Memory. MemAvailable in /proc/meminfo, not “free” — Linux uses free
memory for page cache by design, so a machine with almost no free memory is
usually healthy. The number that matters is what is available for allocation.
Generated health checks compute the wrong one constantly. Then dmesg -T | grep -i oom for whether the kernel has already killed something.
Disk. iostat -x for utilisation and await, and df -i for inodes — a
filesystem with free space and no inodes fails with “no space left on device”,
which is a confusing message the first time.
Network. ss -s for socket summary, ss -ltn for what is listening, and
retransmit counters for whether the problem is off-box.
Here is the output of uptime, free -m, df -h, df -i, ss -s and iostat -x from a server users report as slow.
Which of CPU, memory, disk or network is implicated, and which are ruled out? Quote the specific number that supports each conclusion.
Give me one further read-only command for the resource that remains.
The thing to be careful about: a model will produce a confident narrative from ambiguous numbers. Asking it to quote the figure behind each conclusion is what makes the reasoning auditable, and occasionally reveals that the numbers do not actually support the story.
Log analysis and text processing
Log work is the other place where reading beats writing, and where a model saves real time.
Ask for the journalctl invocation rather than a grep pipeline. The
journal is structured, so -u, -p, --since, --until and
-o json do precisely what a chain of grep and awk approximates.
journalctl -u api -p warning --since "2 hours ago" -o cat is one command where
generated advice frequently produces four.
For files, the pipeline is where mistakes hide. Generated awk and sed is
usually right and occasionally subtly wrong — a field index off by one, a regex
that is greedy where it should not be, a sed -i that edits in place with no
backup. The check is cheap: run it without -i, on a copy, on ten lines first.
The counting idiom worth knowing collapses a log into a frequency table:
journalctl -u api --since today -o cat | grep -oE 'status=[0-9]{3}' | sort | uniq -c | sort -rn | headAsking for “the top error patterns in this log” gets you a variation of that, and it is genuinely faster than reading.
Two smaller points. grep -P is not available everywhere; grep -E is portable.
And anything parsing timestamps should ask for ISO-8601 output rather than
parsing a locale-dependent format — journalctl -o short-iso exists for exactly
that, and it removes a class of bug where a script works until someone changes
LC_TIME.
Linux-specific risks in generated commands
Destructive operations offered as remediation. rm -rf, systemctl stop,
apt purge, truncate. The most common category by far.
Glob expansion in a destructive command. rm -rf "$DIR"/* and
rm -rf "$DIR/*" behave very differently, and an unset $DIR in the unquoted
form is catastrophic.
Recursive permission changes. chmod -R 777 appears in generated
troubleshooting as a fix for permission errors and is never the right answer;
chown -R on a path built from a variable is the same class.
Filesystem and disk tools. dd, mkfs, fdisk, parted, lvremove. A
wrong device name is unrecoverable, and device names are not stable across
reboots — use /dev/disk/by-uuid/ rather than /dev/sdb.
Firewall flushes. iptables -F or nft flush ruleset on a remote host
removes the rule permitting your SSH session.
Package removal. apt remove on a library pulls out everything depending on
it. apt-get autoremove after an interrupted operation has removed kernels.
GNU versus BSD flag differences. sed -i, date -d and grep -P differ
between Linux and macOS. A suggestion may be written for the other one.
Destructive commands
Review workflow
- Read every commandHuman judgementIncluding the ones you recognise, and especially one-liners pasted from chat.
- Ask what it changesHuman judgementIf the answer is 'nothing', confirm that by reading, not by trusting the label 'health check'.
- Run bash -n and ShellCheckSeconds, and they clear the mechanical layer. Neither will tell you it is destructive.
- Check the targetHuman judgementWhich host, which device, which path — and is any of it built from an unvalidated variable?
- Check stderr is not suppressedHuman judgement2>/dev/null on an unverified command produces a silent no-op that still exits 0.
- Dry-run where one existsrsync --dry-run, apt --simulate, or replacing rm with echo.
- Run it in a container or on one host firstThis is the step that found both of this lesson's bugs.
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Diagnostic scripts report; remediation is a separate, deliberate action.
- Prefer
/procand/systo parsing human-facing tool output. - Compare load against
nproc, not against a hard-coded number. - Ask systemd what has failed rather than checking services you already suspect.
- Emit machine-readable output and a meaningful exit status, so the script can gate something.
- Never suppress stderr on a command you have not verified yourself.
- Put the modern-tool substitutions in your instructions file once.
Common mistakes
- Accepting
systemctl restartas a diagnostic step and destroying the evidence. - Trusting a clean ShellCheck run to mean a script is safe.
- Assuming
set -euo pipefailcatches everything — it does not catch process substitution. - Hard-coding thresholds that are meaningless across a heterogeneous fleet.
- Running a command from chat in a terminal because it looked routine.
Where to go next
GitHub Copilot for Bash covers the language
these scripts are written in — quoting, expansion, arrays and the set -e edges.
GitHub Copilot for Ansible is where this
becomes repeatable across a fleet, and
GitHub Copilot for Docker is where the host
stops being the unit of deployment.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.