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.
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
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.
💶 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
Step by step
Step 1: Install Lynis from the CISOfy repository
Debian ships Lynis, but an older release:
apt-cache policy lynislynis:
Installed: (none)
Candidate: 3.1.4-1That 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:
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://packages.cisofy.com/keys/cisofy-software-public.key \
-o /etc/apt/keyrings/cisofy.ascCheck briefly that you actually received a key and not an error page:
head -1 /etc/apt/keyrings/cisofy.asc-----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:
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 lynislynis --version3.1.7Note
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:
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:
Details:
Hardening index : 64 [############ ]
Tests performed : 251
Plugins enabled : 0Above it are the actual results, split into two categories:
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:
grep "^suggestion\[\]=SSH-7408" /var/log/lynis-report.datsuggestion[]=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:
grep "different value than expected" /var/log/lynis.log | sed "s/.*sysctl key //"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=1Sixteen 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:
apt list --upgradable
apt-get -y upgradeIf 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:
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
LogLevel VERBOSE
MaxAuthTries 3
MaxSessions 2
TCPKeepAlive no
X11Forwarding no
AllowAgentForwarding no
EOFLogLevel 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
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.sshd -t && systemctl reload ssh
sshd -T | grep -E "^(maxauthtries|maxsessions|x11forwarding|loglevel)"maxauthtries 3
maxsessions 2
x11forwarding no
loglevel VERBOSEThe 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:
# /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 = 0The 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.
sysctl -p /etc/sysctl.d/99-lynis.confThree 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 loadsoverlay,br_netfilterornf_tableson 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 on2, 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:
docker run --rm alpine:3.22 sh -c "wget -qO- -T5 https://example.com >/dev/null && echo NET-OK"NET-OKPassword policy and umask (AUTH-9286, AUTH-9328)
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.defsUMASK 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:
grep ^ENCRYPT_METHOD /etc/login.defsENCRYPT_METHOD YESCRYPTThe 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:
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
EOFinstall <module> /bin/true is stronger than blacklist: blacklist only prevents automatic
loading, a manual modprobe still works. Verify the result with a dry run:
modprobe -n -v sctpinstall /bin/trueA 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:
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:
echo "Banner /etc/issue.net" >> /etc/ssh/sshd_config.d/99-hardening.conf
sshd -t && systemctl reload ssh
sshd -T | grep -i "^banner"banner /etc/issue.netPackage integrity and the audit log (PKGS-7370, ACCT-9622, ACCT-9628)
apt-get install -y debsums apt-show-versions acct auditddebsums 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:
sed -i 's/^CRON_CHECK=.*/CRON_CHECK=weekly/' /etc/default/debsumsauditd records kernel events. Freshly installed it has no rules at all – and Lynis promptly
answers with a new finding that was not there before:
* 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:
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:
timedatectl timesync-status | grep -E "Poll|Offset"Poll interval: 34min 8s (min: 32s; max 34min 8s)
Offset: +1.462msThe 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:
# /etc/systemd/timesyncd.conf.d/99-lynis.conf
[Time]
PollIntervalMaxSec=1024chmod 0644 /etc/systemd/timesyncd.conf.d/99-lynis.conf
systemctl restart systemd-timesyncd
timedatectl timesync-status | grep PollPoll 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
lynis audit system --quick --no-colors | tee /root/lynis-run2.txt
grep -E "Hardening index|Tests performed" /root/lynis-run2.txt Hardening index : 77 [############### ]
Tests performed : 254From 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.
# /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-5122The next run is noticeably quieter afterwards – but the index does not move:
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:
# /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:
# /etc/systemd/system/lynis-audit.timer
[Unit]
Description=Monthly Lynis audit
[Timer]
OnCalendar=monthly
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.targetPersistent=true catches up on a missed run if the server was off at the scheduled time.
systemctl daemon-reload
systemctl enable --now lynis-audit.timer
systemctl list-timers lynis-audit.timerAfterwards you pull a run’s index out of the report with a single line – that is the number you watch across the months:
grep ^hardening_index= /var/log/lynis-report.dathardening_index=77If 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:
/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
Send feedback: feedback@serverkueche.de
You might also like


