Docs/ Security/ Securing your sh0 server

Securing your sh0 server

The same advice wherever your server is hosted: no provider-specific step, only what runs on the machine itself.

Overview

sh0 secures what it serves (HTTPS, encrypted secrets, roles), but the server under it is yours. Most compromised servers are not broken through the application: they are entered over SSH with a guessed password, or through a port nobody meant to expose. This guide closes those doors, in the order that never locks you out. sh0 checks the same points for you, live, on the Server security page of the dashboard and with sudo sh0 doctor.

Before you start

Keep your current SSH session open until the end, and test every change from a NEW session. From your computer, check that your key opens a session without a password:

bash
ssh -o PasswordAuthentication=no root@SERVER_IP

If that session asks for a password, you have no key on the server yet. Install one, then test again:

bash
ssh-copy-id root@SERVER_IP

SSH: keys only

A server that accepts SSH passwords can be guessed, and root is the first account tried. Once your key works, refuse passwords:

bash
cat > /etc/ssh/sshd_config.d/00-sh0-hardening.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
EOF
Note
The file name starts with 00- on purpose: sshd keeps the FIRST value it reads, and many images ship a 50-cloud-init.conf that turns passwords back on. A file named 99-… would be silently ignored.

Validate, then reload. Never restart sshd on a file that sshd -t rejects; a reload keeps the open sessions alive. The service is named ssh on Debian and Ubuntu, sshd elsewhere.

bash
sshd -t && systemctl reload ssh

Check the effective value for root from an outside address, then open a new session with your key before closing the current one:

bash
sshd -T -C user=root,host=check.invalid,addr=203.0.113.1 | grep -Ei '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin) '

Firewall, and the Docker trap

Allow SSH, HTTP, HTTPS and the panel port, then enable ufw. If SSH listens on a port other than 22, allow that port first.

bash
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443
ufw allow 9000/tcp
ufw --force enable

Port 9000 stays open as long as the *.sh0.app addresses reach your server directly. Close it (sh0 serve --bind 127.0.0.1) only once the panel is served on its own domain over HTTPS.

Warning
ufw does not see the ports published by Docker: Docker writes its own iptables rules ahead of ufw’s, so a database published on 5432 stays reachable from the Internet with ufw active. Docker keeps one chain for the administrator, DOCKER-USER. The rule below lets through, from the public interface, only replies to connections the server opened, and a small service reapplies it at every Docker start:
bash
cat > /usr/local/sbin/sh0-docker-user <<'EOF'
#!/bin/sh
# Docker-published ports: from the public interface, only replies get through.
IF=$(ip -o route get 1.1.1.1 | sed -n 's/.* dev \([^ ]*\).*/\1/p')
[ -n "$IF" ] || exit 1
iptables -C DOCKER-USER -i "$IF" -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN 2>/dev/null ||
  iptables -I DOCKER-USER 1 -i "$IF" -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -C DOCKER-USER -i "$IF" -j DROP 2>/dev/null ||
  iptables -I DOCKER-USER 2 -i "$IF" -j DROP
EOF
chmod 755 /usr/local/sbin/sh0-docker-user
cat > /etc/systemd/system/sh0-docker-user.service <<'EOF'
[Unit]
Description=sh0: filter Docker-published ports (DOCKER-USER)
After=docker.service
Requires=docker.service
PartOf=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/sh0-docker-user

[Install]
WantedBy=docker.service
EOF
systemctl daemon-reload
systemctl enable --now sh0-docker-user
iptables -S DOCKER-USER

To keep one published port reachable, add an exception before the DROP rule in /usr/local/sbin/sh0-docker-user:

bash
iptables -I DOCKER-USER 2 -i "$IF" -p tcp -m conntrack --ctorigdstport 5432 -j RETURN

If your provider offers a network firewall in front of the server, whichever provider it is, use it as well: allow 22, 80, 443 (and 9000 while you rely on *.sh0.app), deny the rest. It filters before the traffic reaches the machine, Docker included.

fail2ban

With keys only, guessing attempts are harmless, but they fill the logs. fail2ban bans the addresses that keep trying:

bash
apt-get install -y fail2ban python3-systemd
cat > /etc/fail2ban/jail.d/sh0-sshd.local <<'EOF'
[sshd]
enabled = true
backend = systemd
journalmatch = _COMM=sshd + _COMM=sshd-session
maxretry = 5
findtime = 10m
bantime = 1h
EOF
systemctl enable fail2ban
systemctl restart fail2ban
fail2ban-client status sshd

Updates and reboots

Install security updates, and let the system install them automatically:

bash
apt-get update && apt-get upgrade
apt-get install -y unattended-upgrades && dpkg-reconfigure -plow unattended-upgrades

When the kernel or a core library is updated, the fix only applies after a reboot (/var/run/reboot-required exists). Plan one when traffic is low: sh0 starts again with the system.

The panel over HTTPS

Without a domain of its own, the panel is served over plain HTTP and the owner password crosses the network unencrypted. Point a DNS A record at the server, then:

bash
sh0 panel-domain set panel.example.com

Let the installer do it

On a new server, the installer applies the SSH, firewall and fail2ban steps above when asked to. It is off by default; in a terminal, the installer also asks the question (no answer within 30 seconds means no).

bash
curl -fsSL https://get.sh0.dev | bash -s -- --harden
Note
The SSH step only runs if an authorized key is found for the account you are connected with; otherwise it is refused, with the reason, and the other steps still run. sshd -t validates the file before any reload, and sshd is never restarted.

Check the result

The Server security page of the dashboard (owner and admin) shows these checks live, with the fix under each warning; a dot in the navigation signals an active warning. From a terminal on the server:

bash
sudo sh0 doctor