Blog archive

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

DevOpsSecurityAnsibleWazuhMalware Detection

Picture by Alexey Demidov on Unsplash

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 agents

config

playbooks/
└── wazuh-clamav-onaccess.yml        → Client + On-Access together
Depends 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 services

The 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.

Screenshot from Wazuh Ansible Series Part 6: Building a Centralized ClamAV Database Mirror with Ansible

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 client

The 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.

Client update freshclam database

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> 283K

Wazuh 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.cld

A 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.

Screenshot from Wazuh Ansible Series Part 6: Building a Centralized ClamAV Database Mirror with Ansible

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?

Screenshot from Wazuh Ansible Series Part 6: Building a Centralized ClamAV Database Mirror with Ansible

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_database

Next: Part 7 — Real-Time Malware Blocking with ClamAV On-Access

References