GitHub Copilot for Linux Administration

GitHub Copilot for DevOps & InfrastructureAcademy lesson 46Cluster 4 · Lesson 8 of 13Intermediate14 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for Linux AdministrationGitHub Copilot for DevOps & Infrastructure8Intermediate/github-copilot/devops/linux/

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.

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                     0

Every 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:

  1. 2>/dev/null discarded the message. The error existed and nobody saw it.
  2. The call was inside a process substitutionwhile read … done < <(df …) — which is a separate process whose exit status the shell does not inspect. So set -e had nothing to act on.
  3. The while loop 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

examples/cluster-4/linux

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

Copilot promptGenerate the toolkitCopilot Chat

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
done

ShellCheck’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

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:

Copilot promptDiagnose without changing anythingCopilot Chat

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:

Copilot promptAfter the diagnosticsCopilot Chat

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.

Copilot promptNarrowing, not guessingCopilot Chat

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 | head

Asking 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

Accepting a generated command or script
  1. Read every commandHuman judgementIncluding the ones you recognise, and especially one-liners pasted from chat.
  2. Ask what it changesHuman judgementIf the answer is 'nothing', confirm that by reading, not by trusting the label 'health check'.
  3. Run bash -n and ShellCheckSeconds, and they clear the mechanical layer. Neither will tell you it is destructive.
  4. Check the targetHuman judgementWhich host, which device, which path — and is any of it built from an unvalidated variable?
  5. Check stderr is not suppressedHuman judgement2>/dev/null on an unverified command produces a silent no-op that still exits 0.
  6. Dry-run where one existsrsync --dry-run, apt --simulate, or replacing rm with echo.
  7. 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 /proc and /sys to 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 restart as a diagnostic step and destroying the evidence.
  • Trusting a clean ShellCheck run to mean a script is safe.
  • Assuming set -euo pipefail catches 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.

Primary sources