Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Intermediate

How Secure Is My Server? A Self-Audit With Lynis

Lynis 3.1.7 on Debian 13: audit your server, understand the hardening index, fix real findings – and spot the suggestions that would wreck your Docker host.

· 17 min read ·Duration: approx. 60 minutes
Table of contents

You have hardened SSH, set up a firewall, got fail2ban running – and still have no idea what you missed. Lynis answers exactly that question: it walks through your server from the inside and tells you what is still open. The harder part comes afterwards, namely deciding which of its suggestions you actually want to follow.

What are we building?

We install Lynis 3.1.7 on a Debian 13 box and run a full audit. From the result – on a fresh netcup server with Docker initially hardening index 64, one warning and 44 suggestions – we work through the points that genuinely help and deliberately leave the rest alone. At the end we have a reproducible audit run driven by a systemd timer, a documented exception list and an index of 77.

What we do not do matters just as much: one of the suggestions instantly cuts every container off the network, a second plants a time bomb that goes off with your next new stack, and a third complains about a setting that is already stricter than required. Lynis does not know your server – you do.

Requirements

  • A hardened server running Debian 13 with root or sudo access
  • Basic familiarity with systemd units and timers – for the automation at the end
  • About an hour; the audit itself only takes one to two minutes
  • Optional but handy: unattended-upgrades is already in place – then one of the findings disappears on its own
🍳 Recommendation Ad

VPS 1000 G12.5

4 vCores · 8 GB RAM · 128 GB SSD

from €14.50/month

Lynis runs on the smallest of servers – a VPS 1000 easily handles the audit and a Docker stack.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 1000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

Step by step

Step 1: Install Lynis from the CISOfy repository

Debian ships Lynis, but an older release:

Terminal
apt-cache policy lynis
Ausgabe
lynis:
  Installed: (none)
  Candidate: 3.1.4-1

That is not cosmetic. Lynis consists almost entirely of tests, and those age faster than the program around them – new kernel parameters, new services, new defaults. An audit with an old test collection misses exactly the things that were recently added. So we use the repository from CISOfy, the Lynis developers.

We store the signing key as an ASCII-armored file (.asc). That is the trick that saves you from installing gnupg – apt reads plain-text keys directly when the filename ends in .asc:

Terminal
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://packages.cisofy.com/keys/cisofy-software-public.key \
  -o /etc/apt/keyrings/cisofy.asc

Check briefly that you actually received a key and not an error page:

Terminal
head -1 /etc/apt/keyrings/cisofy.asc
Ausgabe
-----BEGIN PGP PUBLIC KEY BLOCK-----

Now add the package source. The signed-by binds the source to exactly this key – CISOfy can only sign Lynis packages with it, not replace half your system:

Terminal
echo "deb [signed-by=/etc/apt/keyrings/cisofy.asc] https://packages.cisofy.com/community/lynis/deb/ stable main" \
  > /etc/apt/sources.list.d/cisofy-lynis.list
apt-get update
apt-get install -y lynis
Terminal
lynis --version
Ausgabe
3.1.7

Note

If apt-cache policy lynis still only offers 3.1.4, apt-get update did not pick up the new source – usually because of a typo in the filename. The list must end in .list and live in /etc/apt/sources.list.d/.

Step 2: The first audit run

Lynis needs no daemon and no configuration. One invocation is enough:

Terminal
lynis audit system --quick --no-colors | tee /root/lynis-run1.txt

--quick skips the pauses where Lynis would otherwise wait for a keypress – otherwise identical to a normal run. --no-colors makes the output copyable, and tee keeps it so you can compare later.

After a minute or two the summary appears at the end:

Ausgabe
  Details:
  Hardening index : 64 [############        ]
  Tests performed : 251
  Plugins enabled : 0

Above it are the actual results, split into two categories:

Ausgabe
  Warnings (1):
  ----------------------------
  ! Found one or more vulnerable packages. [PKGS-7392]

  Suggestions (44):

The difference matters. A warning is a state Lynis considers wrong right now – an unpatched package, a service not doing what it should. A suggestion is a recommendation that may or may not make sense for your use case. You always look at warnings; you triage suggestions.

The hardening index is the most widely misunderstood number here. It is not a percentage of “security” but a plain point ratio: every test awards earned and possible points, and at the end they are divided. A server without a web server earns neither points nor maximum for the web server tests – so a server that does run one can score lower with the same amount of care. Only compare the index against your own past runs, never against someone else’s.

Step 3: Read the report, not the screen output

The console output is the worst of the three information sources. Ten lines of “Consider hardening SSH configuration [SSH-7408]” in a row do not tell you which ten options are meant. Those are in the machine-readable report:

Terminal
grep "^suggestion\[\]=SSH-7408" /var/log/lynis-report.dat
Ausgabe
suggestion[]=SSH-7408|Consider hardening SSH configuration|AllowTcpForwarding (set YES to NO)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|ClientAliveCountMax (set 3 to 2)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|LogLevel (set INFO to VERBOSE)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|MaxAuthTries (set 6 to 3)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|MaxSessions (set 10 to 2)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|PermitRootLogin (set YES to (FORCED-COMMANDS-ONLY|NO|PROHIBIT-PASSWORD|WITHOUT-PASSWORD))|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|Port (set 22 to )|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|TCPKeepAlive (set YES to NO)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|X11Forwarding (set YES to NO)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|AllowAgentForwarding (set YES to NO)|-|

The third field is the concrete instruction. Remember these three files:

  • /var/log/lynis-report.dat – the result as a key/value list, ideal for grepping
  • /var/log/lynis.log – the full protocol including the reasoning behind every test
  • the screen output – for skimming only

What exactly Lynis expects in a test is in the log. Kernel parameters, for example:

Terminal
grep "different value than expected" /var/log/lynis.log | sed "s/.*sysctl key //"
Ausgabe
dev.tty.ldisc_autoload has a different value than expected in scan profile. Expected=0, Real=1
fs.protected_fifos has a different value than expected in scan profile. Expected=2, Real=1
kernel.kptr_restrict has a different value than expected in scan profile. Expected=2, Real=0
kernel.modules_disabled has a different value than expected in scan profile. Expected=1, Real=0
kernel.sysrq has a different value than expected in scan profile. Expected=0, Real=438
kernel.unprivileged_bpf_disabled has a different value than expected in scan profile. Expected=1, Real=2
kernel.yama.ptrace_scope has a different value than expected in scan profile. Expected=1 2 3, Real=0
net.core.bpf_jit_harden has a different value than expected in scan profile. Expected=2, Real=0
net.ipv4.conf.all.forwarding has a different value than expected in scan profile. Expected=0, Real=1
net.ipv4.conf.all.log_martians has a different value than expected in scan profile. Expected=1, Real=0
net.ipv4.conf.all.rp_filter has a different value than expected in scan profile. Expected=1, Real=0
net.ipv4.conf.all.send_redirects has a different value than expected in scan profile. Expected=0, Real=1
net.ipv4.conf.default.accept_redirects has a different value than expected in scan profile. Expected=0, Real=1
net.ipv4.conf.default.log_martians has a different value than expected in scan profile. Expected=1, Real=0
net.ipv6.conf.all.accept_redirects has a different value than expected in scan profile. Expected=0, Real=1
net.ipv6.conf.default.accept_redirects has a different value than expected in scan profile. Expected=0, Real=1

Sixteen deviations – and three of them you must not adopt. More on that shortly.

Step 4: Fix the findings that matter

Update packages (PKGS-7392)

The first warning is the most mundane and the most important. Lynis compares installed packages against the available security updates:

Terminal
apt list --upgradable
apt-get -y upgrade

If this keeps showing up for you, Lynis is not the problem – the missing automation is. That is what unattended-upgrades is for.

Bring SSH up to date (SSH-7408)

Instead of touching /etc/ssh/sshd_config, we create a drop-in file. Debian 13 loads everything from /etc/ssh/sshd_config.d/, and a package update leaves your change untouched:

Terminal
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
LogLevel VERBOSE
MaxAuthTries 3
MaxSessions 2
TCPKeepAlive no
X11Forwarding no
AllowAgentForwarding no
EOF

LogLevel VERBOSE additionally logs the fingerprint of the key that was used – exactly the information you need during an incident. MaxAuthTries 3 limits failed attempts per connection, and AllowAgentForwarding no prevents someone with root on the server from riding along on your local SSH agent.

Warning

Test first, then reload. sshd -t validates the configuration without touching the service. A typo plus a blind restart locks you out – and opening a second terminal only helps as long as it is already open.
Terminal
sshd -t && systemctl reload ssh
sshd -T | grep -E "^(maxauthtries|maxsessions|x11forwarding|loglevel)"
Ausgabe
maxauthtries 3
maxsessions 2
x11forwarding no
loglevel VERBOSE

The two sharpest items on the list – PermitRootLogin and PasswordAuthentication – are deliberately missing here. You only set those once a working SSH key is in place, and that is exactly what Harden SSH walks you through. If you have done it, SSH-7408 is shorter for you from the start.

Kernel parameters – here is the Docker trap

Of the sixteen deviating sysctl values we adopt thirteen:

INI
# /etc/sysctl.d/99-lynis.conf
dev.tty.ldisc_autoload = 0
fs.protected_fifos = 2
kernel.kptr_restrict = 2
kernel.sysrq = 0
kernel.yama.ptrace_scope = 1
net.core.bpf_jit_harden = 2
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0

The last four concern ICMP redirects – a router’s hint to send a packet via a different path. A server with a fixed default route needs neither to send nor to accept them, and Docker containers still reach the network over IPv4 and IPv6 without them.

Terminal
sysctl -p /etc/sysctl.d/99-lynis.conf

Three values are missing from that file, and that is on purpose:

  • net.ipv4.conf.all.forwarding = 0 – Lynis wants IP forwarding disabled. Docker needs it to route packets between the container network and the outside world. Set it to 0 and not a single container has network access any more. If you want to know why, understanding Docker networks helps.
  • kernel.modules_disabled = 1 – forbids the kernel from loading further modules. Depending on the stack, Docker loads overlay, br_netfilter or nf_tables on demand. On a container host that is a time bomb that goes off with your next new stack.
  • kernel.unprivileged_bpf_disabled = 1 – here Lynis is simply too narrow. Debian 13 is on 2, which is the stricter setting: BPF for unprivileged users is off and cannot be re-enabled at runtime. Lynis checks for exact equality and therefore complains about an improvement.

After applying, verify that your containers can still talk – rp_filter is the value most likely to cause trouble:

Terminal
docker run --rm alpine:3.22 sh -c "wget -qO- -T5 https://example.com >/dev/null && echo NET-OK"
Ausgabe
NET-OK

Password policy and umask (AUTH-9286, AUTH-9328)

Terminal
sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS\t365/; s/^PASS_MIN_DAYS.*/PASS_MIN_DAYS\t1/' /etc/login.defs
grep -q "^UMASK" /etc/login.defs || printf 'UMASK\t\t027\n' >> /etc/login.defs

UMASK 027 makes sure newly created files are no longer world-readable. That is sensible – but it has a side effect that catches up with you at the next configuration step, see “When it doesn’t work”.

We did not adopt AUTH-9229 and AUTH-9230, which ask for SHA_CRYPT_MIN_ROUNDS in /etc/login.defs. Debian 13 hashes passwords with yescrypt:

Terminal
grep ^ENCRYPT_METHOD /etc/login.defs
Ausgabe
ENCRYPT_METHOD YESCRYPT

The SHA_CRYPT_* values apply exclusively to SHA-512 hashes. Adding them here would make the Lynis message disappear without changing anything – the classic fake improvement. The test really does only look for those two lines and ignores ENCRYPT_METHOD entirely.

Block unused kernel modules (USB-1000, STRG-1846, NETW-3200)

A VPS needs neither USB storage nor FireWire nor the DCCP, SCTP, RDS or TIPC network protocols. Every loaded module is attack surface:

Terminal
cat > /etc/modprobe.d/99-lynis-blacklist.conf <<'EOF'
install usb-storage /bin/true
install firewire-core /bin/true
install firewire_ohci /bin/true
install ohci1394 /bin/true
install dccp /bin/true
install sctp /bin/true
install rds /bin/true
install tipc /bin/true
EOF

install <module> /bin/true is stronger than blacklist: blacklist only prevents automatic loading, a manual modprobe still works. Verify the result with a dry run:

Terminal
modprobe -n -v sctp
Ausgabe
install /bin/true

A banner Lynis can count (BANN-7126, BANN-7130)

Lynis searches /etc/issue for legal buzzwords and wants to find at least five of them – from a hard-coded English list (authori, monitor, log, legal, prohibited, access, system …). A banner in another language fails the test no matter how clear it is. So make it bilingual:

Terminal
cat > /etc/issue <<'EOF'
Zugriff nur für autorisierte Nutzer. Alle Zugriffe werden protokolliert.
Authorized access only. All connections to this system are monitored and
logged. Unauthorized use is prohibited and may be prosecuted by law.
EOF
cp /etc/issue /etc/issue.net

/etc/issue shows up on the local console, /etc/issue.net is what SSH displays before login. The latter only happens if you tell sshd about it – out of the box the banner is none:

Terminal
echo "Banner /etc/issue.net" >> /etc/ssh/sshd_config.d/99-hardening.conf
sshd -t && systemctl reload ssh
sshd -T | grep -i "^banner"
Ausgabe
banner /etc/issue.net

Package integrity and the audit log (PKGS-7370, ACCT-9622, ACCT-9628)

Terminal
apt-get install -y debsums apt-show-versions acct auditd

debsums compares the checksums of installed files against those from the package – so you notice when someone swapped out /usr/bin/ssh. Lynis additionally wants the check to run regularly:

Terminal
sed -i 's/^CRON_CHECK=.*/CRON_CHECK=weekly/' /etc/default/debsums

auditd records kernel events. Freshly installed it has no rules at all – and Lynis promptly answers with a new finding that was not there before:

Ausgabe
* Audit daemon is enabled with an empty ruleset. Disable the daemon or define rules [ACCT-9630]

That is not a bug, it is correct: an audit daemon without rules costs resources and delivers nothing. A small, sensible ruleset for a Docker host:

Terminal
cat > /etc/audit/rules.d/99-serverkueche.rules <<'EOF'
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k privileged
-w /etc/sudoers.d/ -p wa -k privileged
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-w /var/log/lastlog -p wa -k logins
-w /usr/bin/docker -p x -k docker
EOF
augenrules --load
auditctl -l

-p wa means “on write and attribute change”, -k sets the keyword you search for later: ausearch -k sshd. The last rule logs every docker invocation – on a server where Docker access effectively means root access, that is the most interesting line of the lot.

The time warning that comes sooner or later (TIME-3185)

A second warning shows up on almost every server at some point and looks like a real problem, but is not one:

Terminal
timedatectl timesync-status | grep -E "Poll|Offset"
Ausgabe
Poll interval: 34min 8s (min: 32s; max 34min 8s)
       Offset: +1.462ms

The clock is off by 1.5 milliseconds – it is perfectly in sync. Lynis, however, measures the age of the file /run/systemd/timesync/synchronized and warns beyond 2048 seconds. And 2048 seconds is exactly the default maximum systemd-timesyncd raises its poll interval to once the clock is stable. So the test inevitably fires on every healthy system sooner or later.

Instead of ignoring the warning, we push the interval just below the threshold. Then this test means something again:

INI
# /etc/systemd/timesyncd.conf.d/99-lynis.conf
[Time]
PollIntervalMaxSec=1024
Terminal
chmod 0644 /etc/systemd/timesyncd.conf.d/99-lynis.conf
systemctl restart systemd-timesyncd
timedatectl timesync-status | grep Poll
Ausgabe
Poll interval: 1min 4s (min: 32s; max 17min 4s)

The chmod is not a typo – why it is needed is explained further below.

Step 5: Measure again

Terminal
lynis audit system --quick --no-colors | tee /root/lynis-run2.txt
grep -E "Hardening index|Tests performed" /root/lynis-run2.txt
Ausgabe
  Hardening index : 77 [###############     ]
  Tests performed : 254

From 64 to 77, and the Warnings section is gone entirely – with zero warnings Lynis simply omits it. Suggestions shrank from 44 to 22.

Step 6: Deliberate exceptions in custom.prf

What remains are suggestions you re-read on every run and discard every time. That is exactly what /etc/lynis/custom.prf is for. Important: every line gets a reason, otherwise in six months you will not know whether it was a decision or laziness.

INI
# /etc/lynis/custom.prf

# FILE-6310: separate partitions for /home and /var. A VPS has one disk;
# repartitioning after the fact would be more risk than benefit here.
skip-test=FILE-6310

# AUTH-9229/9230: ask for SHA_CRYPT_*_ROUNDS. Debian 13 uses yescrypt,
# so those values would have no effect whatsoever.
skip-test=AUTH-9229
skip-test=AUTH-9230

# BOOT-5122: GRUB password. Anyone reaching the netcup VNC console already has
# access to the server panel and therefore to snapshots and the rescue system.
skip-test=BOOT-5122

The next run is noticeably quieter afterwards – but the index does not move:

Ausgabe
  Hardening index : 77 [###############     ]
  Tests performed : 250
  Suggestions (17):

That is not a bug, it is proof the number cannot be gamed: a skipped test removes both its earned and its possible points from the calculation. From 22 down to 17 suggestions, four tests fewer – index unchanged at 77. custom.prf is a tool against noise, not a tool for your score.

Warning

skip-test disables the test permanently – even once the situation changes. Walk through the list at every quarterly audit and ask yourself whether the reason still holds. Anything you only want to hide this once does not belong here.

We deliberately leave two suggestions visible instead of switching them off: HRDN-7230 (malware scanner) and LOGG-2154 (central logging). Both are hard to justify on a single small VPS – ClamAV alone claims around a gigabyte of RAM for its signature database – but the moment a second server joins, the maths change. A suggestion you re-read and re-reject every time is a reminder; a disabled one is forgotten.

Step 7: Automate the audit

A one-off audit is a snapshot. It gets interesting when you can see the trend – especially after every new stack you deploy. Lynis ships a mode for exactly that:

INI
# /etc/systemd/system/lynis-audit.service
[Unit]
Description=Lynis security audit

[Service]
Type=oneshot
ExecStart=/usr/sbin/lynis audit system --cronjob --quiet

--cronjob turns off colours and prompts, --quiet suppresses everything but warnings. The timer next to it:

INI
# /etc/systemd/system/lynis-audit.timer
[Unit]
Description=Monthly Lynis audit

[Timer]
OnCalendar=monthly
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true catches up on a missed run if the server was off at the scheduled time.

Terminal
systemctl daemon-reload
systemctl enable --now lynis-audit.timer
systemctl list-timers lynis-audit.timer

Afterwards you pull a run’s index out of the report with a single line – that is the number you watch across the months:

Terminal
grep ^hardening_index= /var/log/lynis-report.dat
Ausgabe
hardening_index=77

If you want the result pushed to your phone, append an ExecStartPost line with ntfy to the service.

When it doesn’t work

After setting UMASK 027 a service no longer starts and reports “Permission denied” on its own configuration file. The new umask applies from your next login onwards, and everything you create with > or cat > after that gets mode 0640 instead of 0644. Services running under their own user – systemd-timesyncd uses DynamicUser, for instance – can then no longer read the file. The journal shows Failed to open ...: Permission denied. Set the permissions explicitly for configuration files that are not owned by root: chmod 0644 /etc/systemd/timesyncd.conf.d/99-lynis.conf.

After applying the sysctl values none of the containers have network access. Then net.ipv4.conf.all.forwarding ended up at 0. Lynis suggests it, but without IP forwarding Docker cannot pass packets between the container network and the outside world. Remove the line from /etc/sysctl.d/99-lynis.conf and reset the value immediately with sysctl -w net.ipv4.conf.all.forwarding=1 – restarting Docker alone will not help.

systemd-timesyncd did not synchronized the time recently persists even though timedatectl reports NTPSynchronized=yes. Lynis does not check the clock but the age of /run/systemd/timesync/synchronized, and warns beyond 2048 seconds. Since systemd raises its poll interval to exactly 2048 seconds when the clock is stable, the test inevitably runs into the warning. Either lower PollIntervalMaxSec as shown in step 4, or add the test to custom.prf – but do not just ignore it, or you will miss the day time synchronisation really breaks.

Lynis reports Consider hardening SSH configuration ten times and never says what it means. The screen output drops the details. They are in the third field of grep "^suggestion\[\]=SSH-7408" /var/log/lynis-report.dat – that gives you the parameter and target value for every line.

After installing auditd a finding was added instead of removed. ACCT-9630 reports a running audit daemon with an empty ruleset. That is intended: auditd without rules burns resources without logging anything. Put rules under /etc/audit/rules.d/ and load them with augenrules --load – or uninstall auditd again if you are not going to read the log.

Your problem is not listed? Search all error messages →

Maintenance & backups

Lynis updates itself through apt; the CISOfy repository gets you new test collections promptly. After a major update, check briefly whether new findings suddenly appear – those are usually new tests, not new problems on your server.

Realistic effort: the first audit including the fixes costs you an evening. After that it is roughly 20 minutes per quarter – skim the report, triage new findings, re-read custom.prf. The monthly timer runs without you; you only look when the index moves.

Back up the results of your work. The real value is not in Lynis but in the files you created. Add them to your Restic backup:

Ausgabe
/etc/lynis/custom.prf
/etc/sysctl.d/99-lynis.conf
/etc/modprobe.d/99-lynis-blacklist.conf
/etc/ssh/sshd_config.d/99-hardening.conf
/etc/audit/rules.d/
/etc/systemd/system/lynis-audit.{service,timer}

After every larger change – a new reverse proxy, a new database, a newly opened port – a manual run is worth it. Those are exactly the moments that create the gaps a monthly timer only finds weeks later.

What Lynis is not

Lynis audits from the inside: it reads configuration files and system state with root privileges. It does not tell you what an attacker sees from outside, does not check a web application for vulnerabilities and does not replace a penetration test. For the outside view you need a port scan from another host and – for publicly reachable services – a look at Traefik middlewares.

You might also like