Implementing ClamAV for Uploaded File Scanning

Run clamd as a resident daemon in its own container, give it a signature volume it does not have to re-download on every boot, share a Unix socket with your scanner process, and refuse to mark anything clean until the daemon has answered a PING and reported a database younger than 24 hours.

This article sits under automated virus scanning integration inside backend validation and cloud storage architecture. The parent guide covers the wire protocol and the queue-driven worker; this one covers the thing the worker talks to — how to build, size, feed and health-check the engine itself.

When to use this approach

  • You have somewhere to run a container that stays up. clamd costs 1.3 GB of resident memory and 30–40 seconds of boot before it scans a single byte, so it only pays for itself when it is reused across thousands of objects.
  • Your upload volume is steady rather than spiky. A daemon that idles at 1.4 GB for eight hours a night is cheaper than 40-second cold starts, but only above roughly two scans per minute. Below that, serverless virus scanning with AWS Lambda is the better shape.
  • You need scans of objects larger than a few hundred megabytes. A long-lived container has no 15-minute execution ceiling and no 10 GB ephemeral-storage ceiling, which matters once you are ingesting the kind of files described in best practices for handling 500MB file uploads.

Prerequisites

  1. A container runtime with at least 3 GiB of memory and 2 vCPU available to the scanning task — Docker Compose locally, ECS Fargate or Kubernetes in production.
  2. ClamAV 1.4.2 or later. Versions before 1.0 lack ConcurrentDatabaseReload and pause every scan during an update.
  3. A persistent volume for /var/lib/clamav — an EFS access point, a Kubernetes PVC, or a named Docker volume. Roughly 1.4 GB, growing about 40 MB a month.
  4. Node 20+ (Node 22 for stable node:net typings) and TypeScript 5.4 with "module": "NodeNext" if you are typing the client.
  5. Outbound HTTPS to database.clamav.net, or an internal mirror you control.

Where the daemon actually runs

The mistake that costs the most time is treating clamd as a library your application links against. It is a separate process with its own lifecycle, its own memory ceiling and its own failure modes, and the only sane deployment is two containers in one task sharing a socket. If they share a process, an OOM kill of the engine takes your queue worker with it and the in-flight message is redelivered with no diagnostic.

Container topology for a resident ClamAV scanner One task holds a Node queue-worker container and a clamd container. They share a tmpfs volume carrying the Unix socket. The clamd container mounts a persistent signature volume that a separate freshclam job refreshes every three hours. The worker long-polls an SQS queue outside the task. scanner (Node 22) queue worker clamd 1.4.2 MaxThreads 4 EFS /var/lib/clamav 1.4 GB of signatures /var/run/clamav/clamd.sock — tmpfs volume shared by both containers, mode 0660 ECS task — 2 vCPU, 4 GB RAM SQS av-scan 20 s long poll freshclam job every 3 h, then RELOAD receive / delete write
Two containers, one socket. The signature volume is the only piece that must survive a task replacement.

Put the socket on a tmpfs volume, never on the persistent one. A stale socket inode left behind on a network filesystem after an ungraceful stop makes the next clamd refuse to bind, and you will spend an afternoon on it.

What the daemon does with your memory

Sizing the task is the single decision that determines whether this works. clamd parses main.cvd, daily.cld and bytecode.cvd at boot — about 8.7 million signatures compiled into in-memory automata — and holds the result resident for its whole life. Nothing you configure makes that smaller except loading fewer databases.

The part teams miss is the reload. With ConcurrentDatabaseReload yes (the default since 0.104, and worth keeping) the daemon builds the new database before freeing the old one, so peak RSS is roughly double steady state for three to six seconds. Size the container for the peak, not the plateau.

Resident memory of a clamd container over three minutes Memory climbs from 40 MB to 1.35 GB over the first 38 seconds while signatures load, holds flat while scanning, then spikes to about 2.6 GB for a few seconds during a concurrent database reload before settling at 1.4 GB. clamd resident memory, one container container limit — 2.8 GB 2 GB 1 GB 0 0 s 60 s 120 s 180 s 38 s: 8.7 M signatures loaded, PING answers reload peak 2.6 GB — both databases resident scans run in the flat region; object size barely moves RSS
Provision for the reload spike. A 2 GB limit survives boot and then dies at the first signature update.

Object size does not move that line much: clamd streams through a 64 KiB window and only buffers whole members when it decompresses an archive. Memory blows up on structure, not bytes — which is the same property attackers lean on in detecting and blocking zip bomb uploads.

Implementation

Build an image with the database already in it

Downloading 1.4 GB on every task start is both slow and a good way to get rate-limited. Bake a snapshot into the image and let freshclam apply only the incremental CDIFFs afterwards.

# syntax=docker/dockerfile:1
FROM debian:bookworm-slim AS db
RUN apt-get update && apt-get install -y --no-install-recommends clamav-freshclam ca-certificates \
    && rm -rf /var/lib/apt/lists/*
COPY freshclam.conf /etc/clamav/freshclam.conf
# Fail the build if the snapshot cannot be fetched — never ship an empty database.
RUN freshclam --config-file=/etc/clamav/freshclam.conf --stdout \
    && test -s /var/lib/clamav/daily.cld -o -s /var/lib/clamav/daily.cvd

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends clamav-daemon ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && mkdir -p /var/run/clamav && chown clamav:clamav /var/run/clamav
COPY --from=db --chown=clamav:clamav /var/lib/clamav /var/lib/clamav
COPY clamd.conf /etc/clamav/clamd.conf
USER clamav
# --foreground keeps PID 1 alive so the orchestrator sees a real process to supervise.
CMD ["clamd", "--foreground", "--config-file=/etc/clamav/clamd.conf"]

The test -s guard is not decoration. Without it a CDN outage produces an image that boots, logs LibClamAV Error: cli_loaddbdir(): No supported database files found in /var/lib/clamav, exits 1, and gets restarted forever while your queue silently backs up.

The clamd.conf lines a container needs

The engine limits — StreamMaxLength, MaxScanSize, MaxRecursion and friends — are tabulated in the parent guide’s configuration reference. These are the ones that are specifically about running in a container:

# /etc/clamav/clamd.conf
LocalSocket /var/run/clamav/clamd.sock
LocalSocketMode 660
FixStaleSocket yes
Foreground yes
LogFile /dev/stdout
LogTime yes
LogVerbose no
DatabaseDirectory /var/lib/clamav
# One thread per vCPU you actually granted the task; each one can peg a core.
MaxThreads 4
MaxQueue 64
# Longer than your slowest object transfer, or the worker sees ECONNRESET mid-stream.
ReadTimeout 300
# Re-check the database on disk every 60 s so a freshclam RELOAD is never missed.
SelfCheck 60
ConcurrentDatabaseReload yes

Foreground yes plus LogFile /dev/stdout is what makes the container behave: logs land in the task’s log driver instead of a file nobody reads, and there is no forked child for the supervisor to lose track of. FixStaleSocket yes covers the one case a tmpfs does not — a restart inside the same container.

Refresh the database without getting rate-limited

database.clamav.net is a CDN with per-IP quotas, and a NAT gateway shared by fifty tasks reads as one very greedy client. Run freshclam in exactly one place, write to the shared volume, and have it notify the daemon.

# /etc/clamav/freshclam.conf
DatabaseDirectory /var/lib/clamav
DatabaseMirror database.clamav.net
# 8 checks a day is the documented maximum before you risk a cool-down.
Checks 8
ScriptedUpdates yes
TestDatabases yes
NotifyClamd /etc/clamav/clamd.conf
CompressLocalDatabase no
ReceiveTimeout 300
Foreground yes
LogFileMaxSize 0
UpdateLogFile /dev/stdout

ScriptedUpdates yes pulls CDIFF patches — typically 40–200 KB per update against a 1.4 GB base. TestDatabases yes loads the candidate file into a throwaway engine before it replaces the live one, which is the difference between a bad mirror costing you nothing and a bad mirror taking the daemon down. NotifyClamd sends a RELOAD command over the socket named in that config file, so the update takes effect without a restart.

Gating your worker on readiness

A worker that dequeues before the engine is up will treat connect ENOENT as a transient error, retry three times, and dead-letter a perfectly good object. Gate startup on a real probe, and keep probing.

Readiness states of the ClamAV daemon as seen over the socket The daemon moves from starting to loading its database to ready, cycles through reloading when freshclam notifies it, drops to failed if the load is killed for memory, and to degraded when the database version is older than twenty-four hours. Readiness states probed over the socket starting no socket yet loading DB PING blocks ready PONG under 5 ms reloading scans continue failed exit code 137 degraded drain, page on-call boot 38 s RELOAD 3–6 s OOM kill db over 24 h
`degraded` is a state, not an error. The daemon still answers — it is just answering with last week's knowledge.

Two commands give you everything: zPING\0 returns PONG\0, and zVERSION\0 returns a string like ClamAV 1.4.2/27455/Tue Jul 21 09:12:04 2026 — engine version, database build number, build timestamp. Parse the third field and you have the freshness check.

// clamd-health.js — readiness and freshness probes over the Unix socket.
import { connect } from "node:net";

const SOCKET = process.env.CLAMD_SOCKET ?? "/var/run/clamav/clamd.sock";
const DB_MAX_AGE_HOURS = Number(process.env.DB_MAX_AGE_HOURS ?? 24);

function command(cmd, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const socket = connect(SOCKET);
    let reply = "";
    const done = (err, value) => {
      socket.destroy();
      err ? reject(err) : resolve(value);
    };
    socket.setTimeout(timeoutMs, () => done(new Error(`clamd ${cmd} timed out`)));
    socket.on("error", (err) => done(err));
    socket.on("connect", () => socket.write(`z${cmd}\0`));
    socket.on("data", (chunk) => { reply += chunk.toString("utf8"); });
    socket.on("end", () => done(null, reply.replace(/\0$/, "").trim()));
  });
}

export async function waitUntilReady({ timeoutMs = 180000, intervalMs = 2000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    try {
      if ((await command("PING", 2000)) === "PONG") return true;
    } catch (err) {
      if (Date.now() > deadline) throw new Error(`clamd not ready in ${timeoutMs} ms: ${err.message}`);
    }
    if (Date.now() > deadline) throw new Error(`clamd answered but never returned PONG`);
    await new Promise((r) => setTimeout(r, intervalMs));
  }
}

export async function databaseState() {
  // "ClamAV 1.4.2/27455/Tue Jul 21 09:12:04 2026"
  const version = await command("VERSION");
  const [engine, build, builtAt] = version.split("/");
  const ageHours = (Date.now() - Date.parse(builtAt)) / 3_600_000;
  return {
    engine,
    build: Number(build),
    ageHours: Math.round(ageHours * 10) / 10,
    fresh: ageHours <= DB_MAX_AGE_HOURS
  };
}

Three parameters carry the weight. timeoutMs on waitUntilReady must exceed your worst observed load time — 180 seconds is right for an EFS-backed volume in bursting mode, 60 is enough for local disk. intervalMs of 2000 keeps the log readable; polling every 100 ms just fills it. And DB_MAX_AGE_HOURS is a policy value: the daemon will happily scan with a three-week-old database and report OK, so the only thing stopping you writing a false clean verdict is this check. Record build alongside every verdict you persist — indexing that file metadata in PostgreSQL is what later lets you compute which objects need re-scanning when a signature lands late.

Call waitUntilReady() before the first receiveMessage, and run databaseState() on a 5-minute interval. If fresh goes false, stop taking work — a scanner that cannot be trusted should look broken, not healthy.

Configuration reference

Key Type Value used here Effect
Checks (freshclam) int 8 Update attempts per day. Above ~12 from one egress IP the CDN starts issuing cool-downs.
ScriptedUpdates bool yes Fetch CDIFF patches instead of the whole 1.4 GB daily file.
TestDatabases bool yes Load the candidate database in a scratch engine before swapping it in.
NotifyClamd path /etc/clamav/clamd.conf Sends RELOAD over the socket after a successful update.
LocalSocketMode octal 660 Socket permissions. The worker’s UID must be in the clamav group.
SelfCheck s 60 How often clamd re-stats the database directory. Your backstop if NotifyClamd is missed.
FixStaleSocket bool yes Unlink an orphaned socket file at bind time instead of exiting.
MaxThreads int 4 Concurrent scans. Match it to granted vCPU, not to worker count.
container memory MiB 3072 Must clear the reload peak (~2.6 GB), not the 1.4 GB plateau.
DB_MAX_AGE_HOURS int 24 Age past which the worker refuses to emit a clean verdict.

Configuration gotchas

Error: connect ENOENT /var/run/clamav/clamd.sock

The socket path is not shared, or the daemon has not bound yet. In Compose, both services need the same named volume mounted at /var/run/clamav; in ECS, both containers need the same volumesFrom/mountPoints entry. If the mount is right and you still see this at startup, it is just timing — that is what waitUntilReady() is for. EACCES on the same path is the other half: the worker’s user is not in group clamav and LocalSocketMode is 660.

ERROR: LOCAL: Socket file /var/run/clamav/clamd.sock could not be bound: No such file or directory

The directory does not exist. Container images do not create it for you, and a tmpfs mount replaces whatever the image had. Create it in the entrypoint, or mount the volume one level up. Note that this is the daemon’s own error, so the container exits before anything can connect.

ERROR: Parse error at line 12: Unknown option MaxThread

clamd refuses to start on any unrecognised key, and the Debian package ships a config full of options that changed names across majors. Validate the file in CI with clamd --config-file=clamd.conf --debug --help before it ever reaches a task definition.

WARNING: FreshClam received error code 429 from the ClamAV Content Delivery Network (CDN)

You are on a cool-down, and the following line tells you until when. This is almost always several tasks each running their own freshclam behind one NAT address. Fix it by running the updater exactly once against the shared volume — or, at scale, by standing up a private mirror and setting PrivateMirror instead of DatabaseMirror. Until it clears, the daemon keeps working with whatever it already has, which is precisely why the age check above is not optional.

Container exits with code 137 during a reload

The OOM killer. dmesg shows Killed process 1 (clamd). Raise the memory limit above the reload peak; setting ConcurrentDatabaseReload no also fixes it, at the cost of pausing every scan for the duration of the load.

Verification

EICAR is a 68-byte string every engine is required to detect, and it is harmless. Prove the whole path end to end:

# 1. Daemon is up and the database is current.
printf 'zPING\0' | socat - UNIX-CONNECT:/var/run/clamav/clamd.sock
# PONG
printf 'zVERSION\0' | socat - UNIX-CONNECT:/var/run/clamav/clamd.sock
# ClamAV 1.4.2/27455/Tue Jul 21 09:12:04 2026

# 2. The engine detects a known sample through the same socket the worker uses.
printf 'X5O!P%%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > /tmp/eicar.txt
clamdscan --config-file=/etc/clamav/clamd.conf --stream --no-summary /tmp/eicar.txt
# /tmp/eicar.txt: Win.Test.EICAR_HDB-1 FOUND

# 3. Concurrency and queue depth are what you configured.
printf 'zSTATS\0' | socat - UNIX-CONNECT:/var/run/clamav/clamd.sock
# POOLS: 1 STATE: VALID PRIMARY THREADS: live 1 idle 0 max 4 idle-timeout 30
# QUEUE: 0 items

Then upload the same sample through your real ingress — a presigned PUT issued by your AWS SDK v3 signing endpoint — and confirm the object ends up tagged scan-status=infected and relocated per your quarantine bucket pattern. If step 2 passes but the upload path does not, the fault is in the worker or the queue, not the engine.

Frequently Asked Questions

Can I run clamd and my Node worker in the same container?

You can, and it is fine for local development, but in production it couples two very different lifecycles: the engine needs 3 GB and restarts on every database problem, while the worker needs 256 MB and should keep its queue lease. Separate containers in one task give you independent restart policies and a log stream per process.

How much does an EFS-mounted signature volume slow down boot?

On a bursting-mode file system, expect 90–150 seconds to load 1.4 GB versus 30–40 from local disk, because the load is many small reads rather than one large one. Elastic throughput or a copy-to-local-disk step at startup both cut it back; the copy also removes the risk of two tasks reading a directory mid-update.

Do I still need file-type validation if every upload is scanned?

Yes — they answer different questions. ClamAV asks whether the content matches a known threat; validating file signatures with libmagic in Node.js asks whether the bytes are the format the client claimed. A hand-crafted SVG with an inline script is clean to one and rejected by the other.

Should the worker buffer the object to disk before scanning it?

No. Stream it straight from the storage response into the socket, the same way you would with any other Node.js web-streams upload path. Buffering a 2 GB object turns a 256 MB worker into a 2.5 GB one and adds nothing — the engine sees the same bytes in the same order either way.

What happens to objects the scanner never gets a verdict on?

They must be treated as infected, not clean. An ERROR reply, a socket timeout or a limits heuristic all mean unscanned, and the safe route is quarantine with a distinct tag so a human can revisit them. Give that prefix a short expiry using S3 lifecycle rules for temporary uploads so unresolved samples do not accumulate forever.