How to scan a PHP website for malware with ClamAV on Ubuntu or Debian

ClamAV is a free virus scanner. It can check your site's folders every night and tell you when it finds a known web shell or other known malware. Here is how to set it up, what it costs in memory, and what it can't find.

A clean scan doesn't mean a clean site. ClamAV finds malware that is already known. A small backdoor written just for your site is usually missed. In our test, it found the test virus but not a one-line PHP backdoor sitting right next to it. Use it as one check among several, not as proof.

What ClamAV is good and bad at

  • Good at: known web shells, known malware kits, infected files that attackers upload again and again, and viruses in files people upload to your site.
  • Bad at: new or hand-made PHP backdoors, bad code added to a real plugin file, spam links in your database, and changes to settings. Those need other checks: WordPress file checks with WP-CLI, a look for PHP files in uploads, and your logs.

Before you start

  • Memory. ClamAV loads its whole virus list into memory, which takes about 1 GB. In our tests, the scanner service (clamd) used 1.0 to 1.1 GB all the time. The one-off scanner (clamscan) used about 1 GB too, but only while it ran. Check what you have with free -m (the available column).
  • Which one to use: with 4 GB of memory or more, run the service for fast scans (step 5). With less, skip the service and run clamscan once a night when the site is quiet. With under about 1.5 GB available, ClamAV will push other things out of memory. Don't run it there.
  • We tested on Ubuntu 24.04 (ClamAV 1.5.4) and Debian 13 (ClamAV 1.4.3), with a fresh WordPress of 115 MB and 3,780 files. The site is called shop and lives in /var/www/shop. Change the names to yours.

1. Install ClamAV

sudo apt update
sudo apt install clamav clamav-daemon

clamav is the scanner. clamav-daemon adds the always-on service. It also pulls in clamav-freshclam, which downloads the virus list. On a small server, leave out clamav-daemon.

2. Keep the virus list up to date

Right after install, freshclam downloads the virus list (about 110 MB). This took one to two minutes in our tests. Watch it finish:

sudo tail -f /var/log/clamav/freshclam.log

Wait for lines such as main.cvd updated and bytecode.cvd updated, then press Ctrl+C. After that it checks for updates every hour or so.

Make sure it starts again after a reboot. In our tests on both Ubuntu and Debian, the update service was left turned off for the next boot. After a restart, the virus list quietly stopped updating. Turn it on:

sudo systemctl enable --now clamav-freshclam
systemctl is-enabled clamav-freshclam

It should say enabled. Don't run sudo freshclam by hand while the service runs. It stops with Failed to lock the log file, because the service already holds it. That error is harmless.

3. Check that it works with a test file

EICAR is a harmless test file that every virus scanner reports as a virus. Put one in your uploads folder:

printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' | sudo tee /var/www/shop/wp-content/uploads/eicar.com >/dev/null

Keep the single quotes, or the shell changes the text and the test fails. You'll use this file in the next steps, so the scans have something to find. Delete it when you're done (step 7).

4. Scan a site folder

sudo clamscan -ri /var/www/shop

-r goes into every folder below. -i (the same as --infected) lists only the files it finds something in. With the test file in place, you should see:

/var/www/shop/wp-content/uploads/eicar.com: Eicar-Test-Signature FOUND
...
Infected files: 1

The exit code tells a script what happened: 0 means nothing found, 1 means something found, and 2 means an error, such as a folder it couldn't read.

How long it takes: our 115 MB WordPress took 75 to 80 seconds, and about 10 seconds of that was loading the virus list. clamscan uses one CPU core, so a big site takes a while. Fine for a nightly scan, slow for checking often.

5. Faster scans with the clamd service

Skip this step on small servers. The service keeps the virus list in memory, so scans start at once and can use every core. After the first install, it doesn't start by itself, because the virus list wasn't there yet. Start it now:

sudo systemctl start clamav-daemon
systemctl status clamav-daemon

It took about 11 seconds to load. Then scan with clamdscan:

sudo clamdscan --fdpass -m -i /var/www/shop
  • --fdpass lets the service read files that only root or the site's user can open. Without it, the service runs as the clamav user and stops with Permission denied on private files, such as wp-config.php or .env.
  • -m scans with several CPU cores at once.

In our tests, the same site took 12 to 20 seconds for the first scan, then 1 to 1.5 seconds on a server with 9 cores (6.7 seconds without -m). That speed costs about 1 GB of memory all day. freshclam tells the service when a new virus list arrives, so you don't need to restart it.

6. Leave out big folders, carefully

Attackers like to hide files in folders that nobody checks. So leave out only folders that the web server never runs code from and that visitors can't write to. Good examples are node_modules (used only to build your CSS and JavaScript) and cache folders that you can delete and rebuild.

Never leave out vendor, wp-content/uploads, wp-content/plugins or storage. vendor is PHP code that runs on every page, and uploads is where attackers drop web shells.

With clamscan, add --exclude-dir. It takes a pattern, so end it with $ to match the folder name exactly:

sudo clamscan -ri --exclude-dir='/node_modules$' /var/www/shop

We checked this: a test file in node_modules was skipped, but one in a folder called node_modules_backup was still found. Without the $, both would be skipped.

With clamdscan, the list lives in the service's settings. Add a line to /etc/clamav/clamd.conf, then restart it with sudo systemctl restart clamav-daemon:

ExcludePath ^/var/www/shop/node_modules/

7. Scan every night and hear about it only when something is found

This script prints nothing when all is clean. When it finds something, or when the scan fails, it writes the result to the system log and sends you an email. Save it as /usr/local/bin/site-scan:

#!/bin/sh
# Scan website folders with ClamAV. Prints nothing when all is clean.
DIRS="/var/www/shop"
MAILTO="you@example.com"

out=$(clamscan -ri --no-summary --exclude-dir='/node_modules$' $DIRS 2>&1)
rc=$?

if [ "$rc" -eq 0 ]; then
  exit 0
elif [ "$rc" -eq 1 ]; then
  subject="$(hostname): ClamAV found something"
else
  subject="$(hostname): ClamAV scan failed"
fi
echo "$subject"
echo "$out"
if command -v mail >/dev/null; then
  echo "$out" | mail -s "$subject" "$MAILTO"
fi
exit "$rc"

Put several sites in DIRS with spaces between them. If you run the clamd service, change the out= line to use it:

out=$(clamdscan --fdpass -m -i --no-summary $DIRS 2>&1)
sudo chmod 755 /usr/local/bin/site-scan

Now the service that runs it, /etc/systemd/system/site-scan.service. It runs as root so it can read every site, but ReadOnlyPaths means it can't change anything in /var/www. It also runs at low priority, so your site stays fast:

[Unit]
Description=Scan website folders with ClamAV

[Service]
Type=oneshot
ExecStart=/usr/local/bin/site-scan
Nice=19
IOSchedulingClass=idle
ReadOnlyPaths=/var/www
PrivateTmp=yes

If you'd rather not use root, add User=shop to scan as the site's own user. That works for one site, since that user can read only its own files.

And the timer, /etc/systemd/system/site-scan.timer:

[Unit]
Description=Daily ClamAV scan of website folders

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

[Install]
WantedBy=timers.target

Turn it on, then run one scan now while the test file is still there:

sudo systemctl daemon-reload
sudo systemctl enable --now site-scan.timer
sudo systemctl start site-scan.service
sudo journalctl -u site-scan.service -n 20

The scan "fails" on purpose when it finds something, so systemctl start reports an error. The log shows the server name, ClamAV found something and the file. systemctl --failed also lists site-scan.service until the next clean scan. systemctl list-timers site-scan.timer shows the next run.

Email needs a server that can send mail. The script only emails if the mail command exists (package mailutils or bsd-mailx) and the server can send. This is the same as in our disk warning guide. If it can't, change the mail line to send the text to a chat or alert service you already use, for example with curl. We tested that the script calls mail only when something is found, but not the delivery itself.

Now delete the test file and check that the next scan is quiet:

sudo rm /var/www/shop/wp-content/uploads/eicar.com
sudo systemctl start site-scan.service && echo clean

8. When the scan finds something

  • Don't delete the file straight away. Write down its name, path and date, and keep a copy (ls -l shows when it changed). The file is a clue: it shows when the attacker got in and where to look.
  • Don't use --remove or --move. Removing one file doesn't remove the way in, and attackers usually leave more than one file. A found web shell means you should treat the site as hacked.
  • Then follow our first steps for a hacked PHP site: take a copy, close the site, find what else changed, clean up and change every password.

False alarms

Sometimes ClamAV flags a normal file, for example a big JavaScript bundle or a zip file in your backups. Before you decide it's a false alarm:

  • Look at where the file came from. Is it part of a plugin or package you installed, and does it match the official copy? For WordPress, wp plugin verify-checksums --all answers that (see our WordPress guide).
  • For a public file, such as a file from a plugin or package, you can compare its checksum (sha256sum file) with what other virus scanners say on an online checking site. Search by checksum. Don't upload your own files there: uploaded files are shared, and your code can hold passwords and keys.
  • If you're sure it's a false alarm, report it to ClamAV with their false positive form. Again, only send files that aren't private.

Don't leave the whole folder out of the scan just to silence one alarm.

Scanning files as they arrive

ClamAV can also check files the moment they're written, with a tool called clamonacc. It needs the clamd service, more setup and careful testing, because a mistake can block your site from writing files. We didn't test it for this guide. For most sites, a nightly scan plus the other checks is enough. See On-Access Scanning in the ClamAV docs.

If something goes wrong

  • clamdscan says it can't connect: the service isn't running or is still loading. Run sudo systemctl start clamav-daemon and wait a few seconds.
  • Permission denied ... ERROR: add --fdpass to clamdscan, or run clamscan with sudo.
  • The server got slow or the site went down during a scan: it ran out of memory. Check with sudo journalctl -k | grep -i oom. Stop the clamd service and use clamscan at night, or add memory.
  • The virus list is old: sudo tail /var/log/clamav/freshclam.log shows the last update. If nothing is new after a reboot, run the enable command from step 2.
  • No email arrives: check the log with sudo journalctl -u site-scan.service. If the scan found something there, the problem is mail, not ClamAV.

Official sources

Next steps: stop PHP from running in uploads and check WordPress files with our WordPress hardening guide, and set up backups you've tested, so a hit means a restore, not a rebuild.

ServerNest is in development. Daily ClamAV scans of each app's folders, with a warning when something is found, are a planned feature. Join early access to try it first.

← Back to the security blog