Linux•August 29, 2026• 174 views

Secure a Linux VPS in 2026: Debian and Ubuntu hardening guide

Secure a Linux VPS in 2026: Debian and Ubuntu hardening guide

Securing a Linux VPS is not a matter of installing UFW and Fail2ban and calling the job done. A server exposed to the Internet must be defended in depth: a maintained system, named administrative accounts, robust SSH authentication, strictly necessary ports, services running with minimum privileges, usable logs, restorable backups and a response procedure in case of compromise.

This guide offers a realistic baseline for Debian and Ubuntu. Every sensitive change comes with a check and, where needed, a safeguard against locking yourself out remotely.

By the end, you should be able to answer these questions precisely:

  • which system version is installed, and how long is it maintained?
  • which services listen on IPv4 and IPv6?
  • who can connect over SSH, using which method and from which network?
  • which firewall rules are actually applied?
  • are security patches installed, and are their failures reported?
  • do the logs survive a reboot, and are they copied off the VPS?
  • what was the last backup successfully restored?
  • what will you do if a key, an account or the whole server is compromised?

Method, scope and limits

The recommendations were cross-checked on 31 August 2026 against the publications of the French ANSSI on GNU/Linux hardening, the official documentation of Debian, Ubuntu Server, OpenSSH, systemd, the Linux kernel, Docker and restic. The ANSSI GNU/Linux guide provides a hardening framework; commands and behaviours liable to change were verified against the projects' current documentation.

Versions, support dates and tool behaviour all change. Before applying this baseline in production, run it on a test VPS and check the dated values in this article against the official pages linked throughout the text.

This guide covers a Debian or Ubuntu VPS administered directly. It does not replace the hardening specific to Nginx, Apache, PHP, Docker, a database or an application, nor a high-availability architecture, nor a regulatory audit or penetration test, nor the rules specific to Kubernetes, Proxmox, a router, a mail server or a hypervisor, nor the measures taken by the host below the virtualisation layer.

Security is a property of the whole: host, network, system, application, identities, data, backups and operations.

Versions considered in August 2026

Debian 13 "trixie" is the current stable release. Debian states full support until 9 August 2028, then an LTS phase until 30 June 2030, with a reduced set of architectures during that phase. Debian 12 has now been superseded by Debian 13: its full support ended on 11 July 2026 and it remains covered by its LTS phase until 30 June 2028, limited to certain architectures. See the official Debian 13 and Debian 12 pages.

Ubuntu 22.04, 24.04 and 26.04 are LTS releases still maintained in August 2026. Canonical publishes the exact dates in its Ubuntu release cycle.

SystemStatus on 31 August 2026Recommended decision
Debian 13current stablerecommended Debian choice for a new installation
Debian 12LTS, more limited support depending on architectureplan the migration to Debian 13
Ubuntu 26.04 LTScurrent LTSrecommended Ubuntu choice once application compatibility is validated
Ubuntu 24.04 LTSstandard maintenance until 2029excellent choice for an already qualified stack
Ubuntu 22.04 LTSstandard maintenance until 2027plan the upgrade
release out of supportno more regular patchesmigrate; a firewall does not compensate for an unmaintained system

Do not judge a system's security from uname -r alone. Debian and Ubuntu backport fixes without necessarily adopting the latest upstream version number. Check a CVE in the Debian Security Tracker or the Ubuntu CVE tracker, against the distribution and package actually installed.

Threat model: what this guide aims to reduce

A public VPS is notably exposed to automated scans and authentication attempts, to the exploitation of an unpatched service or dependency, to the theft of an SSH key, an API token or an application secret, to a firewall, permission or Docker publishing mistake, to privilege escalation after an application is compromised, to the deletion or encryption of reachable data and backups, to log deletion by an attacker who has become administrator, and to human error during an update or a network change.

This baseline does not make the VPS invulnerable. It aims to reduce the probability of intrusion, limit the impact of a first access, improve detection and enable a controlled rebuild.

Three maturity levels

LevelMinimum measuresUse case
Baselineinventory, updates, named account, SSH key, root and SSH password disabled, firewall, remote backupany exposed VPS
Productionmonitored updates, AppArmor, Fail2ban where the risk warrants it, persistent and remote logs, external alerting, tested restorea production site or service
Reinforcedadministration through VPN or bastion, FIDO2 key or MFA, considered outbound filtering, auditd/AIDE, append-only backup repository, declarative configurationsensitive data or strict requirements

Not every measure should be applied blindly. Disabling IP forwarding, for instance, breaks a router, a VPN or some container networks. Professional hardening starts with context.

Before any change: avoid locking yourself out

Before touching SSH or the firewall:

  1. check that the host's rescue console works;
  2. keep the current SSH session open;
  3. open a second window to test each change;
  4. note the effective SSH address and port;
  5. take a precautionary snapshot if the platform allows it;
  6. back up the files you modify;
  7. disable the old access only after validating the new one.

A snapshot taken before a change makes an operational rollback easier, but it does not replace a backup held off the VPS. It may be inconsistent if a database is writing at that moment, and it usually remains managed from the same provider account.

1. Inventory the system before hardening it

Start with a read-only collection:

cat /etc/os-release
uname -a
hostnamectl
systemd-detect-virt

ip -brief address
ip route
ip -6 route

sudo ss -lntup
sudo systemctl --type=service --state=running --no-pager
sudo systemctl list-unit-files --state=enabled --no-pager

df -hT
df -ih
findmnt
timedatectl

ss -lntup lists listening sockets. To see established TCP connections, including outbound ones:

sudo ss -tpn state established

Create an operations record with:

ItemValue to record
distribution and versionoutput of /etc/os-release
kernel and architectureoutput of uname -a
public IPv4 and IPv6addresses actually routed
main interfacename, address, gateway
expected portsfor example TCP 22, 80 and 443
expected servicesSSH, Nginx, the application, a monitoring agent
authorised administratorsnamed accounts, keys and network origin
critical datafiles, databases, secrets and configurations
RPOmaximum acceptable data loss
RTOmaximum acceptable downtime

Any port with no owner and no justification must be investigated before being closed. A service may listen only on 127.0.0.1, on a private address, on all IPv4 interfaces with 0.0.0.0, or on IPv6 with ::. Check both families.

2. Use named accounts and least privilege

Avoid day-to-day administration with a shared account or directly as root. Create one account per administrator so that logs and revocations stay attributable.

Example with adminops:

sudo adduser adminops
sudo usermod -aG sudo adminops

sudo groupadd --force ssh-admins
sudo usermod -aG ssh-admins adminops

id adminops
getent group ssh-admins

On Debian and Ubuntu, members of the sudo group can obtain privileges according to the local policy. Ubuntu explains the value of sudo for traceability in its documentation on user management.

From the administrator's workstation, generate a modern key protected by a passphrase:

ssh-keygen -t ed25519 -a 100 -C "adminops@organisation"
ssh-copy-id -i ~/.ssh/id_ed25519.pub adminops@VPS_ADDRESS

Ed25519 is the recommended choice in Ubuntu's OpenSSH documentation. The -a option increases the number of rounds in the derivation function that protects the private key with its passphrase; it does not change the key algorithm. Our guide on setting up an SSH key on a Linux VPS covers the procedure on Windows, macOS and Linux.

For a reinforced level, a FIDO2 key of type ed25519-sk requires a compatible hardware authenticator to be present:

ssh-keygen -t ed25519-sk -C "adminops-fido2@organisation"

Keep a separate, protected and tested recovery method. A single hardware key with no fallback procedure turns its loss into an availability incident.

Test in a new window:

ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 adminops@VPS_ADDRESS
sudo -v
id

Do not go further until key-based login and sudo both work.

Audit existing accounts

getent passwd
awk -F: '($3 == 0) {print $1}' /etc/passwd
sudo lastlog
sudo passwd -S root

There should normally be a single UID 0. Lock or remove accounts that have become unnecessary, after checking their files, scheduled tasks, processes and application dependencies.

3. Harden OpenSSH without interrupting access

Ubuntu accepts the main configuration in /etc/ssh/sshd_config and fragments in /etc/ssh/sshd_config.d/. Its configuration normally includes that directory at the top of the main file. OpenSSH keeps the first value encountered for most directives; a file named 00-hardening.conf is therefore more predictable than a late file that could come after a fragment created by cloud-init. This behaviour is documented by Ubuntu and in Debian's sshd_config manual.

First check the include:

grep -nE '^[[:space:]]*Include' /etc/ssh/sshd_config
sudo sshd -T | sort

Back up the configuration:

sudo cp -a /etc/ssh/sshd_config /etc/ssh/sshd_config.before-hardening
sudo install -d -m 755 /etc/ssh/sshd_config.d
sudoedit /etc/ssh/sshd_config.d/00-hardening.conf

Add:

PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey

AllowGroups ssh-admins
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
PermitTunnel no

Before reloading:

sudo sshd -t
sudo sshd -T | grep -E '^(port|permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|allowgroups|maxauthtries|logingracetime|x11forwarding|permittunnel) '

If Match blocks exist, also check the configuration applied to the administration account and address:

sudo sshd -T \
  -C user=adminops,host="$(hostname -f)",addr=198.51.100.10 |
  grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|allowgroups) '

The address 198.51.100.10 belongs to a range reserved for documentation. Replace it with the real address the administrator will connect from.

If sshd -t returns nothing, the syntax is valid. Reload without closing the current session:

sudo systemctl reload ssh
sudo systemctl status ssh --no-pager
sudo journalctl -u ssh -n 50 --no-pager

Immediately test a new connection with the key, then sudo -v. Close the old session only after that test.

What should remain conditional

  • AllowTcpForwarding no and AllowAgentForwarding no are useful when no SSH tunnel or jump host is needed, but they break those uses;
  • restricting SSH to one IP address or to a VPN greatly reduces exposure, but requires a fallback path;
  • changing port 22 mainly reduces bot noise; it replaces neither a key, nor a firewall, nor patches;
  • TOTP MFA can complement the key. Ubuntu's documentation describes the AuthenticationMethods publickey,keyboard-interactive combination in its TOTP/HOTP guide.

4. Install and monitor security updates

A command with -y is convenient in a lab, but a first production hardening pass should let you read the proposed changes:

sudo apt update
apt list --upgradable
sudo apt-get -s upgrade
sudo apt upgrade

sudo apt autoremove --dry-run

Run apt autoremove only after reviewing the list. A distribution update, a kernel, a driver or a business package may require a reboot and an application test.

Enable automatic patching

On Ubuntu Server, unattended-upgrades is installed and security updates are normally enabled by default. Do not assume it: verify it. The Ubuntu documentation on automatic updates details the files, timers, allowed origins and logs.

On Debian, install and enable the mechanism explicitly, according to your policy:

sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

systemctl list-timers 'apt-daily*' --all
sudo unattended-upgrade --dry-run --debug
sudo journalctl -u apt-daily-upgrade.service -n 100 --no-pager

Check in particular:

grep -RhsE 'APT::Periodic::(Update-Package-Lists|Unattended-Upgrade)' \
  /etc/apt/apt.conf.d/

sudo sed -n '/Allowed-Origins/,/};/p' \
  /etc/apt/apt.conf.d/50unattended-upgrades

A third-party origin is not automatically covered. Check which repositories provide your packages:

grep -RhsE '^[[:space:]]*(deb |Types:|URIs:|Suites:|Signed-By:)' \
  /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null

apt-cache policy

On Debian 13, the release notes also suggest this search for installed packages that do not come from Debian:

apt list '?narrow(?installed, ?not(?origin(Debian)))'

A repository added for a single package widens the chain of trust and may supply updates for other packages too. Use official or vendor repositories, a dedicated key through Signed-By, and remove sources that are no longer needed. Avoid running curl ... | sh directly without downloading, verifying and reading the script.

Reboots and alerts

On Ubuntu, a recommended reboot is generally signalled by:

if [ -f /var/run/reboot-required ]; then
  cat /var/run/reboot-required
  cat /var/run/reboot-required.pkgs 2>/dev/null
fi

Automation without monitoring is incomplete. At a minimum, alert on a failed update timer or service, the age of the last successful run, a reboot required for too long, a held package or an uncovered origin, and a system approaching end of support.

5. Configure the firewall: pick a single manager

A firewall is not an inventory tool: determine the legitimate flows first. It complements service hardening but does not replace it, as the Debian security manual points out.

Choose one main approach:

ContextRecommended choice
Ubuntu, simple rulesUFW
Debian, native and advanced policynftables
Docker Enginestudy Docker's chains and limitations first
infrastructure with a provider firewallapply network filtering as well, without dropping the local firewall

UFW is the default firewall tool documented by Ubuntu. nftables is the default framework recommended by Debian. Do not stack UFW, a manual nftables ruleset and other managers without understanding their order.

Option A: UFW on Ubuntu or for a simple need

Note the effective SSH port:

sudo sshd -T | awk '$1 == "port" {print $2; exit}'

The following example assumes SSH on 22 plus a web server. Adapt the ports before enabling:

sudo apt install ufw

sudo ufw default deny incoming
sudo ufw default allow outgoing

sudo ufw allow 22/tcp comment 'SSH administration'
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'

sudo ufw show added
sudo ufw enable
sudo ufw status verbose
sudo ufw status numbered

Keep the session open and test a new SSH connection.

If administration comes from a fixed IPv4 address, create a restrictive rule first:

sudo ufw allow from 198.51.100.10 to any port 22 proto tcp \
  comment 'SSH from the admin workstation'
sudo ufw status numbered

Once you have tested from that address, delete the SSH rule open to everyone using its number:

sudo ufw delete RULE_NUMBER

Repeat the logic for IPv6 if the administrator uses it. Do not disable IPv6 merely to avoid writing its rules: a forgotten AAAA record or IPv6 interface can create an exposure different from the one you tested over IPv4.

Our tutorial on configuring UFW on a Linux VPS covers rule management in detail.

Option B: nftables on a Debian VPS without Docker

The example below suits a fresh web VPS that is not a router, has no Docker and runs SSH on 22. It replaces the entire ruleset: do not use it if another piece of software already manages netfilter.

First save the current state into a file that itself begins by flushing the ruleset:

sudo sh -c '{
  printf "%s\n" "flush ruleset"
  nft list ruleset
} > /root/nftables.before-hardening.nft'
sudoedit /etc/nftables.conf

Contents:

#!/usr/sbin/nft -f

flush ruleset

define ssh_port = 22

table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;

        iifname "lo" accept
        ct state invalid drop
        ct state established,related accept

        ip protocol icmp accept
        ip6 nexthdr ipv6-icmp accept

        tcp dport $ssh_port ct state new accept comment "SSH"
        tcp dport { 80, 443 } ct state new accept comment "Web"

        counter drop
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

Allowing useful ICMP and ICMPv6 avoids breaking IPv6 neighbour discovery, diagnostics and path MTU discovery. Do not copy a rule that blocks all ICMP without understanding those functions.

Check the syntax before applying:

sudo nft -c -f /etc/nftables.conf
sudo nft -f /etc/nftables.conf
sudo nft list ruleset
sudo systemctl enable nftables

Test a new SSH session and the web service. To roll back from the console:

sudo nft -f /root/nftables.before-hardening.nft

The rollback file also contains flush ruleset: loading it therefore replaces the current policy instead of adding duplicate rules. Perform this rollback from the rescue console if SSH is already broken.

If the VPS routes traffic, hosts a VPN, uses several interfaces or runs Docker, design the forward and NAT chains for that architecture. A drop policy copied without adaptation can take the service down.

Add a network firewall

When the host offers an upstream firewall, use it as a second barrier: SSH only from the administration addresses or the VPN, HTTP/HTTPS from the Internet if the server is public, databases and admin panels never open to everyone, consistent IPv4 and IPv6 rules, and a tested rescue console.

Upstream filtering can block traffic before it consumes the VPS's resources. It does not necessarily see container-internal networks, however, and it does not protect a service reachable from the VPS itself.

6. Critical case: Docker and UFW or nftables rules

Docker modifies filtering and NAT rules to publish container ports. Its documentation warns that traffic to a published port is diverted before the INPUT and OUTPUT chains used by UFW; a Docker port can therefore remain reachable despite a UFW policy that appears to deny it. See the official page on packet filtering and firewalls.

Inventory the published ports:

sudo docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ss -lntup
sudo iptables -S DOCKER-USER 2>/dev/null

The essential difference:

# Exposed on every interface of the VPS
ports:
  - "8080:80"

# Reachable only from the VPS, for example through Nginx
ports:
  - "127.0.0.1:8080:80"

For a database, Redis, an admin panel or an application behind a reverse proxy, bind the port to 127.0.0.1 or to a private address when public access is not required.

Docker recommends using the DOCKER-USER chain for policies that must precede its own iptables rules. It advises against disabling its iptables/ip6tables management, as that generally breaks container networking. Docker's installation documentation further states that Docker Engine supports iptables-nft and iptables-legacy, but not a ruleset created directly with nft as Docker's filtering mechanism. So do not mix the nftables model above with Docker without a tested architecture. Our guide on installing Docker on a Linux VPS covers installation and daemon configuration.

After every container deployment, scan again from an external network. The Compose file describes the intent; the observable network describes the reality.

7. Reduce services, packages and privileges

List the active units and the ports:

sudo systemctl --type=service --state=running --no-pager
sudo systemctl list-unit-files --state=enabled --no-pager
sudo ss -lntup

For each service, ask: is it indispensable? should it start automatically? should it listen on a public interface? does it run under a dedicated unprivileged account? which files, Linux capabilities and networks does it need? are its logs and status monitored?

Stopping and disabling is safer than removing immediately:

sudo systemctl disable --now SERVICE_NAME
sudo systemctl status SERVICE_NAME --no-pager

After an observation period and a dependency check, remove the package if its absence is confirmed.

Do not run the application as root

An application service should use a dedicated system account, restricted directories and no interactive shell when one is not required. Databases should listen on loopback or a private address, unless there is an explicit and filtered need.

With systemd, first analyse the level of confinement:

sudo systemd-analyze security --no-pager NAME.service
sudo systemctl cat NAME.service

The systemd-analyze security score measures only the systemd protections the tool knows about. A high score is not proof of a vulnerability, and a low score does not validate the application's code. The systemd-analyze manual stresses this limitation.

For an internal, non-JIT application that writes only to two directories, a drop-in might look like this:

[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
LockPersonality=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/myapp /var/log/myapp

Create it with:

sudo systemctl edit NAME.service
sudo systemd-analyze verify NAME.service
sudo systemctl daemon-reload
sudo systemctl restart NAME.service
sudo systemctl status NAME.service --no-pager
sudo journalctl -u NAME.service -n 100 --no-pager

Apply the options one at a time. MemoryDenyWriteExecute=yes, ProtectHome=yes, restricting address families or dropping capabilities can break a JIT runtime, a binary that loads modules, or a service that expects a particular access. The full reference is systemd.exec.

8. Keep and check AppArmor

AppArmor applies mandatory access control per program. On Ubuntu it is installed and loaded by default. Check its state:

sudo aa-status
systemctl status apparmor --no-pager

Ubuntu's AppArmor documentation distinguishes in particular enforce mode, which blocks forbidden operations and logs them, complain mode, useful for learning a profile without blocking, and confined versus unprofiled processes.

On Debian, AppArmor is available but its activation and the presence of profiles depend on the image and the kernel:

sudo apt install apparmor apparmor-utils
sudo systemctl enable --now apparmor
sudo aa-status

Do not disable AppArmor to "fix" an application. Analyse the denials:

sudo journalctl -k -g 'apparmor="DENIED"' --since today

Create or adapt a profile in staging, temporarily switch the relevant profile to learning mode if necessary, then return to enforce. A profile that is too permissive, or left in complain, does not provide the protection you expect.

9. Add Fail2ban when the service warrants it

Fail2ban reads failure events and temporarily adds a blocking rule. It reduces noise and some repeated attacks, but it fixes neither a weak password, nor a software flaw, nor a stolen credential.

Install it:

sudo apt update
sudo apt install fail2ban
sudoedit /etc/fail2ban/jail.d/sshd.local

Configuration for a system using the systemd journal:

[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h

The systemd backend reads the journal and must not be given a logpath. The parameters and time units are described in Debian's jail.conf manual.

Validate before starting:

sudo fail2ban-client -t
sudo systemctl enable --now fail2ban
sudo fail2ban-client ping
sudo fail2ban-client status
sudo fail2ban-client status sshd
sudo journalctl -u fail2ban -n 100 --no-pager

An allowlist is only reasonable for a genuinely stable address under your control. Too broad a range bypasses precisely the control you have just added. With Docker, also check that the chosen action operates in the chain the traffic actually traverses.

Our guide on installing and configuring Fail2ban covers recent Debian logs, the SSH jail, unbanning and filter testing.

10. Harden a few kernel parameters carefully

Sysctl settings should not be copied by the hundred from a generic checklist. The Linux kernel warns that some settings can render a system unusable and recommends understanding each adjustment.

Start by recording the values:

sysctl kernel.dmesg_restrict
sysctl kernel.kptr_restrict
sysctl fs.protected_hardlinks
sysctl fs.protected_symlinks
sysctl fs.protected_fifos
sysctl fs.protected_regular
sysctl net.ipv4.conf.all.accept_redirects
sysctl net.ipv6.conf.all.accept_redirects

For a typical VPS host that is not a router, this conservative fragment can be considered:

sudoedit /etc/sysctl.d/60-local-hardening.conf
# Limit the exposure of kernel information
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2

# Harden objects created in shared temporary directories
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2

# A VPS host normally has no need to accept ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0

# It normally has no need to send IPv4 redirects
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0

Load then check:

sudo sysctl -p /etc/sysctl.d/60-local-hardening.conf
sudo sysctl --system

The kernel documentation details the IP sysctls and the filesystem sysctls.

This guide deliberately does not force net.ipv4.ip_forward=0, because Docker, WireGuard or a router may depend on it; nor rp_filter=1, because asymmetric routing, some VPNs and policy routing require study; nor disabling IPv6; nor the irreversible disabling of modules or kexec; nor a BPF or io_uring setting without an inventory of applications and agents.

11. Make logs persistent and usable

Local logs are useful for diagnosis, but an attacker who has become root can modify or erase them. The strategy must combine bounded local retention, a reliable clock, remote forwarding and alerting.

journald persistence and space limits

Create a fragment:

sudo install -d -m 755 /etc/systemd/journald.conf.d
sudoedit /etc/systemd/journald.conf.d/60-persistent.conf

An example to adapt to the disk size:

[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=30day

Then:

sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
sudo journalctl --flush

sudo journalctl --disk-usage
sudo journalctl --verify
sudo journalctl -p warning..alert --since today

SystemMaxUse, SystemKeepFree and persistence are documented in journald.conf. A fixed limit must leave enough space for the system and the application.

Centralise the traces off the VPS

For a production server, forward at least SSH and sudo authentications, kernel and firewall events, identity changes, reverse proxy and application logs, and the output of automatic updates, backups and security agents.

Two common solutions are systemd-journal-upload to systemd-journal-remote, or rsyslog to a collector. systemd-journal-upload sends journal entries; rsyslog's omfwd module supports UDP, TCP or TLS and recommends TCP with TLS for a modern deployment.

A serious implementation must authenticate the collector by certificate, verify its name and certificate authority, use TLS rather than plaintext syslog over UDP on the Internet, queue events during an outage, prevent the source VPS from deleting the collector's archives, alert if the stream or heartbeat stops, and protect the logs against unauthorised access, since they often contain sensitive data.

Synchronise the clock

timedatectl status
timedatectl timesync-status 2>/dev/null || true
systemctl status systemd-timesyncd chrony --no-pager 2>/dev/null || true

Use a single, correctly configured synchronisation mechanism. Clock drift complicates event correlation, certificates, tokens and sometimes authentication.

12. Audit changes and integrity

Linux Audit

auditd collects events produced by the kernel's audit subsystem. Debian provides the tools to store and search those records, documented in the Debian 13 auditd manuals.

sudo apt install auditd audispd-plugins
sudo systemctl enable --now auditd
sudoedit /etc/audit/rules.d/50-local.rules

A minimal baseline:

-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k privilege
-w /etc/sudoers.d -p wa -k privilege
-w /etc/ssh/sshd_config -p wa -k ssh_config
-w /etc/ssh/sshd_config.d -p wa -k ssh_config

Load and verify:

sudo augenrules --check
sudo augenrules --load
sudo auditctl -s
sudo auditctl -l

sudo ausearch -k ssh_config -ts today -i
sudo aureport --auth --summary

Adapt the rules to the files that are genuinely critical. Excessive auditing fills the disk and makes the signal unusable. Configure rotation, a saturation alert and remote forwarding.

Package verification and AIDE

To list modified package files:

sudo dpkg -V

Output is not automatically a compromise: configuration files are legitimately modified. Compare them against your configuration management and your approved changes.

AIDE builds an integrity database:

sudo apt install aide
sudo aideinit
sudo aide --check

Initialise the database only on a server believed to be clean, and after hardening. Read the output of aideinit to confirm which database is active on your distribution. Copy the reference database, or at least its checksum, to a protected location off the VPS; otherwise an attacker with root can modify both the files and their reference.

Neither auditd nor AIDE is a full EDR. They add traces and integrity checks, provided the results are read and protected.

13. Protect files, keys and secrets

Look for anomalies without fixing them automatically:

sudo find /etc -xdev -type f -perm -0002 -ls
sudo find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -print

sudo find /root /home -path '*/.ssh/authorized_keys' -type f \
  -exec stat -c '%a %U:%G %n' {} \;

sudo find /etc/cron.d /etc/cron.daily /etc/cron.hourly \
  /etc/cron.monthly /etc/cron.weekly /var/spool/cron \
  -maxdepth 2 -type f -ls 2>/dev/null

systemctl list-timers --all --no-pager

An unusual permission is not necessarily malicious. Identify the package, the owner and the purpose before changing anything.

Basic rules: no secret in Git, in a Docker image, in a command visible in shell history or in a URL; secret files readable only by the account that needs them; distinct tokens per service and per environment; least privilege on the DNS, backup, database and API side; planned rotation, and immediate rotation after exposure; removal of stale authorized_keys; private keys never copied into documentation or a ticket.

For modern systemd services, consider systemd credentials rather than global environment variables. A variable can remain visible to privileged processes and may end up in a diagnostic dump.

14. Encrypt public services with TLS

HTTPS protects traffic between the client and the server; it does not fix a vulnerable application. For an Nginx or Apache site, the DNS A record and, if present, the AAAA record must point to the right server; TCP ports 80 and 443 must be open according to the ACME challenge; renewal must be automated and tested; an alert must precede expiry; and HSTS should only be enabled after HTTPS has been durably validated on the names concerned.

Follow our guide on installing a Let's Encrypt SSL certificate with Certbot, then verify:

sudo certbot certificates
sudo certbot renew --dry-run
systemctl list-timers '*certbot*' --all

openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -verify_return_error </dev/null

An HTTP to HTTPS redirect is useful for the browser, but keep port 80 reachable if HTTP-01 renewal depends on it. DNS-01 is required for a wildcard and can avoid that dependency, at the cost of a DNS API secret to protect.

15. Set up a genuinely restorable backup

ANSSI points out that backups are indispensable in the face of incidents and recommends in particular a 3-2-1 policy: three copies, on two different media, one of them offline. It also asks organisations to formalise the maximum acceptable data loss and the maximum acceptable downtime, in its measures framework and its guide on backing up information systems.

Distinguish archive, snapshot and backup

MechanismUseful forLimitation
tar archive on the same diska one-off copy of filesdisappears with the VPS or the disk
VPS snapshotfast rollback before a changesame provider account, application consistency to be verified
replication or synchronisationavailability or a fast copyreplicates deletion and corruption too
versioned off-site backuprecovery after failure, mistake or compromisemust be encrypted, monitored and restored
offline or immutable copyresistance to deletion and ransomwarerequires a retention policy and testing

Our VPS plans offer automatic offsite backups as an option: a complete copy of the VPS, taken daily, stored in a separate European datacenter, with one-click restoration and a rotating history whose depth you choose. Ten simultaneous backups therefore give you a ten-day history, each new copy replacing the oldest. This option already covers the off-server copy, the daily frequency and fast restoration.

It does not, however, remove the need for the two precautions that follow. First, a complete copy of a running VPS is not necessarily consistent for a database: keep producing a dump just beforehand, as described below. Second, it remains driven from the same provider account as the server; for the compromise scenario, also keep a copy in a separate administrative domain, append-only or offline depending on the risk. And in every case, test the restore: a ten-day history is only worth something once you have verified that a restore actually brings the application back up.

Define the objective before the tool

DataRPORTORetention
system configuration24 h4 h30 days
customer files1 h2 h30 days and 12 months
transactional database15 min1 hlogs and daily backups
security logsper policyimmediate consultation90 days or applicable requirement

These values dictate the frequency and the technology. A daily backup does not satisfy a fifteen-minute RPO.

Example with restic to a remote destination

restic encrypts and deduplicates backups. It supports several backends; this example uses SFTP. Install the distribution package:

sudo apt update
sudo apt install restic

sudo install -d -m 700 /root/.config/restic
sudo touch /root/.config/restic/password
sudo chown root:root /root/.config/restic/password
sudo chmod 600 /root/.config/restic/password
sudoedit /root/.config/restic/password

sudo touch /etc/restic-vps.env
sudo chown root:root /etc/restic-vps.env
sudo chmod 600 /etc/restic-vps.env
sudoedit /etc/restic-vps.env

Contents of /etc/restic-vps.env:

RESTIC_REPOSITORY=sftp:backup@backup.example:/srv/restic/vps-01
RESTIC_PASSWORD_FILE=/root/.config/restic/password

The passphrase must be long, random, stored in a separate manager and tested. Without it, the encrypted repository is not restorable.

Initialise manually from a root session, after validating the SSH fingerprint of the backup server:

sudo -i
set -a
. /etc/restic-vps.env
set +a

restic init
restic backup /etc /srv /var/www /var/backups/databases
restic snapshots
restic check
exit

Backing up the data directory of a live database is not necessarily consistent. Produce a dump, or use the physical method and the logs recommended by the engine.

For MariaDB, where local root socket authentication is configured:

sudo install -d -m 700 /var/backups/databases
sudo sh -c 'mariadb-dump --single-transaction --routines --events --triggers --all-databases | gzip -9 > "/var/backups/databases/mariadb-$(date -u +%F-%H%M%S).sql.gz"'

For PostgreSQL:

sudo install -d -m 700 -o postgres -g postgres \
  /var/backups/databases/postgresql
sudo -u postgres sh -c 'pg_dumpall | gzip -9 > "/var/backups/databases/postgresql/all-$(date -u +%F-%H%M%S).sql.gz"'

Test the restore on an isolated database. --single-transaction improves the consistency of MariaDB/InnoDB transactional tables, but it is not a universal guarantee for every engine and every operation.

Automate with a systemd timer

Create /etc/systemd/system/restic-backup.service:

[Unit]
Description=restic backup of the VPS
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic-vps.env
ExecStart=/usr/bin/restic backup /etc /srv /var/www /var/backups/databases
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

Create /etc/systemd/system/restic-backup.timer:

[Unit]
Description=Daily restic backup of the VPS

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target

Enable and check:

sudo systemd-analyze verify \
  /etc/systemd/system/restic-backup.service \
  /etc/systemd/system/restic-backup.timer

sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
sudo systemctl start restic-backup.service

sudo systemctl status restic-backup.service --no-pager
sudo journalctl -u restic-backup.service -n 100 --no-pager
systemctl list-timers restic-backup.timer --all

The database dump must be produced just before this service, by a dedicated unit ordered with Requires and After, or by a backup orchestrator. An old dump backed up every night creates an illusion of freshness.

Separate writing from deletion

A compromised VPS should not be able to erase old backups. Where the backend allows it, give the VPS append-only access. Run retention from a separate administration machine that holds the deletion rights:

restic forget \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 12 \
  --prune

restic check --read-data-subset=5%

The restic documentation on forget and prune notes that prune can be lengthy, locks the repository and should be scheduled. It also recommends a check after pruning. For an append-only repository, study the specific risks of retention policies and genuinely separate the maintenance client.

Test a restore

At least quarterly, on an isolated machine:

sudo -i
set -a
. /etc/restic-vps.env
set +a

restic snapshots
install -d -m 700 /mnt/restic-restore-test
restic restore latest --target /mnt/restic-restore-test
exit

Then check the presence and permissions of the files, decryption with the documented credentials, restoring the databases into a test engine, the application starting, functional consistency, the actual restore time compared with the RTO, and the procedure for rebuilding a bare VPS.

A "successful" backup means the process finished. A reliable backup means a useful restore has been demonstrated.

16. Monitor the VPS from outside

Local monitoring cannot report a VPS that is powered off, isolated or fully compromised. Use an external service for availability and an alerting channel separate from the server.

CheckSignal to watch
availabilityTCP/HTTPS, expected content, latency
TLSexpiry date, chain and name
systemCPU, memory, swap, load
storagespace, inodes, log growth
servicesfailed systemd units, restarts
networknew ports, unusual connections
securitySSH and sudo failures, AppArmor denials, auditd
patchinglast run, failure, reboot required
backupsage of the last snapshot, abnormal size, failure
timedrift or loss of synchronisation

Daily check commands:

systemctl --failed --no-pager
df -hT
df -ih
free -h
uptime

sudo ss -lntup
sudo journalctl -p warning..alert --since '-24 hours'
sudo journalctl -u ssh --since '-24 hours'
sudo fail2ban-client status sshd 2>/dev/null || true
sudo aa-status 2>/dev/null || true

Thresholds must reflect the service. An alert at 80% disk is only useful if it arrives early enough relative to the growth rate. Alert on the absence of data too: a silent agent may have failed.

ANSSI defines security monitoring as the set of means that allow an organisation to detect, qualify and choose an appropriate response; its guide on running a monitoring project places the tools within that process.

17. Verify the result instead of trusting the configuration

Local checks

# System and patches
cat /etc/os-release
apt list --upgradable
systemctl list-timers 'apt-daily*' --all

# Effective SSH configuration
sudo sshd -t
sudo sshd -T | grep -E '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|allowgroups) '

# Network exposure
sudo ss -lntup
sudo ufw status verbose 2>/dev/null || true
sudo nft list ruleset 2>/dev/null || true

# Security controls
sudo aa-status 2>/dev/null || true
sudo fail2ban-client status 2>/dev/null || true
sudo auditctl -s 2>/dev/null || true

# Journal and services
sudo journalctl --verify
systemctl --failed --no-pager

# Backup
sudo systemctl status restic-backup.service --no-pager

External scan

From a workstation you administer, and only against your own VPS:

sudo nmap -Pn -sS -sV -p- VPS_IPV4_ADDRESS
sudo nmap -6 -Pn -sT -sV -p- VPS_IPV6_ADDRESS

Compare the result with the list of authorised ports. The option reference is in the Nmap manual.

Run the scan after the first hardening pass, after installing Docker or a control panel, after a firewall change, after a network migration, and regularly from the Internet.

Acceptance table

TestExpected resultEvidence to keep
SSH login with a keysuccess with a named accountanonymised SSH log
root SSH loginrefusedexcerpt from the test
SSH passwordrefusedcommand and result
sudosuccess for the authorised administratorsudo log
SSH syntaxsshd -t with no errordate and version
external portsonly the approved flowsNmap report for IPv4 and IPv6
automatic updatesvalid dry run, active timerlog
firewallpolicy and rules as intendedUFW or nftables export
AppArmoractive, critical profiles in enforceaa-status output
backuprun with no errorlog and restic snapshot
restoreapplication and data readabledated written record
remote logsevent received and timestampedsearch on the collector side
rescue consoleconnection possibledate of the last test

18. What to do if you suspect a compromise

Symptoms such as a CPU spike, an unknown process or an unusual outbound connection justify qualification, but on their own they do not prove an intrusion. Conversely, the absence of symptoms does not prove integrity.

First actions, to be adapted to the severity:

  1. use a trusted workstation and open an incident log;
  2. isolate the VPS with the network firewall when that does not destroy evidence;
  3. avoid rebooting or "cleaning up" before collection if an investigation is planned;
  4. preserve the remote logs, the state of processes, connections, scheduled tasks and a forensic snapshot;
  5. from a healthy system, revoke the exposed SSH keys, passwords, tokens, certificates and provider access;
  6. protect earlier backups and prevent their deletion;
  7. determine the scope: other VPS instances, Git repository, CI/CD, DNS, accounts and databases;
  8. rebuild from an image and a configuration believed to be clean;
  9. restore only data you have checked;
  10. fix the cause before going back online, and increase monitoring.

A snapshot of the suspect server serves to preserve or analyse a state; it does not automatically become a clean restore point. CERT-FR recommends in particular preserving evidence, limiting the spread, securing backups and replacing a compromised system with a new or reinstalled one. See the right reflexes in case of intrusion.

NIST SP 800-61 Revision 3, published in April 2025, embeds incident response within overall risk management: preparation, detection, response and recovery do not begin on the day of the attack.

Recommended operating calendar

FrequencyActions
continuousexternal availability, metrics, security alerts, backup failures
dailysecurity updates, backup, check for failed services
weeklyreview of alerts, ports, disk space, accounts and changes
monthlyreview of repositories, packages, keys, firewall rules and versions
quarterlyfull tested restore, rescue console, incident exercise
twice a yearreview of architecture, privileges, RPO/RTO and external exposure
at every changebackup, validation, external scan, documentation and rollback plan

For several VPS instances, manage this baseline with Ansible, a versioned repository and a CI pipeline that tests the syntax. Secrets must stay out of the repository. A declarative configuration does not replace testing on the real environment, but it makes deviations and rollbacks more controllable.

Common mistakes

  1. Closing SSH before testing the key. Keep a session open and the provider console at hand.
  2. Opening UFW for port 22 when SSH listens elsewhere. Check sshd -T.
  3. Naming an SSH fragment so it loads late. The first value often wins; check the effective configuration.
  4. Forgetting IPv6. Scan and filter IPv4 and IPv6 separately.
  5. Believing UFW automatically filters Docker. Published ports require a specific check.
  6. Installing Fail2ban without checking the jail. Verify status sshd and the logs.
  7. Enabling a generic sysctl. A network setting can break a VPN, routing or containers.
  8. Backing up to the same disk. A failure or a compromise takes the original and the copy together.
  9. Never restoring. A green job guarantees neither the password, nor consistency, nor the timeframe.
  10. Keeping all logs locally. Root can erase them; forward the critical events.
  11. Running the application as root. An application flaw immediately becomes a system compromise.
  12. Confusing upstream version with CVE status. Check the distribution's tracker.

Frequently asked questions

Does changing the SSH port secure the VPS?

It reduces opportunistic scan noise and log volume, but it adds no authentication barrier. A protected key, a non-root account, network restriction and patches all come first.

Is Fail2ban still useful when SSH passwords are disabled?

It can reduce noise and some repeated attempts. Its importance decreases if SSH is reachable only through a VPN or from an address list, but it replaces none of those measures.

Should you install an antivirus on Linux?

It depends on the service. ClamAV can scan uploaded files or mail. An EDR agent can bring telemetry and response on a sensitive server. No antivirus compensates for an unpatched system, an application running as root or exposed secrets.

UFW or nftables?

UFW suits a simple policy and is the default tool documented by Ubuntu. nftables is the native framework recommended by Debian and offers more control. Choose a single owner of the ruleset and take Docker into account.

Can IPv6 be disabled?

That is not a generic hardening measure. Filter and monitor IPv6 as you do IPv4. Disabling IPv6 can break dependencies and mask a poor understanding of the network.

Is a provider snapshot enough?

A snapshot is valuable for a fast rollback, but it usually stays within the same administrative domain and its application consistency must be verified. Also keep a versioned off-site backup, with an offline or immutable copy depending on the risk. Our optional offsite backups go further than a plain snapshot, since they are daily and stored in another datacenter, but they are still driven from your customer account: against the compromise risk, keep a copy in a separate administrative domain.

How do you know whether the VPS is compromised?

There is no single command. Correlate connections, processes, accounts, keys, scheduled tasks, remote logs, file changes, traffic and alerts. In case of serious doubt, preserve the evidence, revoke the secrets and rebuild from a clean base.

Can automatic updates break production?

Every change carries risk. The risk of not applying a patch is often greater, but a critical application calls for staging, maintenance windows, targeted holds where justified, monitoring and a rollback plan. Ubuntu notes that unattended-upgrades is not a complete fleet management tool.

Does this guide make the VPS ANSSI or CIS compliant?

No. It draws on recognised principles and sources, but compliance requires a scope, a level, evidence, exceptions and an audit. Benchmarks must be adapted to the server's role; applying them automatically can interrupt legitimate functions.

Final checklist

Identities and SSH

  • one named account per administrator;
  • protected Ed25519 or FIDO2 keys;
  • sudo tested;
  • PermitRootLogin no in effect;
  • password and keyboard-interactive disabled if unused;
  • SSH group explicitly authorised;
  • configuration validated with sshd -t and sshd -T;
  • rescue console tested.

System and network

  • distribution still maintained;
  • patches installed and automation monitored;
  • third-party repositories inventoried;
  • IPv4 and IPv6 ports justified;
  • local firewall active;
  • network firewall configured where available;
  • Docker publications verified from the Internet;
  • unnecessary services disabled.

Impact reduction

  • applications under dedicated accounts;
  • AppArmor active and denials reviewed;
  • systemd services confined according to their needs;
  • least-privilege secrets;
  • permissions and keys reviewed;
  • sysctls adapted to the role, with no blind copying.

Detection and recovery

  • persistent logs with a space limit;
  • synchronised clock;
  • critical events sent off the VPS;
  • auditd and AIDE where the risk warrants it;
  • external monitoring and a separate alerting channel;
  • encrypted, versioned and off-site backups;
  • separate deletion rights or immutable storage;
  • real restore tested and timed;
  • incident procedure and contacts available offline.

Going further

This guide is the cross-cutting baseline. The detailed procedures live in our dedicated guides: set up an SSH key, configure UFW, install Fail2ban, install Docker and install a Let's Encrypt certificate with Certbot. If you are coming from shared hosting, our guide on migrating a site to a VPS describes the full switchover.

Main sources

General recommendations

Debian and Ubuntu

Components