
Inventory Installed Packages and Services Across Servers over SSH
Before you run this
This guide gives you a small bash script that logs into a list of servers over SSH, and on each one dumps two things to a text file on your control machine: the list of installed packages (via dpkg-query on Debian/Ubuntu or rpm on RHEL/Alma) and the list of systemd service unit files. The purpose is a point-in-time software and service inventory you can diff, audit, or archive.
It is read-only on the target servers. Every remote command here queries state; none of them install, remove, enable, or disable anything. The only files created are on the machine you run the script from, under a local inventory/ folder.
Privileges: you do not need root or sudo for any of this. dpkg-query, rpm -qa, and systemctl list-unit-files all work as an ordinary user. Run the script as your normal login on the control host, over your normal SSH access. If your SSH login itself requires a jump host or a specific key, that's on you to have working first — this script does not manage credentials.
Test first. Read the script before you run it. Point it at a single test host (a VM or one non-critical server) before you unleash it on a full fleet, so you can confirm the output looks right and that your SSH access is truly non-interactive. Nothing here is destructive, so there is no data to lose on the targets — but a script that hangs waiting for a password prompt on host #4 of 200 is its own kind of problem, which is why we force batch-mode SSH below.
What I assume
- Your control machine is Debian or Ubuntu with
bashand the OpenSSH client. The script itself is portable, but I wrote the wording for that. - Your target servers run systemd and use either
dpkg(Debian/Ubuntu) orrpm(RHEL/Alma/Rocky). The script detects which. - You have key-based SSH already set up to every target — you can
ssh user@hostand land on a shell without typing a password. If you're still on passwords, set up keys first; batch-mode SSH will refuse to prompt. - You have a plain list of hosts to connect to.
If your control host is different (macOS, a RHEL box), the script still runs — only the "install the client" step differs, and OpenSSH is already present nearly everywhere.
Prepare the host list
Create a file called hosts.txt, one user@host per line. Lines starting with # are ignored. Replace these with your real users and hostnames or IPs:
# hosts.txt — replace these with your servers
admin@web01.example.com
admin@db01.example.com
admin@10.0.0.42
Confirm your access is non-interactive before going further:
ssh -o BatchMode=yes admin@web01.example.com hostname
If that prints the hostname without prompting, you're good. If it errors with "Permission denied (publickey)", fix your key setup before running the loop.
The script
Save this as inventory.sh and make it executable with chmod +x inventory.sh.
#!/usr/bin/env bash
# inventory.sh — collect installed packages and services from remote hosts over SSH.
# Read-only on the targets. Writes results locally under ./inventory/<date>/<host>/.
set -euo pipefail
HOSTS_FILE="${1:-hosts.txt}" # first argument, or hosts.txt by default
OUTDIR="inventory/$(date +%Y-%m-%d)" # dated output folder
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10) # never prompt; give up after 10s
mkdir -p "$OUTDIR"
# The command block run on each target. Everything here only reads state.
# Heredoc is quoted ('EOF') so nothing expands locally — it runs verbatim remotely.
read -r -d '' REMOTE_CMD <<'EOF' || true
echo "=== host ==="; hostname
echo "=== os ==="
grep -E '^(PRETTY_NAME|VERSION_ID)=' /etc/os-release 2>/dev/null
echo "=== packages ==="
if command -v dpkg-query >/dev/null 2>&1; then
dpkg-query -W -f='${Package}\t${Version}\n' | sort
elif command -v rpm >/dev/null 2>&1; then
rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\n' | sort
else
echo "no known package manager (dpkg/rpm) found"
fi
echo "=== services ==="
if command -v systemctl >/dev/null 2>&1; then
systemctl list-unit-files --type=service --no-pager
else
echo "systemd not found"
fi
EOF
while read -r target; do
# skip blank lines and comments
[[ -z "$target" || "$target" == \#* ]] && continue
host="${target#*@}" # strip "user@" for the folder name
dest="$OUTDIR/$host"
mkdir -p "$dest"
echo "Collecting from $target ..."
if ssh "${SSH_OPTS[@]}" "$target" "$REMOTE_CMD" \
> "$dest/inventory.txt" 2> "$dest/error.log"; then
echo " OK -> $dest/inventory.txt"
else
echo " FAIL -> see $dest/error.log" # host stays in the list; loop continues
fi
done < "$HOSTS_FILE"
echo "Done. Output in $OUTDIR/"
A few notes on the non-obvious lines:
set -euo pipefailmakes the script stop on unset variables and most errors — but the SSH call is deliberately wrapped in anif, so one unreachable host logs a failure and the loop keeps going instead of aborting the whole run.- The heredoc is single-quoted (
<<'EOF'). That matters: it stops your local shell from expanding${Package}or$(...)— the block is sent to the remote host as-is. read -r -d '' ... || truereads the whole heredoc into one variable.readreturns non-zero at end-of-input, which is normal here, so we swallow it.-o BatchMode=yesguarantees SSH never stops to ask for a password or passphrase; it fails fast instead of hanging.
Run it
./inventory.sh # uses hosts.txt in the current directory
# or point at a different list:
./inventory.sh /path/to/other-hosts.txt
You'll see one line per host as it goes, and a per-host folder appear under inventory/<today's date>/.
If you want running services instead of installed unit files
systemctl list-unit-files shows every service unit and whether it's enabled, disabled, or static — that's the inventory question most people mean. If you instead want only what's currently running, the standard command is systemctl list-units --type=service --state=running. Swap that into the services block if that's your goal. I've kept unit files as the default because it survives reboots and reflects intent, not just the current moment.
Verify it worked
Check that every host produced a non-empty inventory and no errors:
# List the outputs and their sizes
find inventory/ -name inventory.txt -printf '%s\t%p\n'
# Any host that failed will have a non-empty error.log
find inventory/ -name error.log ! -empty
Spot-check one file — you should see the four === section headers and real content under each:
less inventory/$(date +%Y-%m-%d)/web01.example.com/inventory.txt
To compare two hosts, or the same host over time (say, before and after a patch window), plain diff on the sorted package lists does the job:
diff inventory/2024-01-01/web01.example.com/inventory.txt \
inventory/2024-06-01/web01.example.com/inventory.txt
Undo and cleanup
There is nothing to roll back on the servers — the run changed nothing there. The only footprint is the local output directory. To remove a single run:
rm -rf inventory/2024-01-01 # replace with the dated folder you want gone
Delete deliberately; there's no recovering it afterward.
If you outgrow this
A shell loop is the right tool for a handful of hosts and a one-off audit. Once you're managing dozens of servers regularly, this is exactly what a configuration-management tool does better: Ansible ships fact modules named package_facts and service_facts that return this data as structured JSON per host, with inventory and parallelism built in. That's the next step — but for a quick, dependency-free inventory over SSH, the script above is the standard, boring, reliable way. Check the OpenSSH client manual (man ssh) and the systemctl manual for the exact behaviour of any option before you adapt it.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
