Build a ClamAV Database Mirror with Ansible (Part 6)
How I built an internal ClamAV mirror using Nginx to serve virus definitions, cutting 7.2GB/day of redundant egress traffic.
Published · Republished on Medium

Every endpoint updating directly from database.clamav.net duplicates some internet traffic. The original 7.2 GB/day figure was an upper-bound estimate—200 MB × 3 agents × 12 checks—not a measured transfer total. FreshClam normally checks headers and may use incremental updates, so measure interface or proxy bytes before and after instead of presenting that estimate as observed savings.
Files in this Post
text
files/clamav/
├── default/
│ └── freshclam.base.conf → Shared FreshClam config
├── database/default/
│ └── nginx-default.conf → Serves /var/lib/clamav on port 80
└── client/<host>/
└── freshclam.conf → Per-host override (optional)config
roles/
├── clamav_database/ → Install FreshClam + Nginx
└── clamav_freshclam_client/ → Set PrivateMirror on agentsconfig
playbooks/
└── wazuh-clamav-onaccess.yml → Client + On-Access togetherDepends on: Part 5 (rules for 52507 alert)
Architecture
text
Public Internet
│
database.clamav.net
│
┌──────────▼──────────┐
│ clamav_database │
│ (10.148.1.125) │
│ │
│ freshclam ──► pulls signatures once
│ │ │
│ ▼ │
│ /var/lib/clamav/ │
│ │ │
│ Nginx :80 │──► serves to internal network
└─────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────────▼─────┐ ┌──────▼───────┐ ┌──────▼───────┐
│ keycloak │ │ agent-01 │ │ agent-02 │
│ freshclam │ │ freshclam │ │ freshclam │
│ client │ │ client │ │ client │
│ │ │ │ │ │
│ PrivateMirror │ │PrivateMirror │ │PrivateMirror │
│ = 10.148.1.125│ │= 10.148.1.125│ │= 10.148.1.125│
└───────────────┘ └──────────────┘ └──────────────┘
No public egress from agents. All definitions from one internal mirror.The Database Server Role
Three steps — install packages, deploy config, start services:
yaml
- name: Install packages
ansible.builtin.package:
name: [clamav, clamav-freshclam, nginx]
- name: Deploy freshclam.conf
ansible.builtin.copy:
content: |
{{ _base | trim }}
{% if _host and _host | trim %}
{{ _host | trim }}
{% endif %}
dest: /etc/clamav/freshclam.conf
mode: "0644"
- name: Deploy nginx site
ansible.builtin.copy:
src: "{{ _nginx }}"
dest: /etc/nginx/sites-available/default
mode: "0644"
notify: restart clamav database servicesThe Nginx configuration serves /var/lib/clamav. Directory listing is not required for FreshClam and can be disabled to reduce unnecessary exposure:
nginx
server {
listen 10.148.1.125:80;
root /var/lib/clamav;
autoindex off;
location / {
limit_except GET HEAD { deny all; }
try_files $uri =404;
}
}The database contents are public, but integrity and availability still matter. Restrict the listener with host and network firewalls to approved clients, prevent writes by the web-server user, protect DNS and routing, and prefer authenticated TLS where traffic crosses an untrusted network. A malicious or stale mirror can weaken detection even when it exposes no secret.

The Client Role
One critical task — replace DatabaseMirror with PrivateMirror:
yaml
- name: Set PrivateMirror
ansible.builtin.lineinfile:
path: /etc/clamav/freshclam.conf
regexp: '^(PrivateMirror|DatabaseMirror)\s+'
line: "PrivateMirror {{ _mirror }}"
when: _mirror | length > 0
notify: restart freshclam clientThe mirror IP resolves dynamically from inventory — stored as _mirror:
yaml
_mirror: >-
{{ hostvars[groups['clamav_database'][0]].private_ip if 'clamav_database' in groups and groups['clamav_database'] | length > 0 else '' }}Skip on the database host itself. The clamav_freshclam_client role has a guard:
yaml
- role: clamav_freshclam_client
when: inventory_hostname not in groups['clamav_database']The database host pulls from the internet — not from itself. Client hosts pull from the mirror. No self-referencing loop.

Verification
bash
# Check clamav database config
$ grep DatabaseMirror /etc/clamav/freshclam.conf
DatabaseMirror database.clamav.net
# Check client config
$ grep PrivateMirror /etc/clamav/freshclam.conf
PrivateMirror 10.148.1.125
# Check updates are flowing
$ tail -3 /var/log/clamav/freshclam.log
Tue Jun 16 05:47:00 2026 -> ClamAV update process started
Tue Jun 16 05:47:08 2026 -> Database updated (3627875 signatures)
# Browse mirror from any agent
$ curl -s http://10.148.1.125/ | grep cvd
<a href="daily.cvd">daily.cvd</a> 58.2M
<a href="main.cvd">main.cvd</a> 132.4M
<a href="bytecode.cvd">bytecode.cvd</a> 283KWazuh alert — rule 52507 fires on every database update:
json
{
"rule": {"id": "52507", "level": 3, "description": "ClamAV database update"},
"agent": {"name": "keycloak"},
"location": "/var/log/syslog"
}Investigating a 404 Warning
bash
WARNING: remote_cvdhead: file not found: http://10.148.1.125/bytecode.cldA missing .cld can be followed by a successful full database fetch in this mirror design, but do not blanket-ignore HTTP 404 responses. Confirm in the same update run that FreshClam exits successfully, downloads a valid .cvd or .cld, and reports a current database version. A 404 for daily.cvd, repeated fallback downloads, or an unchanged database age is an incident.

Freshness, Integrity, and Failure Behavior
ClamAV's private-mirror guide requires an HTTP server that supports Range requests; otherwise each update check may download the whole database and erase the expected bandwidth benefit. It also recommends PrivateMirror with ScriptedUpdates no when the mirror serves only complete CVD/CLD files.
On the mirror and clients, alert on FreshClam failure, database age beyond the approved threshold, unexpected file ownership, invalid database loading, and sustained 4xx/5xx responses. Verify database metadata with the installed ClamAV tooling and run a harmless EICAR detection check after controlled updates. Publish only files that FreshClam has finished writing; do not expose partial temporary files.
Define client behavior for a stale or unavailable mirror. Retaining the last known-good signed database is safer than deleting it, but scanning indefinitely with old signatures must page an operator. Keep a tested emergency configuration that restores the official mirror, subject to egress policy, and roll it out in batches to avoid a public-CDN thundering herd.
Measure savings from web-server access logs or network-byte counters over comparable windows. Report update checks, response status, Range behavior, bytes served, full downloads, signature versions, and client count; that distinguishes actual transfer from the original arithmetic estimate.
Why Not Just Use the Public Mirror?

Deploy
bash
# Database server — freshclam pulls from internet, nginx serves to agents
ansible-playbook -i inventories/lab/hosts.ini \
playbooks/wazuh-clamav-database.yml --limit clamav_database
# Agents — freshclam_client pulls from internal mirror (auto-skips on db host)
ansible-playbook -i inventories/lab/hosts.ini \
playbooks/wazuh-agent.yml --limit keycloak,clamav_databaseNext: Part 7 — Real-Time Malware Blocking with ClamAV On-Access