Host baseline¶
Every VM in the GrinSystem fleet (grintest-dev-01, future grintest-uat-01,
future prod VMs) gets the same host-level configuration before any
application services land on it. This page is the source of truth for that
baseline.
Originally applied manually on grintest-dev-01 during
GRI-121.
Will migrate to cloud-init / Ansible when the second VM is provisioned.
What the baseline covers¶
| Concern | Mechanism |
|---|---|
| Admin login | Named non-root user with sudo NOPASSWD and SSH key |
| SSH hardening | Root login disabled, password auth disabled, key-only |
| Security patches | unattended-upgrades with automatic reboot |
| Host firewall | ufw default-deny, mirroring the cloud firewall |
| Brute-force protection | fail2ban on the SSH jail |
| Identity | FQDN hostname matching DNS; login banner |
| Memory headroom | Swap file with low swappiness |
| Disk hygiene | Weekly Docker prune cron |
Admin user¶
Each VM has exactly one human admin user. No shared accounts. For the dev
VM that's filip. For uat and prod, additional named users get added as
the team grows.
adduser filip --gecos "" --disabled-password
usermod -aG sudo filip
# Passwordless sudo
echo "filip ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/filip
chmod 440 /etc/sudoers.d/filip
# SSH key (copied from /root/.ssh/authorized_keys at provisioning time;
# subsequent keys added via the Hetzner Cloud Console + cloud-init)
mkdir -p /home/filip/.ssh
cp /root/.ssh/authorized_keys /home/filip/.ssh/authorized_keys
chown -R filip:filip /home/filip/.ssh
chmod 700 /home/filip/.ssh
chmod 600 /home/filip/.ssh/authorized_keys
--disabled-password means the user has no Unix password — only SSH
key auth can log them in. sudo works because of the NOPASSWD line.
For service accounts (currently the Coolify Cloud service user),
the pattern is similar but uses a --system user (UID < 1000, no
interactive expectations). The Coolify account also has NOPASSWD:ALL
because Coolify Cloud's bootstrap and update flow require broad sudo —
see coolify-user.md § Why NOPASSWD:ALL
for the trade-off and compensating controls.
SSH hardening¶
The default Ubuntu sshd config is relaxed (allows root, allows passwords). We override it via a drop-in file:
# /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
# Banner shown before login
Banner /etc/issue.net
After dropping the file in place:
Verify with:
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractive'
# permitrootlogin no
# passwordauthentication no
# kbdinteractiveauthentication no
Automatic security updates¶
unattended-upgrades applies security patches and reboots automatically
outside business hours.
apt -y install unattended-upgrades apt-listchanges
dpkg-reconfigure -plow unattended-upgrades # answer Yes
Local override:
# /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Reboot time 03:30 (server-local time) is outside any expected work
window. Sanity check the config:
Host firewall — ufw¶
ufw is the kernel-level mirror of the
Hetzner Cloud Firewall. Same allow-list, so a single
misconfiguration at either layer does not expose the VM.
apt -y install ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status verbose # expected: Status: active, three allow rules
When new public ports are needed (e.g., a future external metrics endpoint), they must be added in both layers:
- Hetzner Cloud Firewall → see
firewall.md. - Host
ufw→ above.
SSH brute-force protection — fail2ban¶
fail2ban watches journald for failed SSH attempts and bans the source
IP for an hour after 5 failures within 10 minutes.
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = 22
This is meaningful because SSH (22) is currently open to the world — see
the deviation note in firewall.md.
When Tailscale lands and 22 closes at the cloud-firewall level, the
fail2ban jail becomes redundant but harmless.
Hostname + login banner¶
The hostname matches DNS (dev.grintest.pl, future uat.grintest.pl), so
shell prompts, logs, and hostname -f all agree with the address an
operator typed to connect.
hostnamectl set-hostname dev.grintest.pl
sed -i 's/127.0.1.1.*/127.0.1.1 dev.grintest.pl dev/' /etc/hosts
Login banner shown before the password / key prompt:
# /etc/issue.net
**************************************************************
GrinSystem dev environment — authorized access only.
All activity is logged and monitored.
**************************************************************
Referenced from sshd_config via Banner /etc/issue.net (set in the
hardening drop-in above).
Swap¶
Small VMs (CX-line, 4 GB or less) get a swap file as headroom. Larger VMs may skip this.
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
# Reduce swappiness so swap stays mostly idle
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
sysctl --system
swappiness=10 biases the kernel toward keeping pages in RAM and only
swapping under real pressure.
Disk hygiene — Docker prune¶
When Coolify and its containers land, image and volume churn can fill
the disk (40 GB on CX23). A weekly prune handles it:
# /etc/cron.d/docker-prune
0 4 * * 0 root command -v docker >/dev/null 2>&1 && docker system prune -af --volumes >/var/log/docker-prune.log 2>&1
The command -v docker guard means the cron is a safe no-op until
Docker exists on the VM.
Verification recipe¶
A single command that prints every baseline check at once:
echo "--- unattended-upgrades ---" ; systemctl is-enabled unattended-upgrades
echo "--- ufw ---" ; sudo ufw status verbose | head -10
echo "--- fail2ban ---" ; sudo fail2ban-client status sshd | head -10
echo "--- hostname ---" ; hostname -f
echo "--- swap ---" ; swapon --show
echo "--- ssh hardening ---" ; sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication'
echo "--- disk ---" ; df -h /
Expected output:
unattended-upgrades→enabledufw→Status: active, allow rules on 22 / 80 / 443fail2ban→Status for the jail: sshdwithCurrently banned: 0hostname→dev.grintest.pl(oruat.grintest.pl, etc.)swap→ 2 GB swapfilessh hardening→permitrootlogin no,passwordauthentication nodisk→ plenty of free space
Codification roadmap¶
For now this page is the source of truth and the steps above are applied manually. The intent is to migrate them in stages:
- cloud-init snippet for new VMs — applied at first boot. Covers admin user creation and the SSH hardening drop-in. The fastest win.
- Ansible playbook for the rest —
unattended-upgradesconfig,ufw,fail2ban, hostname, banner, swap, cron. Idempotent so it re-runs safely on existing VMs. - Terraform for the infrastructure layer (network, firewall, VM
itself) — see
firewall.mdandnetwork.md.
Until those exist, this page IS the playbook — bookmark it before provisioning a new VM.