Securing a Debian Server From Default Install to Production-Ready

Problem

A small business had its server exposed to the internet with barely any protection. The system was running with defaults and no firewall was configured. Small companies who don't have a sysadmin have such an common setup like this, which leaves them wide open to attackers.

Did you know: Port 22 is one of the most scanned ports on the internet.

I set up a Debian 13 "trixie" VM for this example on VirtualBox with SSH enabled. After install, this is what I saw:

lain@navi:~$ ss -tuln
Netid       State       Recv-Q      Send-Q          Local Address:Port          Peer Address:Port
udp         UNCONN      0           0               0.0.0.0:5353                0.0.0.0:*
udp         UNCONN      0           0               0.0.0.0:44410               0.0.0.0:*
udp         UNCONN      0           0               [::]:57048                  [::]:*
udp         UNCONN      0           0               [::]:5353                   [::]:*
tcp         LISTEN      0           4096            127.0.0.1:631               0.0.0.0:*
tcp         LISTEN      0           128             0.0.0.0:22                  0.0.0.0:*
tcp         LISTEN      0           128             [::]:22                     [::]:*
tcp         LISTEN      0           4096            [::1]:631                   [::]:*
lain@navi:~$ grep -E '^(PermitRootLogin|PasswordAuthentication)' /etc/ssh/sshd_config
lain@navi:~$ sudo iptables -L -n
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
Chain FORWARD (policy ACCEPT)
target     prot opt source               destination
Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
lain@navi:~$ systemctl status unattended-upgrades
Unit unattended-upgrades.service could not be found.

Explanation: Respectively, command prompts hint that SSH is wide open on port 22, default config allowing root login (it's commented by default), no firewall rules exist, and unattended updates disabled.

Solution

We'll just need to do some basic hardening with a few simple steps:

  1. Moved SSH to a non-standard port (2222), restarting and testing SSH with ssh -p 2222 lain@navi
  2. Disabled root SSH login in /etc/ssh/sshd_config
  3. Enabled a firewall with UFW
  4. Installed and enabled security updates

So now, we get these outputs using the same commands we've tried before:

lain@navi:~$ ss -tuln
Netid   State    Recv-Q   Send-Q     Local Address:Port      Peer Address:Port  
udp     UNCONN   0        0                0.0.0.0:40623          0.0.0.0:*     
udp     UNCONN   0        0                0.0.0.0:5353           0.0.0.0:*     
udp     UNCONN   0        0                   [::]:36504             [::]:*     
udp     UNCONN   0        0                   [::]:5353              [::]:*     
tcp     LISTEN   0        128              0.0.0.0:2222           0.0.0.0:*     
tcp     LISTEN   0        4096           127.0.0.1:631            0.0.0.0:*     
tcp     LISTEN   0        128                 [::]:2222              [::]:*     
tcp     LISTEN   0        4096               [::1]:631               [::]:*     
lain@navi:~$ grep -E '^(PermitRootLogin|PasswordAuthentication)' /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication yes
lain@navi:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
2222/tcp                   ALLOW IN    Anywhere                  
2222/tcp (v6)              ALLOW IN    Anywhere (v6)             

lain@navi:~$ systemctl status unattended-upgrades
 unattended-upgrades.service - Unattended Upgrades Shutdown
     Loaded: loaded (/usr/lib/systemd/system/unattended-upgrades.service; enabled; preset: enabled)
     Active: active (running) since Thu 2025-09-04 12:31:43 CET; 1min 50s ago
 Invocation: f94b8a91656541c0a6c91892b20ff6f0
       Docs: man:unattended-upgrade(8)
   Main PID: 927 (unattended-upgr)
      Tasks: 2 (limit: 2297)
     Memory: 27.2M (peak: 29.2M)
        CPU: 361ms
     CGroup: /system.slice/unattended-upgrades.service
             └─927 /usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal

Warning: some journal files were not opened due to insufficient permissions.

We can also add some monitoring to know when things go down. For that we'll deploy my own tiny Lua-based tool servemon.

We start our HTTP server and add it to that servemon:

python3 -m http.server 8080
luajit servemon.lua add localweb http://127.0.0.1:8080

Then we run the monitor once and output when it was up/down:

lain@navi:~/servemon$ luajit servemon.lua add localweb http://127.0.0.1:8080
Site added:	localweb	http://127.0.0.1:8080
lain@navi:~/servemon$ luajit servemon.lua run --once
2025-09-05 12:23:09 | localweb | http://127.0.0.1:8080 | OK
2025-09-05 12:23:09 | localweb | http://127.0.0.1:8080 | OK
2025-09-05 12:23:09 | localweb | http://127.0.0.1:8080 | OK
lain@navi:~/servemon$ luajit servemon.lua run --once
2025-09-05 12:23:44 | localweb | http://127.0.0.1:8080 | DOWN
2025-09-05 12:23:44 | localweb | http://127.0.0.1:8080 | DOWN
2025-09-05 12:23:44 | localweb | http://127.0.0.1:8080 | DOWN
lain@navi:~/servemon$ luajit servemon.lua summary
=== Server Summary Report ===
2025-09-05 12:24:10 | localweb | http://127.0.0.1:8080 | DOWN
DOWN - localweb (http://127.0.0.1:8080) - Uptime: 65.5%
2025-09-05 12:24:10 | localweb | http://127.0.0.1:8080 | DOWN
DOWN - localweb (http://127.0.0.1:8080) - Uptime: 63.3%
2025-09-05 12:24:10 | localweb | http://127.0.0.1:8080 | DOWN
DOWN - localweb (http://127.0.0.1:8080) - Uptime: 61.3%

servemon logs results into SQLite (monitor.db) and can send alerts via Telegram or ntfy.sh if desired.