Any VM with a public IP gets found fast. Bots scan the whole IPv4 internet for SSH around the clock. On a fresh Ubuntu test VM we brought up for this guide, fail2ban had logged 16 failed SSH logins and banned 3 IP addresses within about 15 minutes of boot. Expect the same on any internet-facing machine.
Your cloud provider runs the hardware and the network. Everything inside your VM is yours: the users, SSH, the firewall, the packages, and the Docker containers your models run in. This guide hardens both layers, the VM and its containers, then sets up automatic security patching that won’t swap your NVIDIA driver out from under a running job.
Every command here was tested on an Ubuntu 24.04 GPU VM running Docker and the NVIDIA 580 driver in November 2026. Each step starts with a quick check, because images and snapshots differ. Look at what your VM does before you change it.
At a glance
| Layer | Tool | What it does | Check it |
|---|---|---|---|
| VM | OpenSSH | Key-only logins, no root | sudo sshd -T |
| VM | ufw | Host firewall | sudo ufw status |
| VM | fail2ban | Bans IPs that brute-force SSH | sudo fail2ban-client status sshd |
| VM | unattended-upgrades | Installs security patches daily | systemctl is-enabled apt-daily-upgrade.timer |
| VM | needrestart | Restarts services after library updates | sudo needrestart -b |
| Docker | Docker Engine + NVIDIA Container Toolkit | Runs GPU containers | docker ps --format '{{.Names}} {{.Ports}}' |
| GPU | NVIDIA driver | Your GPU | apt-mark showhold |
Before You Start
- Keep a second SSH session open while you change SSH or firewall settings. If a change locks you out, the open session can undo it. Only close it after a fresh login works.
- Everything below runs as your normal login user with
sudo.
Part 1: Harden the VM
Step 1: Use least-privilege users
Your login user probably has sudo, often without a password. That’s fine for administering the box, but your model servers shouldn’t run as that user, and never as root. Give each long-running service its own locked-down system user:
sudo useradd --system --create-home --home-dir /srv/llm --shell /usr/sbin/nologin llm
id llm
Then run the service as that user, for example with User=llm in its systemd unit. If it gets compromised, the attacker gets a user who can’t log in and can’t sudo.
One rule that catches people out: membership in the docker group is equivalent to root. Anyone who can talk to the Docker socket can start a privileged container and mount the host’s disk. Only add trusted admins to it, and never add service users like llm. Inside containers, least privilege comes from --user (Part 2).
Step 2: SSH keys only, no root, no passwords
First, check what your SSH server actually allows:
sudo sshd -T | grep -E '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication) '
If you see passwordauthentication yes, anyone on the internet can try to guess a password. Don’t assume the main sshd_config tells the whole story. Other files in /etc/ssh/sshd_config.d/ can override it, and cloud-init and other provisioning tools commonly drop one there.
Before you turn anything off, make sure you won’t lock yourself out:
- Don’t log in as root. Run
whoami. If it saysroot, first create a sudo user (adduser <user> && usermod -aG sudo <user>), copy/root/.ssh/authorized_keysto/home/<user>/.ssh/(owned by that user, mode600), and confirm you can log in as that user. - Test your key on its own:
ssh -o IdentitiesOnly=yes -i ~/.ssh/your_key <user>@<vm-ip>. TheMaxAuthTries 3setting below counts every key your SSH agent offers, so if you carry several keys, useIdentitiesOnlyand-i(orIdentityFilein~/.ssh/config).
Then add your own drop-in file:
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'
# Named 00- so it loads first. sshd keeps the first value it reads.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
X11Forwarding no
EOF
The 00- prefix matters. Ubuntu’s sshd_config includes sshd_config.d/*.conf at the top, in name order, and for most settings sshd uses the first value it reads. A file named 99-hardening.conf would lose to any lower-numbered file that sets the same option. Test the config, restart, and confirm:
sudo sshd -t && sudo systemctl restart ssh
sudo sshd -T | grep -E '^(passwordauthentication|permitrootlogin|maxauthtries) '
maxauthtries 3
permitrootlogin no
passwordauthentication no
Now prove it from your laptop. A password-only attempt should be refused before it ever asks for a password:
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password <user>@<vm-ip>
<user>@<vm-ip>: Permission denied (publickey).
Step 3: Turn on the firewall (ufw)
ufw ships with Ubuntu but is inactive until you enable it. First check which port SSH listens on with sudo ss -tlnp | grep sshd. The commands below assume port 22; if yours differs, use sudo ufw limit <port>/tcp instead of sudo ufw limit OpenSSH, or the firewall will block your next login.
Deny everything incoming, allow SSH, then enable it:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH
sudo ufw --force enable
sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), deny (routed)
To Action From
-- ------ ----
22/tcp (OpenSSH) LIMIT IN Anywhere
22/tcp (OpenSSH (v6)) LIMIT IN Anywhere (v6)
limit allows SSH but rejects an IP that opens 6 or more connections within 30 seconds. That slows down brute-force bots, but it can also block you. A script that opens several SSH connections in a row trips the limit easily, and the next login gets Connection refused for about a minute. Rsync loops, Ansible and CI jobs all do this. If you script a lot of SSH, reuse one connection with ControlMaster in your SSH config. Or allow your own IP outright: sudo ufw allow from <your-ip> to any port 22 proto tcp.
Running Docker? ufw does not protect ports that Docker publishes. That’s the most important fact in this guide, and Part 2 covers it.
Step 4: Ban brute-force attempts with fail2ban
fail2ban watches the SSH logs and temporarily bans IPs that keep failing. Check whether it’s already running with sudo fail2ban-client status sshd. If not, install it:
sudo apt install fail2ban
Add a local config so your settings survive package updates, and add your own IP to ignoreip so you can’t ban yourself:
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
# Never ban yourself. Add your own public IP here:
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
backend = systemd
EOF
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
On an internet-facing VM, don’t be surprised to see bans within minutes:
Status for the jail: sshd
|- Filter
| |- Currently failed: 0
| |- Total failed: 16
`- Actions
|- Currently banned: 1
|- Total banned: 3
Step 5: Close ports you don’t use
List everything that’s listening, and on which address:
sudo ss -tulpn
Anything bound to 0.0.0.0 or [::] is listening on every interface, including the public one. Common surprises are a database left on its default port, a Jupyter server, an old test web server, or a helper service you never use. With ufw on, those ports are blocked from outside, but don’t rely on one layer. Stop what you don’t need, and bind the rest to 127.0.0.1:
sudo systemctl disable --now <service-name>
Part 2: Lock Down Docker
Docker skips your firewall
This is the one that bites people. When you publish a container port with -p 8080:80, Docker routes that traffic through its own iptables rules in the nat table. Those rules run before ufw’s rules, so ufw never sees the traffic. Docker’s own docs say so.
You can see it for yourself. With ufw active and set to deny all incoming traffic except SSH, start two containers:
sudo docker run -d --name web-public -p 8080:80 nginx:1.27-alpine
sudo docker run -d --name web-local -p 127.0.0.1:8081:80 nginx:1.27-alpine
Then, from a laptop on the internet:
$ curl -s -o /dev/null -w '%{http_code}\n' http://<vm-ip>:8080/
200 <- reachable, even though ufw says "deny (incoming)"
$ nc -zv -w 4 <vm-ip> 8081
(no answer) <- the 127.0.0.1-only container is not reachable
ufw status shows nothing but SSH, and the container on 8080 serves pages to the whole internet anyway. Every vLLM, Jupyter, ComfyUI or Open WebUI container you start with -p port:port behaves the same way. Remove the test containers with sudo docker rm -f web-public web-local.
The fix: publish to localhost, connect over SSH
Bind container ports to 127.0.0.1 so they’re only reachable from inside the VM:
docker run -d -p 127.0.0.1:8000:8000 your-inference-image
Or in Docker Compose:
services:
vllm:
image: vllm/vllm-openai:<pinned-tag>
ports:
- "127.0.0.1:8000:8000"
Then reach it from your laptop through an SSH tunnel:
ssh -N -L 8000:127.0.0.1:8000 <user>@<vm-ip>
# now open http://localhost:8000 on your laptop
The tunnel is encrypted, needs your SSH key, and opens no new ports. Your laptop gets the service, and the internet doesn’t.
If a service really must be public, put an authenticating reverse proxy with TLS in front of it, publish only the proxy’s port 443, and keep the app itself on 127.0.0.1. To restrict a published port to specific source IPs, add rules to the DOCKER-USER iptables chain, which Docker evaluates before its own rules (see Docker’s packet filtering docs).
Run containers with less privilege
A container that runs as root, with every capability, is one bug away from owning the host. Tighten what you can:
docker run --rm \
--user 1000:1000 \
--cap-drop ALL \
--read-only \
--security-opt no-new-privileges \
ubuntu:24.04 id
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
Not every image works with all of these flags, so add them one at a time. Some patterns you should avoid entirely:
--privileged, which gives the container nearly full access to the host.-v /var/run/docker.sock:/var/run/docker.sock, which hands the container root on the host.--network host, which makes the container’s ports host ports and skips Docker’s network isolation.- Writable mounts of your model weights. Mount them read-only:
-v /data/models:/models:ro.
GPU containers only need --gpus all (or --gpus device=0). That doesn’t require --privileged:
sudo docker run --rm --gpus all ubuntu:24.04 nvidia-smi --query-gpu=name,driver_version --format=csv
Keep secrets out of images and snapshots
- Never put tokens in a Dockerfile.
ENV HF_TOKEN=...orCOPY .env .bakes the secret into an image layer. Deleting the file in a later layer doesn’t remove it, anddocker historyor a pulled copy of the image can recover it. - Pass secrets at runtime with
--env-file /etc/llm/llm.env(owned by root, mode0600) or Composesecrets:. For systemd services, useEnvironmentFile=. - Scrub the VM before you snapshot it. Check for tokens in
~/.bash_history, notebooks,~/.cache/huggingface/token,~/.aws,~/.config, registry logins in~/.docker/config.jsonand/root/.docker/config.json, and webhook or API URLs in/etc(such as/etc/patch-alert.urlfrom Part 4). Remove old keys from~/.ssh/authorized_keys. Anyone who launches your snapshot gets everything in it. - Remember that
docker inspectshows environment variables, including ones loaded from--env-file. Anyone with Docker access can read them, which is one more reason to keep thedockergroup small. - Rotate anything that leaked. If a token ever landed in an image or a public repo, revoke it. Deleting the file isn’t enough.
Use images you can trust
- Prefer official or verified-publisher images.
- Pin a version, and ideally a digest, never just
latest:nginx:1.27-alpine@sha256:.... Tags can be re-pointed. In March 2026, attackers force-pushed 76 of the 77 version tags of thetrivy-actionGitHub Action to credential-stealing code, and published a malicious Trivy 0.69.4 release and Docker images (CVE-2026-33634). Version tags couldn’t be trusted, while references pinned to a known-good commit or digest kept pulling the real thing. - Scan what you run. Docker Scout (
docker scout cves <image>) or Trivy will list known CVEs in an image. Pinning an image keeps it stable, but it also freezes its vulnerabilities. In a Trivy scan on November 5, 2026, the oldernginx:1.27-alpineimage had 63 HIGH or CRITICAL CVEs. That’s why Part 3 rebuilds images on a schedule.
Part 3: Keep the VM and Containers Patched
Check whether automatic updates are on
dpkg -l unattended-upgrades | tail -1
systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer
apt-config dump | grep -E '^APT::Periodic::(Update-Package-Lists|Unattended-Upgrade) '
On a stock Ubuntu Server install, unattended-upgrades is installed and installs security updates daily. Don’t assume yours does, though. It’s often off on:
- Custom images and old snapshots.
- VMs someone set up to “never change under a running job.”
Any of these results means it’s off:
dpkg -lshowsrc(removed) instead ofii.systemctlsaysmaskedordisabledfor either timer. The timers run the daily updates; theunattended-upgradesservice only makes sure a run finishes before shutdown.- The periodic values are
0or missing.
If you keep a VM for more than a few days, turn it on.
Turn on unattended-upgrades
sudo apt update
sudo apt install unattended-upgrades
sudo systemctl unmask unattended-upgrades # harmless if it wasn't masked
sudo systemctl enable --now unattended-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
sudo systemctl unmask apt-daily.timer apt-daily-upgrade.timer # harmless if they weren't masked
sudo systemctl enable --now apt-daily.timer apt-daily-upgrade.timer
systemctl list-timers 'apt-daily*'
Update-Package-Lists "1" refreshes package lists daily, and Unattended-Upgrade "1" installs updates daily. The two apt-daily timers run them, each with a random delay.
Security-only or everything?
The allowed sources live in /etc/apt/apt.conf.d/50unattended-upgrades. By default only security updates are allowed:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
// "${distro_id}:${distro_codename}-updates";
Adding -updates would install every bug-fix release too. On a GPU VM, keep the default (security only) and install everything else yourself during a monthly maintenance window (see below). Security updates are small, targeted fixes. Bulk updates are where surprises come from.
Also note what’s not on that list: Docker’s repository and NVIDIA’s repositories. unattended-upgrades never touches Docker Engine, the NVIDIA Container Toolkit, or anything from NVIDIA’s CUDA repo. You update those deliberately.
Put your settings in a local override file
Don’t edit 50unattended-upgrades itself; a package update can replace it. Put your changes in a file that sorts after it:
sudo tee /etc/apt/apt.conf.d/52unattended-upgrades-local >/dev/null <<'EOF'
// Local overrides. 50unattended-upgrades stays untouched.
// Never auto-upgrade the GPU driver stack. Update it by hand in a maintenance window.
Unattended-Upgrade::Package-Blacklist {
"nvidia-";
"libnvidia-";
"xserver-xorg-video-nvidia";
};
// Reboot automatically when an update needs it, but only at 04:00 server time.
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-WithUsers "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
// Clean up old kernels and unused dependencies.
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
EOF
apt-config dump | grep -E 'Unattended-Upgrade::(Package-Blacklist|Automatic-Reboot)'
Always confirm with apt-config dump. For single values, whichever file sorts last wins, and installers and other tools sometimes leave their own files in /etc/apt/apt.conf.d/. Check the merged result, not just the file you wrote.
Decide about automatic reboots
Kernel and core-library updates need a reboot to take effect. With the settings above, the VM reboots at 04:00 only when an update requires it, and the time is in the VM’s time zone (timedatectl). A reboot kills whatever is running, so choose one:
- Inference servers: allow the 04:00 reboot, and start containers with
--restart unless-stopped(orrestart: unless-stoppedin Compose) so they come back on their own. - Long training runs: set
Automatic-Reboot "false", checkpoint regularly, and reboot by hand between runs. The alert in Part 4 tells you when a reboot is pending.
Keep needrestart from restarting Docker
Since Ubuntu 24.04, needrestart automatically restarts services that are using outdated libraries after an upgrade. If that includes docker.service or containerd.service, every container on the VM restarts with it. Tell it to leave those two alone:
sudo tee /etc/needrestart/conf.d/50-keep-containers.conf >/dev/null <<'EOF'
# Restarting dockerd/containerd restarts every container. Do it in a maintenance window instead.
$nrconf{override_rc}{qr(^docker\.service$)} = 0;
$nrconf{override_rc}{qr(^containerd\.service$)} = 0;
EOF
sudo perl -c /etc/needrestart/conf.d/50-keep-containers.conf
Add your own inference service to that list the same way if it can’t tolerate a surprise restart.
Protect the NVIDIA driver
A driver update while your job is running, or a kernel update the driver module can’t build against, is how auto-patching breaks GPU boxes. There are two layers of protection.
1. Package holds. Check what’s held, and hold your driver packages if they aren’t already:
apt-mark showhold
sudo apt-mark hold nvidia-driver-580-open nvidia-dkms-580-open # use your driver's package names
Hold your kernel metapackages too. This is required if Automatic-Reboot is true. New kernels arrive through the security pocket. If one installs unattended and your held driver can’t build against it, the 04:00 reboot brings the VM up with no GPU (the monthly routine below shows exactly that failure). Find the metapackages and hold them:
dpkg -l 'linux-generic*' 'linux-image-generic*' 'linux-headers-generic*' 'linux-virtual*' | awk '/^ii/{print $2}'
sudo apt-mark hold linux-generic-hwe-24.04 linux-image-generic-hwe-24.04 linux-headers-generic-hwe-24.04 # use the names printed above
unattended-upgrades respects holds, so it now skips the driver and the kernel. The log shows a message like linux-generic-hwe-24.04 upgradable but fails to be marked for upgrade, which is expected.
2. The blacklist in the override file above catches anything named nvidia-* or libnvidia-* that isn’t held.
If your driver came from NVIDIA’s own CUDA repository instead of Ubuntu’s, NVIDIA documents a version-lock pin file (Pin: version 580*) that keeps every NVIDIA package on one driver branch.
The trade-off: a held kernel doesn’t get security fixes automatically. That’s the price of a stable GPU stack, so you patch the kernel and driver by hand, on your schedule (see the monthly routine below).
Two more things to know:
- Manual
apt upgradeis not protected by the blacklist. The blacklist only applies to unattended-upgrades. If NVIDIA’s CUDA repo is configured on your VM, a plainsudo apt upgradecan move the NVIDIA Container Toolkit and NVIDIA’s utility libraries to new versions, sometimes onto a newer driver branch than your driver. Read the list before you pressy. - CUDA usually lives in the container, not on the host. Your container brings its own CUDA runtime and needs only a host driver new enough to support it.
nvidia-smishows the newest CUDA version your driver supports (driver 580 showsCUDA Version: 13.0), so images built for that version or older will run. Pin your image’s CUDA tag (for examplenvidia/cuda:12.8.1-runtime-ubuntu24.04) so it doesn’t drift.
Patch Docker and your containers
Containers never patch themselves. The OS inside an image stays exactly as it was when the image was built.
- Rebuild and re-pull on a schedule (weekly or monthly), and whenever your base image publishes a security fix:
bash
docker compose pull
docker compose up -d
docker image prune -f
- Rebuild your own images with
docker build --pullso the newest base image gets used. - Update Docker Engine and the NVIDIA Container Toolkit in your monthly window. They come from Docker’s and NVIDIA’s repositories, which unattended-upgrades skips. (If you installed Ubuntu’s own
docker.iopackage instead, it does get security updates automatically, and its upgrade restarts the Docker daemon.) - Consider
"live-restore": truein/etc/docker/daemon.json. With it, containers keep running while the Docker daemon restarts for an upgrade. It’s a documented Docker option; restart Docker once after adding it.
The monthly maintenance routine
This is the part that automation deliberately leaves to you: the kernel, the driver stack, Docker, and non-security updates. Once a month, between jobs:
sudo apt update
apt list --upgradable # read it, especially anything linux-, nvidia or docker
apt-mark showhold > ~/holds.txt # remember what was held
sudo apt-mark unhold $(cat ~/holds.txt) # unhold the kernel AND the driver, together
sudo apt upgrade
dkms status # do not reboot until this line looks right
Unhold the kernel and the driver together. Our testing shows why. We unheld only the kernel, and apt upgrade moved the HWE kernel from 6.17 to 7.0. The held driver (580.126.16) then failed to build its module against the new kernel:
Autoinstall of module nvidia/580.126.16 for kernel 7.0.0-42-generic (x86_64)
nvidia-uvm/uvm_hmm.c:84:39: error: too few arguments to function 'zone_device_page_init'
Error! Bad return status for module build on kernel: 7.0.0-42-generic (x86_64)
/boot/vmlinuz already pointed at the new kernel, so a reboot at that point would have come up with no GPU driver at all. Unholding the driver too and running apt upgrade again moved it to 580.196.07 on the same 580 branch, and the module built for both kernels:
nvidia/580.196.07, 6.17.0-14-generic, x86_64: installed
nvidia/580.196.07, 7.0.0-42-generic, x86_64: installed
Only when dkms status shows installed for the new kernel is it safe to reboot:
sudo reboot
After the reboot, check the GPU on the host and in a container, then put the holds back:
uname -r
nvidia-smi
sudo docker run --rm --gpus all ubuntu:24.04 nvidia-smi --query-gpu=name,driver_version --format=csv
sudo apt-mark hold $(cat ~/holds.txt)
All three should report the new kernel and the same driver version, on the host and inside the container. Any container started with --restart unless-stopped comes back on its own.
If dkms status shows an error and you can’t fix it, don’t reboot. Remove the new kernel so the VM boots the one that works, but simulate the removal first, because it can also take your kernel metapackages with it:
apt remove -s linux-image-<new-version>-generic linux-modules-<new-version>-generic # -s = simulate
Read the list, then run the same command without -s. If it removed a metapackage such as linux-generic-hwe-24.04, reinstall it once a driver that supports the new kernel is out.
If nvidia-smi prints Failed to initialize NVML: Driver/library version mismatch, the driver libraries were updated but the old kernel module is still loaded. Reboot.
On RHEL, Rocky or AlmaLinux: dnf-automatic
The same idea on RHEL-family systems:
sudo dnf install dnf-automatic
In /etc/dnf/automatic.conf, set upgrade_type = security and apply_updates = yes under [commands]. Set reboot = when-needed if you want automatic reboots (the default is never). Then:
sudo systemctl enable --now dnf-automatic.timer
To keep the NVIDIA driver from moving, use dnf versionlock (from the python3-dnf-plugin-versionlock package) or NVIDIA’s driver module streams. Fedora 41 and later use dnf5, whose dnf-automatic uses the same config file but differs in some options, so check man dnf5-automatic.
Part 4: Verify It Works
Dry run. See what would be installed, without changing anything:
sudo unattended-upgrade --dry-run -v
Allowed origins are: o=Ubuntu,a=noble, o=Ubuntu,a=noble-security, o=UbuntuESMApps,a=noble-apps-security, o=UbuntuESM,a=noble-infra-security
Initial blacklist: nvidia- libnvidia- xserver-xorg-video-nvidia
Option --dry-run given, *not* performing real actions
Packages that will be upgraded: ... openssh-server openssl ... sudo systemd ...
Check three things in the output:
- Allowed origins shows only security sources.
- Initial blacklist shows your NVIDIA patterns.
- No
nvidiapackages appear in the upgrade list.
Logs. Each run writes here:
sudo tail -n 30 /var/log/unattended-upgrades/unattended-upgrades.log
grep -A3 "Start-Date" /var/log/apt/history.log | tail -n 20
journalctl -u apt-daily-upgrade.service --since "2 days ago"
A healthy run on a GPU VM with held driver and kernel packages ends with lines like these:
INFO Package nvidia-driver-580-open is marked to be held back.
INFO Package linux-image-generic-hwe-24.04 is marked to be held back.
INFO Package xserver-xorg-video-nvidia-580 is blacklisted.
WARNING Found /var/run/reboot-required, rebooting
WARNING Shutdown msg: b"Reboot scheduled for Fri 2026-11-06 04:00:00 UTC, use 'shutdown -c' to cancel."
- Held: the driver and kernel were skipped.
- Blacklisted: the unheld NVIDIA package was skipped.
- Reboot: the needed reboot was scheduled for 04:00 instead of happening mid-day. To reboot sooner yourself, run
sudo shutdown -c, thensudo reboot. - Docker: with the needrestart override above, Docker kept running through the upgrade, and the containers weren’t restarted.
sudo needrestart -blistsdocker.serviceandcontainerd.serviceas waiting for a restart, which you can do at your convenience.
Is a reboot pending?
cat /var/run/reboot-required 2>/dev/null && cat /var/run/reboot-required.pkgs
sudo needrestart -b
Is only SSH reachable? Run this from your laptop, not from the VM, with your containers running:
nc -zv -w 4 <vm-ip> 22 # should succeed
nc -zv -w 4 <vm-ip> 8000 # should fail if your app is bound to 127.0.0.1
A small alert so you don’t have to remember
This script checks for a pending reboot, unattended-upgrades errors, a run that’s more than 3 days old, stopped containers or an unresponsive Docker, and a broken nvidia-smi. If anything is wrong, it posts one line to a Slack or Discord webhook. It needs jq, which isn’t installed on every Ubuntu server, so install that first:
sudo apt install -y jq
sudo tee /usr/local/sbin/patch-alert >/dev/null <<'EOF'
#!/usr/bin/env bash
# Posts a short message to a Slack/Discord webhook when this VM needs attention.
set -u
command -v jq >/dev/null || { echo "patch-alert: jq is not installed" >&2; exit 1; }
WEBHOOK_URL="$(cat /etc/patch-alert.url 2>/dev/null)"
host="$(hostname)"
msgs=()
[ -f /var/run/reboot-required ] && \
msgs+=("reboot pending for: $(tr '\n' ' ' < /var/run/reboot-required.pkgs 2>/dev/null)")
log=/var/log/unattended-upgrades/unattended-upgrades.log
if [ -f "$log" ] && tail -n 50 "$log" | grep -q -E 'ERROR|Traceback'; then
msgs+=("unattended-upgrades logged an error, check $log")
fi
last=$(stat -c %Y /var/lib/apt/periodic/unattended-upgrades-stamp 2>/dev/null || echo 0)
if [ $(( $(date +%s) - last )) -gt $(( 3 * 86400 )) ]; then
msgs+=("unattended-upgrades has not run in over 3 days")
fi
if command -v docker >/dev/null; then
if ! down=$(docker ps -a --filter status=exited --filter status=dead --format '{{.Names}}' 2>/dev/null); then
msgs+=("docker is not responding")
elif [ -n "$down" ]; then
msgs+=("stopped containers: $(echo $down)")
fi
fi
if command -v nvidia-smi >/dev/null && ! nvidia-smi >/dev/null 2>&1; then
msgs+=("nvidia-smi is failing (driver/library mismatch? reboot)")
fi
[ ${#msgs[@]} -eq 0 ] && exit 0
text="[$host] $(printf '%s; ' "${msgs[@]}")"
if [ -n "$WEBHOOK_URL" ]; then
# Slack reads "text", Discord reads "content". Sending both works for either.
payload=$(jq -n --arg t "$text" '{text: $t, content: $t}')
curl -fsS -m 10 -H 'Content-Type: application/json' -d "$payload" "$WEBHOOK_URL" >/dev/null
else
echo "$text"
fi
EOF
sudo chmod 0755 /usr/local/sbin/patch-alert
Store your webhook URL in a root-only file. Create the file with the right permissions first, then paste the URL in the editor, so it never lands in your shell history:
sudo install -m 600 -o root -g root /dev/null /etc/patch-alert.url
sudoedit /etc/patch-alert.url # paste the webhook URL on one line, save
Then run the script every morning with a systemd timer:
sudo tee /etc/systemd/system/patch-alert.service >/dev/null <<'EOF'
[Unit]
Description=Alert when this VM needs patching attention
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/patch-alert
EOF
sudo tee /etc/systemd/system/patch-alert.timer >/dev/null <<'EOF'
[Unit]
Description=Run patch-alert every morning
[Timer]
OnCalendar=*-*-* 08:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now patch-alert.timer
sudo /usr/local/sbin/patch-alert # test it now
When everything is healthy, the script prints nothing and sends nothing. To test it, run a container that exits right away (sudo docker run --name crashed ubuntu:24.04 false). Your webhook then receives:
{
"text": "[my-gpu-vm] stopped containers: crashed; ",
"content": "[my-gpu-vm] stopped containers: crashed; "
}
Clean up with sudo docker rm crashed.
Copy-Paste Checklist
VM
- Service users for workloads; only trusted admins in
sudoanddocker /etc/ssh/sshd_config.d/00-hardening.conf: no root, no passwordssudo sshd -T | grep passwordauthentication→no- Password login refused from your laptop:
Permission denied (publickey) sudo ufw status→ active, deny incoming, only SSH allowedsudo fail2ban-client status sshd→ jail running, your IP inignoreipsudo ss -tulpn→ nothing listening publicly that you don’t need
Docker
- Every published port is
127.0.0.1:host:container; access overssh -L - No
--privileged, nodocker.sockmounts, no--network host - Containers run as non-root where possible; model weights mounted
:ro - No secrets in Dockerfiles or images;
--env-filewith mode0600 - Images pinned by version or digest and scanned
Patching
unattended-upgradesinstalled,apt-daily*timers enabled,20auto-upgradesset to1- Security-only origins; your settings in
52unattended-upgrades-local - NVIDIA driver held (
apt-mark showhold) and inPackage-Blacklist - Kernel metapackages held if
Automatic-Rebootistrue - Reboot policy chosen; containers use
--restart unless-stopped - needrestart leaves
docker/containerdalone sudo unattended-upgrade --dry-run -vshows nonvidiapackagespatch-alert.timerenabled and tested- Monthly: kernel and driver unheld together,
dkms statuschecked before reboot, thennvidia-smi
Frequently Asked Questions
01I locked myself out of SSH.
Use the session you kept open to undo the change: sudo rm /etc/ssh/sshd_config.d/00-hardening.conf && sudo systemctl restart ssh, or sudo ufw disable. If you get Connection refused right after a burst of logins, that’s ufw limit; wait a minute and try again.
02I set ufw deny, but my container port is still open.
Docker’s rules run before ufw’s. Publish to 127.0.0.1 instead (Part 2).
03Could not get lock /var/lib/dpkg/lock-frontend right after boot.
unattended-upgrades is probably catching up on a missed run. Wait for it to finish (journalctl -fu apt-daily-upgrade). If your VMs boot just for short jobs, you can stop timers from running at boot with sudo systemctl edit apt-daily-upgrade.timer, adding [Timer] and Persistent=false.
04nvidia-smi: Driver/library version mismatch.
The driver packages changed but the old kernel module is still loaded. Reboot. If it keeps happening, something is upgrading the driver: check apt-mark showhold and /var/log/apt/history.log.
05Should I change the SSH port?
It cuts log noise from bots, but it isn’t security. Keys-only SSH plus fail2ban is what protects you. If you do change it, open the new port in ufw first, or you’ll lock yourself out. On Ubuntu 24.04, SSH is started by ssh.socket, so after changing Port, run sudo systemctl daemon-reload && sudo systemctl restart ssh.socket, and test a new login before closing your session.
06Is security-only patching enough?
For the OS packages, yes, alongside the monthly maintenance pass. The bigger gaps on most GPU VMs are containers that never get rebuilt and ports published without 127.0.0.1.
Launch a GPU VM
Working through this guide takes about 20 minutes on a fresh VM, and you only do it once per VM (or once per snapshot you build on).











