A new VPS running a website takes password guesses on port 22 within minutes. Its web server sends its version in every response, and nothing installs security updates until someone logs in. This tutorial sets up a web server security baseline on Debian 13 (trixie) in five parts: automatic security updates, key-only SSH, TLS with certbot plus HSTS and security headers, a default-deny nftables firewall, and Fail2ban. Each part ends with a check that proves it works.
This is a baseline, so each step keeps to the minimum. Where this site has a full guide on the topic, the step links to it.
What we build and prerequisites
You need a Debian 13 server with root or sudo access, a DNS name pointing at it (example.com in the examples), and Nginx or Apache already serving a site on port 80. When this was written, Debian 13 shipped these package versions (from packages.debian.org):
| Package | Version in trixie | Used for |
|---|---|---|
| unattended-upgrades | 2.12 | automatic security updates |
| openssh-server | 1:10.0p1-7+deb13u4 | SSH |
| nginx | 1.26.3-3+deb13u9 | web server (option A) |
| apache2 | 2.4.68-1~deb13u1 | web server (option B) |
| certbot | 4.0.0-2+deb13u1 | Let's Encrypt client |
| python3-certbot-nginx / -apache | 4.0.0-2 | certbot web server plugins |
| nftables | 1.1.3-1 | firewall |
| fail2ban | 1.1.0-8 | banning brute-force sources |
Security updates change the Debian revision suffixes, so check yours with apt policy.
Step 1: Automatic security updates with unattended-upgrades
apt update
apt install unattended-upgrades apt-listchanges
dpkg-reconfigure -plow unattended-upgrades
Answer “Yes” to the prompt about automatic updates. In Debian's default /etc/apt/apt.conf.d/50unattended-upgrades, the enabled origins are the main Debian archive and Debian-Security for the current codename, so you get stable point-release and security fixes, not new upstream versions. Don't edit the packaged file. The Debian wiki recommends putting local changes in a copy:
Unattended-Upgrade::Mail "root";
Unattended-Upgrade::Automatic-Reboot "false";
Automatic-Reboot is false by default. If you set it to true, the server reboots without asking whenever /var/run/reboot-required exists. Without Automatic-Reboot-Time, that reboot happens immediately. Mail to root only arrives if the system has a working mail setup and a package that provides mailx is installed, as the comments in Debian's packaged 50unattended-upgrades say.
On one of my own servers unattended-upgrades had been removed without anyone noticing, and no security updates were installed for weeks - so do not skip the check below.
Verify
apt-config dump APT::Periodic::Unattended-Upgrade
systemctl list-timers 'apt-daily*'
unattended-upgrade --dry-run --debug
tail -n 20 /var/log/unattended-upgrades/unattended-upgrades.log
The first command should print APT::Periodic::Unattended-Upgrade "1";. You should see both apt-daily.timer (downloads) and apt-daily-upgrade.timer (installs). The dry run lists the allowed origins and any packages it would upgrade, without changing anything.
Step 2: SSH key-only access
Before you turn off passwords, confirm that key login already works. From your workstation, run ssh-copy-id user@example.com, then open a new session and make sure no password prompt appears. OpenSSH in trixie no longer supports DSA keys, so use Ed25519.
Before you set PermitRootLogin no, make sure a non-root user with sudo rights and a working key exists, otherwise you lock yourself out; if you only log in as root, use PermitRootLogin prohibit-password (key-only root login) instead.
sshd uses the first value it reads for each keyword. Files in /etc/ssh/sshd_config.d/ are read before the rest of sshd_config, in lexical order, so a file whose name starts with a low number takes precedence:
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
Warning: a mistake here can lock you out. Keep your current SSH session open until you've tested a login from a new terminal. Use your provider's console as a fallback.
sshd -t
systemctl reload ssh
Note that the unit is named ssh.service on Debian, not sshd.service.
Verify
sshd -T | grep -Ei '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)'
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password user@example.com
passwordauthentication no
kbdinteractiveauthentication no
permitrootlogin no
user@example.com: Permission denied (publickey).
sshd -T shows the effective configuration after all includes. If it still shows yes, another file is setting the value first. For the remaining options (ciphers, AllowUsers, rate limits), see hardening SSH on Debian 13 with OpenSSH 10.
Step 3: TLS with certbot, HSTS and security headers
Get the certificate
For Nginx:
apt install certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com
For Apache:
apt install certbot python3-certbot-apache
certbot --apache -d example.com -d www.example.com
The plugin gets the certificate and edits your vhost to use it. It also adds an HTTP-to-HTTPS redirect by default, without asking. Pass --no-redirect if you don't want that. If an Nginx edit goes wrong, certbot --nginx rollback undoes it. Debian's certbot package installs certbot.timer (plus a fallback in /etc/cron.d/certbot), so renewal is automatic. For deploy hooks that reload the web server after a renewal, see certbot renewals on Debian 13.
Add HSTS and headers: Nginx
Put the headers in a snippet so you can include them in more than one place:
add_header Strict-Transport-Security "max-age=300" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
Include it in the listen 443 ssl server block that certbot edited, and turn off the version banner (server_tokens is on by default):
server {
server_name example.com www.example.com;
listen 443 ssl;
# ssl_certificate lines added by certbot stay here
server_tokens off;
include snippets/security-headers.conf;
# ...
}
nginx -t
systemctl reload nginx
The always parameter makes Nginx send these headers on error responses as well. Without it they're only added to responses such as 200, 301 and 304.
Add HSTS and headers: Apache
a2enmod headers
Inside the <VirtualHost *:443> block (certbot creates it in /etc/apache2/sites-available/example.com-le-ssl.conf):
Header always set Strict-Transport-Security "max-age=300"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "frame-ancestors 'self'"
Debian ships /etc/apache2/conf-available/security.conf with ServerTokens OS and ServerSignature On. Tighten both there. TraceEnable Off is already the Debian default, so that line only needs checking, not changing:
ServerTokens Prod
ServerSignature Off
TraceEnable Off
apache2ctl configtest
systemctl reload apache2
Raise HSTS once it works
Start with max-age=300. If something breaks on HTTPS during the test, browsers only enforce HTTPS for your domain for five minutes. Once every page and subdomain works over HTTPS, raise it to max-age=31536000. Add includeSubDomains only if every subdomain serves valid HTTPS. Add preload only if you actually plan to submit the domain to the browser preload list. On any HSTS host, browsers don't let users click past certificate errors.
Verify
certbot certificates
systemctl list-timers certbot.timer
certbot renew --dry-run
curl -sI https://example.com/ | grep -Ei 'strict-transport|x-content-type|referrer-policy|content-security|^server'
curl -sI http://example.com/ | head -n 3
server: nginx
strict-transport-security: max-age=300
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
content-security-policy: frame-ancestors 'self'
The plain-HTTP request should return a 301 redirect to HTTPS. Also request a URL that doesn't exist: the 404 response should still carry the headers.
Step 4: Default-deny nftables firewall
This ruleset drops all inbound traffic except loopback, established connections, ICMP, SSH and web traffic. It doesn't filter outbound traffic.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
meta l4proto { icmp, ipv6-icmp } accept
tcp dport 22 accept
tcp dport { 80, 443 } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Warning: loading a drop policy over SSH can cut off your own session. If SSH runs on a different port, change
tcp dport 22first. The first command below schedules an automaticflush rulesetin five minutes. If you lose access, the flush removes all filtering and lets you back in.
nft -c -f /etc/nftables.conf
systemd-run --unit=nft-rollback --on-active=5min /usr/sbin/nft flush ruleset
nft -f /etc/nftables.conf
Open a new SSH session and load the site in a browser. If both work, cancel the rollback and enable the rules at boot:
systemctl stop nft-rollback.timer
systemctl enable --now nftables.service
Verify
nft list ruleset
systemctl is-enabled nftables.service
From another machine, run nmap -Pn example.com. Only 22, 80 and 443 should show as open. For logging dropped packets, rate-limited SSH and IPv6 details, see the nftables default-drop firewall guide.
Step 5: Fail2ban for SSH
apt install fail2ban
Debian's package already ships /etc/fail2ban/jail.d/defaults-debian.conf. It sets banaction = nftables and enables the sshd jail with backend = systemd, reading the journal of ssh.service. Put your own settings in jail.local, and add your own static IP to ignoreip so you can't ban yourself:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
systemctl restart fail2ban
Verify
fail2ban-client status
fail2ban-client status sshd
nft list ruleset | grep -i f2b
The sshd jail status shows a Journal matches line and counters for failed and banned addresses. On a public server, the failed count usually starts rising within hours. Fail2ban creates its f2b-table in nftables at the first ban, so the last command prints nothing until an address has been banned. Jails for Nginx and Apache logs, and recidive, are covered in Fail2ban on Debian 13 with nftables.
How to verify the whole baseline
Run these after any major change. Together they cover all five parts:
unattended-upgrade --dry-run
sshd -T | grep -E '^(passwordauthentication|permitrootlogin)'
certbot renew --dry-run
curl -sI https://example.com/ | grep -i strict-transport
nft list ruleset | head -n 20
fail2ban-client status sshd
ls /var/run/reboot-required
If the last command finds the file, an update is waiting for a reboot (on Debian, a kernel postinst hook shipped with unattended-upgrades creates it after a kernel update). Schedule one.
Common pitfalls on Debian 13
- A drop-in still allows passwords. The first value sshd reads wins, and drop-ins are read in lexical order. If a file such as
50-cloud-init.confsetsPasswordAuthentication yes, a file named60-can't override it. Always check withsshd -T. - Fail2ban points at
/var/log/auth.log. Without rsyslog that file doesn't exist, and the jail fails to start. Keep Debian'sbackend = systemdfor sshd instead of copying old tutorials. - Reloading nftables removes Fail2ban's bans. Our
/etc/nftables.confstarts withflush ruleset, which deletes every table, including the one Fail2ban created. Runsystemctl restart fail2banaftersystemctl restart nftables. - Headers disappear in some locations (Nginx). A
locationblock with its ownadd_headerinherits none of the parent's headers. Nginx 1.29.3 addedadd_header_inherit merge, but Debian 13 ships 1.26.3, so include the snippet in every location that has its ownadd_header. - Duplicate headers (Apache with PHP-FPM).
mod_proxy_fcgistores the backend's headers in thealwaystable, soHeader always setreplaces a header that PHP also sends. A plainHeader setworks on the other table and sends the header twice. Check withcurl -sI. - HSTS on the port 80 vhost. RFC 6797 tells browsers to ignore HSTS headers received over plain HTTP. The header only counts on HTTPS responses.
- Updated but not restarted. unattended-upgrades installs packages but doesn't reboot unless you set Automatic-Reboot. Daemons that still have old libraries loaded keep running the old code until they're restarted. The package suggests
needrestartfor this. - Old SSH keys. OpenSSH in trixie dropped DSA support. Replace any
ssh-dsskeys before you disable passwords.
Takeaways: baseline checklist
- [ ]
unattended-upgradesinstalled, both apt timers active, dry run clean, local overrides in52unattended-upgrades-local - [ ]
sshd -Tshows passwords and root login disabled, and a password-only login attempt is refused - [ ] Certificate issued,
certbot.timeractive,certbot renew --dry-runsucceeds - [ ] HSTS, nosniff, Referrer-Policy and frame-ancestors present on HTTPS responses, including 404s; HSTS raised from 300 to 31536000 after testing
- [ ] Version banner hidden (
server_tokens off, orServerTokens Prodon Apache) - [ ] nftables input policy is drop, only 22, 80 and 443 open,
nftables.serviceenabled - [ ] Fail2ban
sshdjail running on the systemd backend, your own IP inignoreip - [ ] Reboot scheduled whenever
/var/run/reboot-requiredexists
Sources
- Debian packages: unattended-upgrades (trixie)
- Debian packages: nginx (trixie)
- Debian packages: apache2 (trixie)
- Debian packages: fail2ban (trixie)
- Debian packages: certbot (trixie)
- Debian packages: certbot file list (trixie)
- Debian packages: python3-certbot-nginx (trixie)
- Debian packages: python3-certbot-apache (trixie)
- Debian packages: openssh-server (trixie)
- Debian packages: nftables (trixie)
- Debian Wiki: UnattendedUpgrades / PeriodicUpdates
- unattended-upgrade(8), Debian trixie
- unattended-upgrades 2.12: 50unattended-upgrades.Debian
- fail2ban 1.1.0-8: Debian jail.d defaults
- Debian 13 release notes: Issues to be aware of for trixie
- sshd_config(5), Debian trixie
- sshd(8), Debian trixie
- Certbot User Guide
- Certbot CHANGELOG
- nginx: ngx_http_headers_module
- nginx: ngx_http_core_module (server_tokens)
- Apache HTTP Server: mod_headers
- apache2 2.4.68-1~deb13u1: security.conf
- RFC 6797: HTTP Strict Transport Security
- MDN: Strict-Transport-Security
- MDN: X-Content-Type-Options
- MDN: Referrer-Policy
- Debian Wiki: nftables
- nft(8), Debian trixie
- systemd-run(1), Debian trixie
- unattended-upgrades 2.12: kernel postinst hook
- fail2ban 1.1.0-8: nftables action
Comments