Docs menu Platforms

Linux host baseline

The state every Linux guest reaches before it carries a workload. One administrative account, key-only SSH, no root login over SSH, a sudo prompt that asks for root's password, and passwordless sudo reserved for the automation account.

Updated Source in the repo: Guides/Linux-Host-Baseline.md Operations/Maintenance/Fleet Access Model Verified and Credential Item Collapsed - 2026-09-07.md Operations/Maintenance/Sudo Pointed at the Root Password on Eleven Guests - 2026-08-20.md Operations/Maintenance/Guest Package Updates - 2026-10-02.md Platforms/Ansible/Source/host-access-baseline/README.md

I apply this baseline to a Linux VM or LXC before it carries a workload. The finish line is a patched host with one administrative account, three authorized SSH public keys, key-only SSH, root that cannot log in over SSH, a sudo prompt that asks for a password other than the login password, passwordless sudo for automation only, and consistent time and locale.

docker-network (CT 107) is the recorded reference build. I applied the same baseline to media-01 (CT 842) when I built the media stack, The password and sudo parts were rolled across the rest of the fleet with the Ansible host access baseline project in August 2026.

Scope

The baseline covers the Linux guests in the host access model. On 2026-09-07, when I verified it, that was eleven guests:

GuestKindNotes
alpha-prod-01VMTeamSpeak host
app-01VMCoolify host; last guest to lose root SSH, on 2026-09-07
edge-01VM
security-01VMWazuh
splunk-siemVMThe one Rocky Linux host; its admin group is wheel, not sudo
docker-blueLXC
docker-networkLXCReference build
monitor-01LXCPrometheus and Grafana
media-01LXCMedia stack
game-01LXCRetired on 2026-09-12
ansible-01LXCThe automation controller

Some hosts sit outside the model on purpose. The five Proxmox nodes take root access through the cluster’s shared key file, not a POSIX account. docker-main stays a root-login-only, keyed host with no separate admin account. ubuntu-dev is a single-account workstation. Windows hosts follow their own records.

Linux host baseline diagram: a fresh guest passes through layer 1, patch and identity, layer 2, SSH and sudo, and layer 3, joining the fleet, each gated by a check, before it is workload-ready

The six steps in three layers, each gated by a check, from the repository’s diagram set. Click to enlarge.

Step 1: Patch the host

apt update
apt upgrade -y

On an RHEL-family host I use dnf upgrade -y. I don’t install the workload until the package transaction exits 0.

After the build, patching moves to the fleet os-update.yml playbook, described on the automation page. The most recent fleet pass was on 2026-10-02. It covered all twelve running Linux guests: eleven through the playbook and ubuntu-dev by hand, with reboots left in report-only mode.

GuestPackages installed or upgradedResult
alpha-prod-0160Newer kernel installed
ansible-0153Semaphore active
app-0170Newer kernel installed
docker-blue69Containers running
docker-main19Application containers running
docker-network54Proxy and NetBird containers running
edge-0153Newer kernel installed
media-0153Media containers running
monitor-015350 of 50 Prometheus targets up
security-0174Wazuh central stack upgraded to 4.14.8-1
splunk-siem82DNF: 77 upgrades and 5 kernel-package installs
ubuntu-dev43Newer kernel installed

Every guest kept its boot ID, all eleven APT guests passed dpkg --audit and apt-get check, and Rocky passed dnf check. Six VMs were left with a newer kernel installed than the one running, so a reboot window was still open after that pass. The LXCs share their node’s kernel. Existing wazuh-agent package holds stayed in place.

Step 2: One administrative account

adduser <admin>
usermod -aG sudo <admin>

Group membership is the whole policy. %sudo ALL=(ALL:ALL) ALL already ships in /etc/sudoers, so the account gets sudo and is asked for a password. Step 5 makes that password root’s rather than its own.

I don’t write a NOPASSWD drop-in for a human account. Automation needs one because it runs unattended, and a person at a keyboard doesn’t. It also means a stolen key on its own is not root.

Step 3: Three public keys

The admin account’s ~/.ssh/authorized_keys holds one complete ED25519 public key per line, for the three approved identities. The directory is 0700, the file is 0600, and both are owned by the account. The keys themselves are managed by the SSH identity automation once the host joins the inventory.

Step 4: Key-only SSH, no root login

I write /etc/ssh/sshd_config.d/99-hardening.conf:

PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no

I run sshd -t before restarting SSH and keep the console open until a second session connects with a public key.

Two lessons from rolling this out:

  • sshd takes the first value it reads for a keyword. On security-01, 50-cloud-init.conf sets PasswordAuthentication yes. The hardening file there carries a 00- prefix so it is read first and wins on every setting the two share.
  • On a socket-activated sshd, restart instead of reload. On 2026-08-15 a systemctl reload ssh on ansible-01 killed the daemon: after SIGHUP, sshd re-executed, lost the socket systemd had handed it, failed with Cannot bind any address, and systemd then removed /run/sshd. systemctl restart ssh recovered it in one step, and connectivity never dropped because ssh.socket stayed active.

Root SSH came off in stages. ansible-01 and security-01 moved from prohibit-password to no on 2026-08-15; neither had a single key in root’s authorized_keys, so the setting granted nothing. app-01 stayed open because Coolify managed its own host over root SSH (735 root logins in the 30 days to 2026-08-15). On 2026-09-07 I moved Coolify to a dedicated non-root service account and closed root SSH there too, which put all eleven guests on PermitRootLogin no.

Warning: Any guest cloned from debian13-template or ubuntu-cloud-template arrives with root locked. That is exactly the state that makes Step 5 dangerous, so the order is not optional.

Step 5: Give root a password and point sudo at it

I set root’s password with passwd root and prove it authenticates with su - from an unprivileged account before going further. Then I write /etc/sudoers.d/00-rootpw, mode 0440, validated with visudo -cf at a temporary path first:

Defaults rootpw

A sudo prompt on the host now asks for root’s password instead of the invoking user’s.

Why root’s password

The goal is one password to log in and a different one at the sudo prompt. Stock sudo can’t do that: it authenticates the invoking user’s own password, so on an unmodified host the login password and the sudo password are necessarily the same value. Defaults rootpw is the only mechanism that separates them, and it works by pointing every prompt at root’s password. With it in place, a stolen login password stays short of root. Root still cannot log in over SSH, because Step 4 turned that off.

The order matters. If root’s password is locked or unknown when that file lands, every account on the host loses sudo at once, and the way back is the hypervisor console. I used to lock root at this step. That ended on 2026-08-15, when I moved to this model.

How it was rolled out

DateChange
2026-08-15Set known root and admin passwords on twelve guests. Eight had root locked (L) before the run. A play proved each password authenticates through su rather than just checking that a hash landed
2026-08-20Wrote 00-rootpw to eleven guests, one host at a time, aborting the run on the first failure. The play proved root’s password authenticated on each host before writing anything
2026-09-07Removed the last passwordless sudo drop-ins for the human account, on docker-network, monitor-01, media-01 and game-01

The single-host trial on 2026-08-20 caught a real problem. On docker-blue the file was written and the prompt behaved correctly, but the final check failed because sudo -l itself authenticates once rootpw is in force. The fix was to read the policy as root with sudo -l -U <user>, which reports the Defaults sudo actually resolved for that user. That is a better check than “the file exists and parses”. The same habit caught a YAML trap on 2026-08-15: a bare true in [sudo, -n, true] became the string True, and sudo went looking for a command by that name.

Note: Once rootpw is in force, sudo -l is no longer a way to find out where you stand without a password. Use sudo -l -U <user> as root.

Step 6: Time and locale

timedatectl set-timezone America/New_York

I generate en_US.UTF-8 and make it active through the distribution’s locale tools. The zone matters because timestamps should compare across hosts without conversion. A few hosts had drifted to Etc/UTC: ansible-01 was corrected on 2026-09-01, and media-01, docker-blue and docker-main on 2026-09-07.

Automation accounts

Passwordless sudo exists on these hosts for one purpose: unattended automation. Each guest has one dedicated automation account, first created on 2026-07-25 on the Ansible controller and nine guests, with these properties:

  • SSH is key-only. The account has a console-recovery password for the Proxmox console, but SSH rejects password and keyboard-interactive login.
  • Its only authorized key is the controller’s, restricted to connections from the controller’s address, with agent forwarding, port forwarding, X11 forwarding and PTY allocation all disabled.
  • NOPASSWD: ALL comes from its own drop-in, validated with visudo -cf before it is moved into place.
  • It is the route back in if a sudoers change goes wrong, so every play that touches sudo rechecks sudo -n true for this account on every host before it finishes.

Command allowlisting isn’t possible for an Ansible-managed account. Ansible escalates with sudo -u root /bin/sh -c '<token>; python3' and feeds the module on stdin, so any sudoers rule permissive enough for a play to succeed is equivalent to full root. The controls that actually constrain the account are the source restriction on its key, the disabled PTY and forwarding, and an empty group list.

Coolify has the same shape on app-01: its own key-only service account with passwordless sudo, created with the same uid as the Coolify container’s internal user so the ownership of /data/coolify was already correct.

Verification

Checks I run after the build

id <admin>
sudo -n true; echo "expect exit 1: $?"
sudo -l -U <admin>   # as root: expect rootpw among the Defaults and no NOPASSWD
sudo sshd -T | grep -E 'permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication'
ssh-keygen -lf ~<admin>/.ssh/authorized_keys
passwd -S root
timedatectl
locale

The expected state is membership in sudo, non-interactive sudo exiting 1 because a human account is asked for a password, three fingerprints, permitrootlogin no, both password methods disabled, root status P, and a wrong password refused at the sudo prompt.

Fleet verification, 2026-09-07

After the last drop-in came off, I ran one read-only sweep across all eleven guests at 12:23 AM Eastern. Each host reported the number of NOPASSWD grants sudo -l -U resolved per account, whether rootpw was in the admin account’s Defaults, and the contents of /etc/sudoers.d.

CheckResult
Admin account: (ALL : ALL) ALL from the group, rootpw in force, no NOPASSWD11 of 11
Wrong password refused at the admin prompt, journal shows sudo checking root’s password11 of 11 (seven proven on 2026-08-20, four on 2026-09-07)
Automation account: sudo -n true exits 011 of 11
Root: passwd -S reports P11 of 11
Root SSH login off11 of 11
No drop-in in /etc/sudoers.d besides 00-rootpw and the automation account’s11 of 11

The negative proof is the part that matters. On the four hosts that still had a human NOPASSWD drop-in on 2026-08-20, the play recorded the wrong-password test as untestable rather than a pass, because sudo never read the password there. Those four became testable on 2026-09-07 and all four refused.

Independent sweep after the rootpw run, 2026-08-20

CheckResult
/etc/sudoers.d/00-rootpw mode and owner440 root:root on 11 of 11
Defaults rootpw present exactly once11 of 11
visudo -cPasses on 11 of 11
rootpw among the Defaults resolved for the admin account11 of 11
A second run of the playchanged=0

Key state

Key state is verified separately by the SSH identity audit. On 2026-09-07 it covered seventeen Linux hosts and read present for every identity on every host in its allowlist. That audit found that ansible-01’s admin account, created on 2026-08-15, had never received a key file, which I fixed the same night.

How credentials were handled

No password entered a command string, an inventory, a playbook, a log or a repository file. For each run, the values were read out of my password vault into a JSON file written with umask 077 in tmpfs, moved to the controller into a mode-0700 directory, consumed with -e @file, and destroyed afterwards with shred -u -z. Every task that touches a password carries no_log: true. Before each run I confirmed by SHA-256 digest, without printing either value, that the two passwords were present and distinct from each other. If they had been equal, the negative proof would have passed for the wrong reason.

Recovery

If sshd -t fails, I don’t restart SSH; I fix the reported file and line from the console. If the second session can’t connect, I restore the previous drop-in while the first session is still open. For sudo, the automation account’s NOPASSWD grant is the repair route, and the Proxmox console (pct exec for LXCs, qm guest exec or the console for VMs) is the last resort.

Known limits

This baseline covers the human administrative account. Any host that drops the automation account’s NOPASSWD grant has to replace every Ansible step that depends on sudo -n succeeding. docker-main stays outside the model by decision.

Written from the public Homelab repository on GitHub, which records the lab's changes, incidents and current state.