Skip to content

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:

systemctl restart ssh

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:

unattended-upgrades --dry-run --debug | tail -20

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:

  1. Hetzner Cloud Firewall → see firewall.md.
  2. 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.

apt -y install fail2ban
# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
backend  = systemd

[sshd]
enabled = true
port    = 22
systemctl enable --now fail2ban
fail2ban-client status sshd

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-upgradesenabled
  • ufwStatus: active, allow rules on 22 / 80 / 443
  • fail2banStatus for the jail: sshd with Currently banned: 0
  • hostnamedev.grintest.pl (or uat.grintest.pl, etc.)
  • swap → 2 GB swapfile
  • ssh hardeningpermitrootlogin no, passwordauthentication no
  • disk → 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:

  1. cloud-init snippet for new VMs — applied at first boot. Covers admin user creation and the SSH hardening drop-in. The fastest win.
  2. Ansible playbook for the rest — unattended-upgrades config, ufw, fail2ban, hostname, banner, swap, cron. Idempotent so it re-runs safely on existing VMs.
  3. Terraform for the infrastructure layer (network, firewall, VM itself) — see firewall.md and network.md.

Until those exist, this page IS the playbook — bookmark it before provisioning a new VM.