Repository navigation
Concepts
The ideas behind server-prep, in simple words: what each protection does, why it is there, and how to check it on your server.
Each section says what the idea is, what server-prep does, and gives one read-only command to check it. Words in italics are in the Glossary.
SSH is how you open a terminal on the server from your own computer. You can log in with a password or with an SSH key. A key is a pair of files: the private key stays on your computer, the public key goes on the server. Bots try millions of passwords on servers all day. They cannot guess a key.
server-prep does two things in the users and ssh steps:
- it adds your public key (
ADMIN_PUBKEY) to your admin account; - it turns password login off (
PasswordAuthentication no,KbdInteractiveAuthentication no,AuthenticationMethods publickey).
Only the users listed in AllowUsers can log in. Add your key before the SSH step: without it, you can only log in from the console afterwards.
sudo sshd -T | grep -E '^(passwordauthentication|pubkeyauthentication|allowusers)'You should see passwordauthentication no, pubkeyauthentication yes and your user name after allowusers.
SSH usually listens on port 22, the first port every scanner tries. server-prep moves it to SSH_PORT, 822 by default. Ports below 1024 can only be opened by root, so a normal program cannot take the SSH port. The audit passes any port that is not 22.
Moving SSH could cut you off, so the ssh step works in two phases:
- Phase A. sshd listens on the old port and the new port. The script starts a timer: in 10 minutes, the old SSH config comes back by itself.
- You open a new terminal and test
ssh -p 822 <user>@<server>. You answery(it works),n(roll back now) ors(skip, I have console access). -
Phase B. After
yors, the timer stops, sshd listens only on the new port, and the temporary firewall rule for the old port is removed.
Known limit: with --yes, the script does not wait for your test, so the timer stops right away. A fix is planned in issue #65. Until then, change SSH interactively, or have console access.
Once SSH has moved, the endlessh step can put a tarpit on port 22: bots that still knock there get stuck on a very slow fake SSH banner.
sudo sshd -T | grep '^port'You should see one line, port 822 (or your SSH_PORT). Two lines mean the old port is still open.
A firewall decides which connections can reach the server. server-prep uses UFW, the simple firewall of Ubuntu and Debian. The firewall step sets:
- incoming: deny everything, unless a rule allows it;
- outgoing: allow, so the server can download updates and images;
-
SSH only from your
ADMIN_IPS, from the tailnet (tailscale0) and from the panel IP, depending onPANEL_ACCESS. With no source at all, SSH is open to everyone but rate-limited; -
80/443 only when
EXPOSE_WEB=yes, and anyEXTRA_PORTSyou list.
sudo ufw status verboseYou should see Default: deny (incoming), allow (outgoing) and one line per allowed port and source.
This is the surprise that bites most people. When a container publishes a port (for example -p 5432:5432), Docker writes its own firewall rules. Those rules are checked before UFW's. So ufw status says the port is closed, but the internet can still reach the database.
server-prep installs ufw-docker, a small script that adds rules to the DOCKER-USER chain. That chain is the place Docker leaves for your own rules. After that, traffic to containers goes through UFW too. Docker networking between containers is not changed. The script is pinned to one version and its checksum is checked before it runs.
sudo iptables -S DOCKER-USERYou should see a line with -j ufw-user-forward. If it is missing, the audit shows a FAIL on "Docker vs firewall": re-run the firewall step (see FAQ).
Both watch the logs and block addresses that attack the server. They work in different ways, and server-prep can run both (the ips step).
| fail2ban | CrowdSec | |
|---|---|---|
| How it decides | counts failed logins on this server | uses its own detections plus a shared list of known bad IPs from other users |
| What blocks | its own firewall rules | a separate bouncer (the firewall bouncer) |
| server-prep settings |
sshd jail on your SSH port, ban 1 hour, up to 1 week for repeat offenders |
collections linux and sshd, bouncer on INPUT and DOCKER-USER, LAPI moved to port 8091 |
Your admin IPs, the panel IP, the tailnet and localhost are on the allow list of both, so you don't ban yourself. CrowdSec's local API usually uses port 8080, which the panels' Traefik dashboard also wants. That is why server-prep moves it (CROWDSEC_LAPI_PORT=8091).
sudo fail2ban-client status sshdYou should see the jail with a count of failed and banned addresses.
Tailscale links your machines in a private network called a tailnet. Each machine gets an address in 100.64.0.0/10, which tailscale ip -4 prints. The traffic is encrypted, and it works behind a home or office router with no open port.
server-preconfig.sh installs Tailscale and logs the server in. With PANEL_ACCESS=tailscale, server-prep lets SSH in on the tailscale0 interface and allows the panel's root login from tailnet addresses. The panel server must be in the same tailnet.
Turn off key expiry for the server in the Tailscale admin console. If you don't, access stops on the expiry date.
tailscale ip -4You should see the server's tailnet address, starting with 100..
A Cloudflare Tunnel publishes your apps without opening any port. A small program, cloudflared, connects out from the server to Cloudflare. Visitors reach Cloudflare, and Cloudflare sends the traffic down the tunnel to http://localhost:80, the panel's proxy. Your server's IP stays hidden and 80/443 stay closed (EXPOSE_WEB=no).
With Coolify, run cloudflared as a Coolify service, not with the server-level "Cloudflare Tunnel" option: that option moves Coolify's own SSH to the tunnel and Coolify can lose the server. The full steps, with Zero Trust login in front of admin tools, are in docs/cloudflare-tunnel.md.
Panels like Coolify and Dokploy manage the server over SSH as root. server-prep allows that, but in a narrow way:
-
PermitRootLogin nofor everyone; - a Match Address block that says
PermitRootLogin prohibit-password(keys only) only for the panel's sources; - the panel's public key in
/root/.ssh/authorized_keys, with afrom="..."option that lists the same sources.
The sources come from PANEL_ACCESS:
PANEL_ACCESS |
Root allowed from |
|---|---|
cloudflared |
127.0.0.1 and ::1 (the tunnel arrives on the server itself) |
tailscale |
100.64.0.0/10 and fd7a:115c:a1e0::/48
|
public |
PANEL_IP, the panel server's public IP |
From any other address, root is refused even with the right key. You log in with your own admin account and use sudo.
sudo sshd -T -C user=root,host=test,addr=203.0.113.7 | grep permitrootloginYou should see permitrootlogin no: a random internet address cannot log in as root.
The unattended step turns on unattended-upgrades: every day, the server installs security updates by itself. With DOCKER_AUTO_UPDATE=yes, Docker CE updates are included. With THIRD_PARTY_AUTO_UPDATE=yes (the default), so are CrowdSec, cloudflared, Telegraf and Tailscale: they come from their own repositories, which automatic updates skip otherwise, so their security fixes would wait for you. Each update restarts that tool for a moment: SSH over Tailscale pauses a few seconds. Any other package installed from these repositories, like an InfluxDB server or a CrowdSec bouncer, is upgraded too. Old kernels and unused packages are removed.
Some updates (like a new kernel) need a reboot. AUTO_REBOOT=yes reboots by itself at AUTO_REBOOT_TIME (04:00). It is no by default, because a reboot restarts your apps.
systemctl status unattended-upgrades --no-pagerYou should see active (running) and enabled.
These three tools don't block anything. They help you see what happened.
-
auditd writes a log entry when someone changes a sensitive file: users and passwords, sudoers, SSH config,
/root/.ssh, Docker config, cron, the firewall. It also logs commands that admins run as root. Search by key, for examplesudo ausearch -k sshd. - rkhunter looks for known rootkits (hidden malware) and changed system programs, every day.
-
AIDE takes a fingerprint of the system files once (the baseline), then checks every day which files changed. Fast-changing folders like
/var/lib/dockerand/data/coolifyare left out. The first baseline is built in the background and takes a few minutes.
sudo auditctl -lYou should see the rules, each ending with a key such as -k identity or -k sshd.
server-audit.sh has 59 checks (some run only where they apply) and gives each one a status:
- PASS: good.
- WARN: worth a look, not always a problem.
- FAIL: a real security gap. The script exits with code 1 when there is at least one FAIL.
- INFO: a fact, not scored.
The score is (PASS × 100 + WARN × 50) / (PASS + WARN + FAIL), so 100 means all PASS. A prepared server usually has about 43 PASS and 0 FAIL. Some WARNs are normal: many services on a desktop (GNOME), regular updates waiting, or a reboot pending after a kernel update. Aim for 0 FAIL, not for 100.
sudo ./server-audit.shThe last line shows the score, for example Score: 92/100 - 43 PASS, 8 WARN, 0 FAIL, 4 INFO. Each WARN and FAIL line shows how to fix it.
Glossary for the terms, FAQ for lockouts, re-runs and undo, and the setup console to see the ports and logins for your setup.
Setup console · Brand · Discussions · Issues · Roadmap · MIT licence · Edit these pages through a pull request on wiki/
Get started
Understand
Run it
Project