Overview
pompelmi can connect to clamd in two ways: via TCP (host:port) or via
a UNIX domain socket (socket path). Both use the ClamAV
INSTREAM protocol: data is sent in 64 KB chunks, each
prefixed with a 4-byte big-endian length. The stream is terminated by a
four-zero-byte sequence (\x00\x00\x00\x00).
UNIX sockets have lower latency than TCP because there is no loopback networking
overhead. TCP is more flexible for multi-container and remote setups. Both modes
produce identical Verdict results.
TCP: Docker sidecar
The simplest production setup is running clamd as a Docker sidecar on the same host as your application.
docker-compose.ymlservices:
clamav:
image: clamav/clamav:stable
ports:
- "3310:3310"
volumes:
- clamav_db:/var/lib/clamav # definitions survive restarts
healthcheck:
test: ["CMD", "clamdcheck.sh"]
interval: 60s
retries: 3
start_period: 120s # first-boot download runway
app:
build: .
depends_on:
clamav:
condition: service_healthy
environment:
CLAMAV_HOST: clamav
CLAMAV_PORT: "3310"
volumes:
clamav_db:
Application code
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/upload.zip', {
host: process.env.CLAMAV_HOST || '127.0.0.1',
port: Number(process.env.CLAMAV_PORT) || 3310,
});
if (result === Verdict.Malicious) { /* reject */ }
For quick local development without Compose:
docker run -d --name clamav -p 3310:3310 clamav/clamav:stable
First boot — expect 1–2 minutes
The very first time the ClamAV container starts, freshclam downloads
the virus definition database (~300 MB). clamd will not accept scan requests
until the download is complete.
The start_period: 120s in the healthcheck gives it that runway.
During this window the container reports starting, not
healthy.
starting state —
that will cancel the download and force it to restart from scratch.
If clamd refuses connections during this window, pompelmi will throw
ECONNREFUSED. Use the healthcheck or a retry loop in your
application startup path.
clamav_db
named volume, so startup is fast (a few seconds). Only the very first cold start,
or a start after wiping the volume, triggers the long download.
UNIX socket: local clamd daemon
If you run clamd as a system daemon (rather than in Docker), it exposes a UNIX
socket at /run/clamav/clamd.sock by default.
sudo apt-get install -y clamav clamav-daemon sudo freshclam # download definitions sudo systemctl enable --now clamav-daemonApplication code
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/upload.pdf', {
socket: '/run/clamav/clamd.sock',
});
if (result === Verdict.Clean) {
// safe to proceed
}
clamd.conf
(usually /etc/clamav/clamd.conf) for the LocalSocket
directive to confirm the path on your system.
UNIX socket: Docker with socket mount
You can share a UNIX socket between the ClamAV container and your app container using a named volume that holds only the socket file.
docker-compose.ymlservices:
clamav:
image: clamav/clamav:stable
volumes:
- clamav_db:/var/lib/clamav
- clamav_socket:/run/clamav # share socket directory
healthcheck:
test: ["CMD", "clamdcheck.sh"]
interval: 60s
retries: 3
start_period: 120s
app:
build: .
depends_on:
clamav:
condition: service_healthy
volumes:
- clamav_socket:/run/clamav:ro # mount read-only in app
environment:
CLAMAV_SOCKET: /run/clamav/clamd.sock
volumes:
clamav_db:
clamav_socket:
Application code
const { scanBuffer, Verdict } = require('pompelmi');
const result = await scanBuffer(req.file.buffer, {
socket: process.env.CLAMAV_SOCKET || '/run/clamav/clamd.sock',
});
App integration
Always use depends_on with condition: service_healthy
so your application container waits for clamd to be fully ready before it starts
accepting traffic.
app:
build: .
depends_on:
clamav:
condition: service_healthy
host: 'clamav' (not '127.0.0.1') as the
value of CLAMAV_HOST. Docker's embedded DNS resolves service names
automatically within the same Compose project.
scanBuffer and scanStream — no disk I/O
In TCP or UNIX socket mode, both scanBuffer() and
scanStream() pipe data directly to clamd without writing anything
to disk. This makes them ideal for serverless and memory-only upload pipelines.
const { scanBuffer, Verdict } = require('pompelmi');
// req.file.buffer from multer memoryStorage — never written to disk
const result = await scanBuffer(req.file.buffer, {
host: 'clamav',
port: 3310,
});
if (result !== Verdict.Clean) {
return res.status(400).json({ error: 'Upload rejected' });
}
scanStream — pipe S3 body directly to clamd
const { scanStream, Verdict } = require('pompelmi');
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const { Body } = await s3.send(new GetObjectCommand({
Bucket: 'my-uploads',
Key: event.key,
}));
// Body is a Readable — piped straight to clamd, no temp file
const result = await scanStream(Body, {
socket: '/run/clamav/clamd.sock',
timeout: 30000,
});
host, port,
or socket option), scanBuffer and scanStream
write a temporary file to os.tmpdir() and delete it in a
finally block. Switch to clamd mode to avoid the disk write.
Timeout
The timeout option (default 15000 ms) controls how
long pompelmi waits for socket activity before giving up. Increase it for large
files or when clamd is under heavy load.
const result = await scan('/uploads/large-archive.zip', {
host: '127.0.0.1',
port: 3310,
timeout: 60_000, // 60 seconds for large files
});
When the timeout is exceeded, pompelmi throws an error with the message:
clamd connection timed out after <N>ms.
timeout option applies to socket idle time, not
total scan time. Clamd streams the response incrementally; as long as data keeps
flowing, the timer resets. Very large files should still complete even with a
moderate timeout.
Retry & healthcheck
New in v1.10.0: use retries and retryDelay to
automatically retry on connection errors without extra wrapper code.
const result = await scan('/path/to/upload.pdf', {
host: '127.0.0.1',
port: 3310,
retries: 3,
retryDelay: 2000, // wait 2s between attempts
});
For application startup, use a manual retry loop until clamd is healthy before accepting traffic:
const { scan, Verdict } = require('pompelmi');
async function waitForClamd(host, port, maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i++) {
try {
// EICAR test string as a buffer — always returns Malicious when clamd is live
await scan('/dev/null', { host, port, timeout: 3000 });
console.log('clamd is ready');
return;
} catch {
console.log(`clamd not ready, attempt ${i + 1}/${maxAttempts}...`);
await new Promise(r => setTimeout(r, 3000));
}
}
throw new Error('clamd did not become ready in time');
}
await waitForClamd(process.env.CLAMAV_HOST, 3310);
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ECONNREFUSED |
clamd is not yet running or still downloading definitions. | Wait for the healthcheck to report healthy, then retry. |
Container stays starting for more than 5 minutes |
Definition download stalled or start_period is too short. |
Check docker compose logs clamav. Increase start_period if on a slow connection. |
clamd connection timed out |
The file is very large, or the container is under heavy load. | Increase timeout in options, or check container CPU/memory limits. |
Always returns ScanError |
Definitions are present but corrupted, or clamd restarted mid-stream. | Run docker compose restart clamav and wait for healthy. |
ENOENT on socket path |
The UNIX socket file does not exist yet (clamd still starting). | Wait for the container healthcheck, or retry with backoff until the socket file appears. |