Document cover
Locking Down Coolify Servers With Tailscale: A Step-by-Step Guide
How I moved SSH and the Coolify dashboard off the public internet and onto a private Tailscale network, closed every port except 80 and 443, and verified the result from inside and outside each server. Every step and command, plus two audit scripts you can run on your own servers.

I run a handful of VPS servers managed by a self-hosted Coolify instance. Coolify is great: it deploys my apps, handles TLS, and manages every server over SSH. But when I actually looked at what my servers exposed to the internet, I did not like what I found: SSH was open to the world on every machine, the Coolify dashboard was reachable on a public IP and port, and one server did not even have a firewall enabled.

This post is the process I followed to fix that, written so you can follow it step by step. The idea is simple: only your apps should be public. Everything you use to manage the servers (SSH, the Coolify dashboard) should only be reachable from a private network that you control. I used Tailscale for that private network.

The goal

Before:

    SSH (port 22) open to the internet on every server.

    Coolify dashboard (port 8000) and its realtime ports (6001, 6002) open to the internet.

    Password logins allowed over SSH.

After:

    Only ports 80 and 443 (your apps) are reachable from the internet.

    SSH and the Coolify dashboard only work over Tailscale.

    SSH only accepts keys, never passwords.

    Coolify still manages every server and deploys still work.

The examples below use dummy values. Replace them with your own:

    control: the server running Coolify. Public IP 203.0.113.10, Tailscale IP 100.64.0.10.

    apps-1: a server managed by Coolify. Public IP 203.0.113.20, Tailscale IP 100.64.0.20.

    deploy: your non-root user with sudo.

1. Put every server on your tailnet

Tailscale creates a private, encrypted network (a "tailnet") between your devices. Install it on your laptop and on every server, including the one running Coolify:

curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up --hostname=apps-1 --accept-dns=false tailscale ip -4 # prints the server's 100.x.y.z address

A few notes:

    --hostname is just the name the machine gets inside your tailnet. It does not have to match the server's real hostname. With MagicDNS on, you can reach it as apps-1 or apps-1.<your-tailnet>.ts.net.

    --accept-dns=false keeps the server's normal DNS resolver. Docker containers depend on it, so do not let Tailscale replace it.

    In the Tailscale admin console, disable key expiry for every server. Otherwise, a few months from now, the key expires, the server drops off the tailnet, and once public SSH is closed you are locked out.

Check that the Coolify server can reach the new server privately:

# on control tailscale ping 100.64.0.20

2. Connect Coolify to your servers over Tailscale

Coolify talks to every server over SSH, so it needs a key that the server trusts.

In Coolify, go to Keys & Tokens → Private Keys → New private key → Generate ED25519 (no passphrase) and copy the public key. Then, on the server:

sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh echo "<coolify public key>" | sudo tee -a /root/.ssh/authorized_keys sudo chmod 600 /root/.ssh/authorized_keys

Make sure the key goes into /root/.ssh/authorized_keys if Coolify connects as root. If you add it while logged in as another user, it ends up in that user's file instead.

Now go to Servers → + Add and use the server's Tailscale IP as the address (100.64.0.20), port 22, user root, and the key you just created. Click Validate Server.

If the server was already in Coolify with its public IP, change the IP to the Tailscale one and validate again. Do this before closing any ports, or Coolify loses access.

Two things that tripped me up:

    Use the IP, not the MagicDNS name. Coolify runs inside a Docker container, and with --accept-dns=false it cannot resolve *.ts.net names.

    Tailscale SSH. If you started Tailscale with --ssh, port 22 on the tailnet IP is answered by Tailscale, not OpenSSH, and authorized_keys is ignored. Either keep it and add a tailnet SSH rule with "action": "accept" for the Coolify server, or turn it off with sudo tailscale set --ssh=false and let OpenSSH use Coolify's key. A "check" rule does not work, because it asks for a browser login that Coolify cannot do.

If validation fails, these commands on the Coolify server tell you where it breaks:

tailscale ping 100.64.0.20 # does the tailnet reach it? nc -vz -w 5 100.64.0.20 22 # is port 22 open over the tailnet? nc -w 3 100.64.0.20 22 </dev/null | head -1 # SSH-2.0-OpenSSH... or SSH-2.0-Tailscale? sudo docker exec coolify ls /var/www/html/storage/app/ssh/keys/ sudo docker exec coolify ssh -i /var/www/html/storage/app/ssh/keys/<key-file> \ -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@100.64.0.20 echo OK

If the last command prints OK, Coolify's key works and you can validate again. Permission denied (publickey) means the key is not in root's authorized_keys, or the SSH settings reject it.

3. Turn on the firewall

On each managed server, allow everything over Tailscale, allow web traffic, and deny everything else:

sudo ufw allow in on tailscale0 sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw enable sudo ufw status verbose

Run this from an SSH session over Tailscale (ssh deploy@apps-1), not over the public IP. When ufw enable warns that it may disrupt SSH connections, your session survives because traffic on tailscale0 is allowed.

The server running Coolify needs extra care. Coolify also SSHes into its own host from a Docker network, and a default-deny UFW blocks that. If you enable UFW there, also allow SSH from the Docker networks:

sudo ufw allow from 10.0.0.0/8 to any port 22 proto tcp sudo ufw allow from 172.16.0.0/12 to any port 22 proto tcp

On my Coolify server I skipped UFW entirely and relied on the provider firewall from step 5.

4. Keys only: turn off SSH password logins

Even with port 22 closed to the internet, password logins should be off. Create a small config file:

printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n' \ | sudo tee /etc/ssh/sshd_config.d/00-hardening.conf sudo sshd -t && sudo systemctl reload ssh sudo sshd -T | grep -E '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)'

    The file name starts with 00- on purpose. On Ubuntu, files in sshd_config.d are read in order and the first value wins, and 50-cloud-init.conf often turns password login back on.

    PermitRootLogin prohibit-password lets root log in with a key only, which is what Coolify needs. If root login is already set to no on a server (for example, because Coolify uses Tailscale SSH there), leave that line out.

    Keep your current session open and test a new login in a second terminal before you close it.

Your user password is still needed for sudo. If you need to change it, log in as root and run passwd deploy.

5. The Docker trap: published ports skip UFW

This is the step most guides miss. When a Docker container publishes a port (like 8000:8080), Docker writes its own firewall rules that bypass UFW. After step 3, ufw status showed "deny incoming" on my Coolify server, but the dashboard on port 8000 and the realtime ports 6001/6002 were still reachable from the internet.

The cleanest fix is your cloud provider's firewall, which filters traffic before it reaches the server, so Docker cannot get around it. Most providers offer one for free (Hetzner, DigitalOcean, Contabo, and others). Create one that allows only:

    TCP 80 and 443: your apps.

    UDP 443: HTTP/3 (optional).

    UDP 41641: direct Tailscale connections. Without it Tailscale still works, but goes through slower relays.

Everything else is dropped.

Before attaching it:

    Open the Coolify dashboard over Tailscale instead of the public IP: http://100.64.0.10:8000. Plain HTTP is fine here because Tailscale already encrypts the traffic.

    Make sure you can SSH in over Tailscale.

Then attach the firewall to the server. My first attempt did nothing: I had created the firewall with all the right rules, but it was assigned to zero servers.

After attaching it, check from the server that outbound traffic still works:

tailscale status # peers should still show "active; direct" curl -I https://github.com

If a server runs something that is meant to be public on another port (for example, a peer-to-peer daemon), add a rule for that port, or use a separate firewall for that server.

6. Check from the inside and from the outside

A firewall config is a claim. You want evidence. I wrote two scripts (full code at the end of this post):

    server-audit.sh runs on each server and checks Tailscale, UFW, Docker-published ports, SSH settings, authorized keys, sudo users and automatic updates. On the Coolify server, it also tests that Coolify can still SSH into every server it manages. It changes nothing.

    external-check.sh runs from your laptop and probes the public IPs, to see what the internet actually sees.

Run the audit on every server over Tailscale:

for h in deploy@control deploy@apps-1; do echo "########## $h" scp server-audit.sh "$h":/tmp/ && ssh -t "$h" 'sudo bash /tmp/server-audit.sh' done

Then check from the outside, for both IPv4 and IPv6 (the audit prints each server's public addresses):

PORTS="22 80 443 6001 6002 8000 8080" bash external-check.sh 203.0.113.10 203.0.113.20 PORTS="22 80 443 6001 6002 8000 8080" bash external-check.sh 2001:db8:10::1 2001:db8:20::1

What you want to see on every server:

== 203.0.113.10 == 22 closed 80 open (expected) 443 open (expected) 6001 closed 6002 closed 8000 closed 8080 closed

Don't skip IPv6. Most VPS servers have a public IPv6 address too, and a firewall that only covers IPv4 leaves the door half open.

A note on port 8080: Coolify's Traefik proxy publishes it, so the audit warns about it. On my servers nothing was listening there and the external check showed it closed. Trust the external check.

7. Don't break your deploys

If your apps redeploy automatically when you push to GitHub, GitHub sends a webhook to Coolify. If Coolify was set up without a domain, that webhook goes to http://<public-ip>:8000, which is now closed.

Test it: push a small change and watch the app's Deployments tab in Coolify. If nothing starts, check the GitHub App's Advanced → Recent Deliveries for failed webhooks. You can then:

    give Coolify its own domain (Settings → General → Domain), so it is served through the proxy on 443, and turn on 2FA, since the login page becomes public; or

    keep Coolify private and trigger deploys from CI with Coolify's deploy API, from a CI runner that joins your tailnet; or

    just click Redeploy when you push, if you rarely deploy.

Mine kept working, but this is exactly the kind of thing that breaks silently.

8. Keep a way back in

Once public SSH is closed, Tailscale is your only way in. Prepare for the day it fails:

    Know your provider's console. Every VPS provider has a web console (VNC). It uses a local password login, not SSH, so make sure you know a working password for root or your sudo user on every server, and keep it in your password manager.

    Key expiry off on every server in the Tailscale admin console.

    Back up Coolify. In Coolify, Settings → Backup can send scheduled backups to S3-compatible storage. Also save the APP_KEY from /data/coolify/source/.env. A backup cannot be restored without it.

    Turn on 2FA in Coolify.

    Prune your tailnet. Every device on your tailnet can reach every port on your servers. Remove old phones and laptops, and consider tailnet access rules so only your laptop and your servers can reach them.

One more thing: if SSH ever warns you that a server's host key has changed, do not just delete the old entry. Log in another way (for example, the provider console) and compare the fingerprint first:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

If it matches what SSH showed you, remove the old entry with ssh-keygen -R <host>.

Final checklist


    Every server is on the tailnet, with key expiry disabled.

    Coolify connects to every server through its 100.x Tailscale IP.

    UFW is active on the managed servers: tailnet allowed, 80/443 allowed, everything else denied.

    SSH accepts keys only.

    The provider firewall is attached and allows only 80/443 (plus UDP 41641 for Tailscale).

    The external check shows only 80 and 443 open, on IPv4 and IPv6.

    A push to GitHub still deploys.

    You know how to get in through the provider console, and Coolify is backed up.

Re-run both scripts whenever you add a server or deploy something that opens a port.

Appendix: the scripts

server-audit.sh

Run it on each server with sudo bash server-audit.sh. It is read-only: it reports PASS, WARN or FAIL, with the fix for most findings.

#!/usr/bin/env bash # Read-only security audit for a Coolify + Tailscale server. # Usage: sudo bash server-audit.sh # Optional: ALLOWED_PUBLIC_PORTS="80 443" sudo -E bash server-audit.sh # Changes nothing. Exit code 1 if any FAIL. set -u ((BASH_VERSINFO[0] >= 4)) || { echo "Needs bash 4+"; exit 2; } ALLOWED_PUBLIC_PORTS="${ALLOWED_PUBLIC_PORTS:-80 443}" if [ -t 1 ]; then R=$'\e[31m' Y=$'\e[33m' G=$'\e[32m' B=$'\e[1m' N=$'\e[0m'; else R='' Y='' G='' B='' N=''; fi fails=0 warns=0 pass() { printf '%s[PASS]%s %s\n' "$G" "$N" "$*"; } warn() { printf '%s[WARN]%s %s\n' "$Y" "$N" "$*"; warns=$((warns + 1)); } fail() { printf '%s[FAIL]%s %s\n' "$R" "$N" "$*"; fails=$((fails + 1)); } info() { printf ' %s\n' "$*"; } section() { printf '\n%s== %s ==%s\n' "$B" "$*" "$N"; } have() { command -v "$1" >/dev/null 2>&1; } allowed() { case " $ALLOWED_PUBLIC_PORTS " in *" $1 "*) return 0 ;; esac; return 1; } is_public_addr() { case "$1" in 127.* | ::1 | "[::1]" | 100.* | "[fd7a:115c:a1e0:"* | fd7a:115c:a1e0:* | 10.* | 192.168.* | 172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) return 1 ;; esac return 0 } [ "$(id -u)" -eq 0 ] || { echo "Run as root: sudo bash $0"; exit 2; } ufw_active=0 docker_ok=0 have docker && docker info >/dev/null 2>&1 && docker_ok=1 section "Host" info "hostname: $(hostname) os: $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")" info "public addresses (use these with external-check.sh):" ip -o addr show scope global 2>/dev/null | awk '$2 !~ /^(tailscale|docker|br-|veth|lo)/ {split($4, a, "/"); print " " $2 " " a[1]}' section "Tailscale" if ! have tailscale; then fail "tailscale is not installed" else ts_state='' ts_ip='' ts_name='' ts_expiry='' ts_tags='' if have python3; then { read -r ts_state; read -r ts_ip; read -r ts_name; read -r ts_expiry; read -r ts_tags; } < <( tailscale status --json 2>/dev/null | python3 -c ' import json, sys d = json.load(sys.stdin); s = d.get("Self") or {} print(d.get("BackendState", "")) print((s.get("TailscaleIPs") or [""])[0]) print(s.get("DNSName", "").rstrip(".")) print(s.get("KeyExpiry") or "") print(",".join(s.get("Tags") or []))' 2>/dev/null ) else ts_ip=$(tailscale ip -4 2>/dev/null | head -1) tailscale status >/dev/null 2>&1 && ts_state=Running fi if [ "$ts_state" = Running ]; then pass "tailscale is running: ${ts_name:-?} ($ts_ip)"; else fail "tailscale is not running (state: ${ts_state:-unknown}). Run: sudo tailscale up"; fi [ -n "$ts_tags" ] && info "tags: $ts_tags" if [ -n "$ts_expiry" ]; then warn "tailscale node key expires $ts_expiry. Disable key expiry in the admin console (Machines > ... > Disable key expiry) or this box drops off the tailnet." else pass "tailscale key expiry is disabled" fi if tailscale debug prefs 2>/dev/null | grep -q '"RunSSH": *true'; then info "Tailscale SSH is enabled: port 22 on the tailnet IP is answered by Tailscale, not OpenSSH, and authorized_keys is ignored." info "Coolify then needs a tailnet SSH rule with action \"accept\". Only disable it (sudo tailscale set --ssh=false) if OpenSSH accepts Coolify's key." fi fi section "Firewall (UFW)" if ! have ufw; then fail "ufw is not installed (apt install ufw)" else ufw_v=$(ufw status verbose 2>/dev/null) if grep -q '^Status: active' <<<"$ufw_v"; then pass "ufw is active"; ufw_active=1; else fail "ufw is inactive"; fi if [ "$ufw_active" = 1 ]; then if grep -Eq 'Default: (deny|reject) \(incoming\)' <<<"$ufw_v"; then pass "default incoming policy is deny"; else fail "default incoming policy is not deny (sudo ufw default deny incoming)"; fi if grep -Eq '^IPV6=yes' /etc/default/ufw 2>/dev/null; then pass "ufw also filters IPv6"; else warn "ufw IPv6 filtering is off (/etc/default/ufw IPV6=no) and this server may have a public IPv6"; fi ts_rule=0 declare -A seen_rule=() while IFS= read -r line; do line="${line%%#*}" [[ $line =~ ^(.*[^[:space:]])[[:space:]]+(ALLOW|LIMIT)(\ (IN|FWD))?[[:space:]]+(.*[^[:space:]])[[:space:]]*$ ]] || continue to="${BASH_REMATCH[1]}" from="${BASH_REMATCH[5]}" to="${to// (v6)/}" from="${from// (v6)/}" iface='' if [[ $to == *" on "* ]]; then iface="${to##* on }"; to="${to%% on *}"; fi if [ "$iface" = tailscale0 ]; then ts_rule=1; continue; fi [ -n "${seen_rule[$to|$from|$iface]:-}" ] && continue seen_rule[$to|$from|$iface]=1 if [ "$from" != Anywhere ]; then info "rule: allow $to from $from${iface:+ on $iface}"; continue; fi case "$to" in Anywhere) fail "ufw allows ALL ports from anywhere${iface:+ on $iface}" ; continue ;; OpenSSH) fail "ufw allows SSH (OpenSSH profile) from anywhere. Remove it: sudo ufw delete allow OpenSSH" ; continue ;; "Nginx Full" | "Apache Full" | "WWW Full") continue ;; esac ports="${to%%/*}" ports="${ports##* }" if ! [[ $ports =~ ^[0-9,:]+$ ]]; then warn "ufw allows '$to' from anywhere (app profile, check what it opens)"; continue; fi IFS=',' read -ra toks <<<"$ports" for t in "${toks[@]}"; do lo="${t%%:*}" hi="${t##*:}" if [ "$lo" -le 22 ] && [ "$hi" -ge 22 ]; then fail "ufw allows SSH (port $t) from anywhere. Remove it: sudo ufw delete allow $to" elif [ "$lo" = "$hi" ] && allowed "$lo"; then : else warn "ufw allows port $t from anywhere. Make sure that is intended." fi done done < <(ufw status 2>/dev/null) if [ "$ts_rule" = 1 ]; then pass "ufw allows tailnet traffic on tailscale0"; else warn "no 'allow in on tailscale0' rule, so tailnet SSH may be blocked (sudo ufw allow in on tailscale0)"; fi fi fi section "Host services listening on all interfaces" if have ss; then found=0 while read -r proto addr proc; do port="${addr##*:}" host="${addr%:*}" case "$proc" in *docker-proxy* | *tailscaled*) continue ;; esac case "$host" in 0.0.0.0 | "[::]" | "*" | "[::]%"* | "0.0.0.0%"*) ;; *) is_public_addr "${host%%%*}" || continue ;; esac allowed "$port" && continue found=1 pname=$(sed -E 's/.*\(\("([^"]+)".*/\1/' <<<"$proc") if [ "$ufw_active" = 1 ]; then info "$proto $addr ($pname). Blocked by UFW unless a rule above allows it."; else warn "$proto $addr ($pname) is exposed because ufw is inactive"; fi done < <(ss -H -tulpn 2>/dev/null | awk '{print $1, $5, $7}') [ "$found" = 0 ] && pass "no unexpected host services on public interfaces" fi section "Docker published ports (these bypass UFW)" if [ "$docker_ok" != 1 ]; then info "docker is not running or not installed, skipping" else docker_user_rules=$(iptables -S DOCKER-USER 2>/dev/null | grep -v -e '^-N DOCKER-USER' -e '^-A DOCKER-USER -j RETURN$') [ -n "$docker_user_rules" ] && info "DOCKER-USER chain has custom rules, so some ports may be filtered. Confirm with external-check.sh." exposed=0 declare -A seen_port=() while IFS=$'\t' read -r name ports; do IFS=',' read -ra items <<<"$ports" for it in "${items[@]}"; do it="${it# }" [[ $it == *"->"* ]] || continue hostpart="${it%%->*}" proto="${it##*/}" hport="${hostpart##*:}" hip="${hostpart%:*}" case "$hip" in 0.0.0.0 | "[::]" | :: | "") ;; *) is_public_addr "$hip" || continue ;; esac allowed "$hport" && continue key="$name|$hport|$proto" [ -n "${seen_port[$key]:-}" ] && continue seen_port[$key]=1 exposed=1 case "$name:$hport" in coolify:8000) msg="Coolify dashboard" ;; coolify:6001 | coolify:6002) msg="Coolify realtime" ;; coolify-proxy:8080) msg="Traefik dashboard/API port" ;; *) msg="" ;; esac if [ "$name:$hport" = coolify-proxy:8080 ]; then warn "container '$name' publishes $hport/$proto on $hip ($msg). Traefik usually does not listen there; confirm it shows closed with external-check.sh." elif [ -n "$docker_user_rules" ]; then warn "container '$name' publishes $hport/$proto on $hip${msg:+ ($msg)}. Possibly filtered by DOCKER-USER; verify externally." else fail "container '$name' publishes $hport/$proto on $hip${msg:+ ($msg)}. It is reachable from the internet even though UFW denies it." fi done done < <(docker ps --format '{{.Names}}\t{{.Ports}}') if [ "$exposed" = 0 ]; then pass "no containers publish ports other than: $ALLOWED_PUBLIC_PORTS" else info "Fix: remove the port mapping (route the app through the Coolify proxy with a domain)," info "or block the port in your provider's firewall or the DOCKER-USER iptables chain." fi fi if [ "$docker_ok" = 1 ] && docker ps --format '{{.Names}}' | grep -qx coolify; then section "Coolify control plane: SSH reachability to managed servers" envf=/data/coolify/source/.env dbu=$(sed -n 's/^DB_USERNAME=//p' "$envf" 2>/dev/null | head -1) dbn=$(sed -n 's/^DB_DATABASE=//p' "$envf" 2>/dev/null | head -1) rows=$(docker exec coolify-db psql -U "${dbu:-coolify}" -d "${dbn:-coolify}" -tA -F'|' \ -c 'select name, ip, port, "user" from servers order by id' 2>/dev/null) if [ -z "$rows" ]; then info "could not read the server list from coolify-db, skipping" else while IFS='|' read -r sname sip sport suser; do [ -n "$sip" ] || continue out=$(docker exec coolify ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p "$sport" "$suser@$sip" true 2>&1) case "$out" in "" | *"Permission denied"*) pass "Coolify reaches '$sname' at $suser@$sip:$sport" ;; *) fail "Coolify cannot reach '$sname' at $suser@$sip:$sport: $(tail -1 <<<"$out")" ;; esac case "$sip" in 100.* | host.docker.internal | localhost | 127.* | *.ts.net) ;; *) warn "'$sname' uses $sip, which is not a Tailscale address, so SSH to it must be publicly reachable. Switch it to its 100.x IP." ;; esac done <<<"$rows" fi fi section "SSH daemon" if have sshd; then cfg=$(sshd -T 2>/dev/null) sget() { awk -v k="$1" '$1 == k {print $2; exit}' <<<"$cfg"; } case "$(sget passwordauthentication)" in no) pass "password authentication is disabled" ;; *) fail "password authentication is enabled. Set 'PasswordAuthentication no' in /etc/ssh/sshd_config(.d), then: sudo systemctl reload ssh" ;; esac [ "$(sget kbdinteractiveauthentication)" = yes ] && warn "keyboard-interactive auth is enabled (set KbdInteractiveAuthentication no)" case "$(sget permitrootlogin)" in yes) warn "root can log in with a password. Use 'PermitRootLogin prohibit-password' (Coolify still works with keys)" ;; prohibit-password | without-password) pass "root login is key-only" ;; no) info "root login is disabled. Coolify then needs a non-root user configured for this server" ;; esac [ "$(sget pubkeyauthentication)" = no ] && fail "public key authentication is disabled" else info "sshd not found, skipping" fi section "Authorized SSH keys (check you recognise every one)" for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do [ -f "$f" ] || continue info "$f:" ssh-keygen -lf "$f" 2>/dev/null | sed 's/^/ /' done section "Accounts" extra_root=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd) if [ -n "$extra_root" ]; then fail "non-root accounts with UID 0: $extra_root"; else pass "only root has UID 0"; fi for g in sudo admin wheel docker; do members=$(getent group "$g" 2>/dev/null | cut -d: -f4) [ -n "$members" ] && info "group '$g': $members$([ "$g" = docker ] && echo ' (docker group = root-equivalent)')" done section "Updates" if have apt-get; then if apt-config dump 2>/dev/null | grep -q 'APT::Periodic::Unattended-Upgrade "1"' && systemctl is-enabled unattended-upgrades >/dev/null 2>&1; then pass "unattended security upgrades are enabled" else warn "unattended upgrades are off (sudo apt install unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades)" fi sec=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst .*-security') if [ "$sec" -gt 0 ]; then warn "$sec security updates pending (sudo apt update && sudo apt upgrade)"; else pass "no pending security updates (as of the last apt update)"; fi fi [ -f /var/run/reboot-required ] && warn "a reboot is required to finish applying updates" section "Summary for $(hostname)" printf '%s%d FAIL%s, %s%d WARN%s\n' "$R" "$fails" "$N" "$Y" "$warns" "$N" [ "$fails" -eq 0 ]

external-check.sh

Run it from your laptop with your servers' public IPs: bash external-check.sh 203.0.113.10 2001:db8:10::1.

#!/usr/bin/env bash # Probe servers' PUBLIC addresses from outside to see which ports the internet can reach. # Usage: bash external-check.sh <public-ip> [<public-ip> ...] # Optional: PORTS="22 80 443 8000" ALLOWED_PUBLIC_PORTS="80 443" bash external-check.sh ... # Run from a machine that is NOT one of the servers (e.g. your laptop). Do not use a Tailscale exit node while running it. set -u PORTS="${PORTS:-22 80 443 3000 3306 5432 6001 6002 6379 8000 8080 8443 9000 27017}" ALLOWED_PUBLIC_PORTS="${ALLOWED_PUBLIC_PORTS:-80 443}" [ $# -gt 0 ] || { echo "Usage: bash $0 <public-ip> [<public-ip> ...]"; exit 2; } if [ -t 1 ]; then R=$'\e[31m' G=$'\e[32m' B=$'\e[1m' N=$'\e[0m'; else R='' G='' B='' N=''; fi probe() { if [ "$(uname)" = Darwin ]; then nc -z -G 3 -w 3 "$1" "$2" >/dev/null 2>&1; else nc -z -w 3 "$1" "$2" >/dev/null 2>&1; fi } bad=0 for host in "$@"; do case "$host" in 100.*) echo "$host is a Tailscale IP; pass the server's PUBLIC IP instead."; continue ;; esac printf '\n%s== %s ==%s\n' "$B" "$host" "$N" for p in $PORTS; do if probe "$host" "$p"; then case " $ALLOWED_PUBLIC_PORTS " in *" $p "*) printf ' %-6s open (expected)\n' "$p" ;; *) printf ' %-6s %sOPEN <- reachable from the internet%s\n' "$p" "$R" "$N"; bad=1 ;; esac else printf ' %-6s %sclosed%s\n' "$p" "$G" "$N" fi done done [ "$bad" -eq 0 ]

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime