Running pompelmi with ClamAV in Docker Compose
Containerizing your Node.js application alongside ClamAV requires a deliberate
architecture choice. pompelmi supports two scanning modes: invoking the
clamscan binary directly inside the same container, or connecting
to a remote clamd daemon over TCP. This guide covers both and
provides production-ready Docker Compose files for each.
Two approaches
| Approach | How it works | Best for |
|---|---|---|
| clamscan inside app container | ClamAV is installed in the same Docker image as your Node.js app. pompelmi calls clamscan as a subprocess. |
Simple deployments. Smaller compose file. Virus definitions are local. |
| clamd sidecar over TCP | A separate clamav/clamav sidecar container runs clamd. pompelmi connects to it over TCP using the host/port option. |
Production and Kubernetes. Shared definitions. Faster scans (no per-process startup). Scales independently. |
Option 1 — clamscan inside the app container
Install ClamAV in your Dockerfile alongside Node.js. The virus definitions are baked into the image or mounted as a volume.
# Dockerfile FROM node:20-slim # Install ClamAV (no daemon needed — clamscan only) RUN apt-get update \ && apt-get install -y --no-install-recommends clamav \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . # Download virus definitions at build time # (definitions are large; consider a volume mount instead for production) RUN freshclam --no-warnings || true EXPOSE 3000 CMD ["node", "server.js"]
freshclam into the image means definitions are frozen at
build time. In production, mount a persistent volume and refresh definitions
separately (see the Virus definitions section below).
pompelmi usage is unchanged — no configuration needed:
const { scan, Verdict } = require('pompelmi');
const verdict = await scan('/tmp/upload.pdf');
// Uses clamscan in PATH automatically
Option 2 — clamd sidecar over TCP
pompelmi's scan() accepts a host and port
option to connect to a remote clamd daemon instead of running
clamscan locally.
const { scan, Verdict } = require('pompelmi');
const verdict = await scan('/tmp/upload.pdf', {
host: process.env.CLAMD_HOST || 'clamav', // Docker service name
port: parseInt(process.env.CLAMD_PORT) || 3310
});
docker-compose.yml
Complete compose file for the clamd sidecar approach:
version: '3.9'
services:
app:
build: .
ports:
- '3000:3000'
environment:
- CLAMD_HOST=clamav
- CLAMD_PORT=3310
- UPLOAD_TMP=/tmp/shared-uploads
volumes:
- upload-tmp:/tmp/shared-uploads # Shared with clamav service
depends_on:
clamav:
condition: service_healthy
clamav:
image: clamav/clamav:stable
environment:
- CLAMD_CONF_MaxFileSize=100M
- CLAMD_CONF_StreamMaxLength=100M
volumes:
- clamav-db:/var/lib/clamav # Persist virus definitions
- upload-tmp:/tmp/shared-uploads # Shared with app service
ports:
- '3310:3310' # Expose only if debugging; remove in production
healthcheck:
test: ['CMD', '/usr/local/bin/clamdcheck.sh']
interval: 60s
retries: 3
start_period: 120s # Allow time for freshclam on first boot
volumes:
clamav-db:
upload-tmp:
On first boot the clamav/clamav image runs freshclam
automatically before starting clamd. The clamav-db
volume persists definitions across container restarts.
Waiting for clamd to be ready
clamd takes 30–120 seconds to load the full virus database on
first boot. Your Node.js app should not start accepting uploads until clamd
is healthy. The depends_on.condition: service_healthy in the
compose file above handles this, but you also need a runtime guard.
// startup.js — wait for clamd before accepting traffic
const net = require('net');
function waitForClamd(host, port, retries = 30, delayMs = 5000) {
return new Promise((resolve, reject) => {
function attempt(n) {
const socket = net.createConnection({ host, port }, () => {
socket.destroy();
resolve();
});
socket.on('error', () => {
if (n <= 0) return reject(new Error('clamd not available after retries'));
console.log(`Waiting for clamd at ${host}:${port} (${n} attempts left)...`);
setTimeout(() => attempt(n - 1), delayMs);
});
}
attempt(retries);
});
}
async function main() {
const host = process.env.CLAMD_HOST || 'clamav';
const port = parseInt(process.env.CLAMD_PORT) || 3310;
console.log('Checking clamd availability...');
await waitForClamd(host, port);
console.log('clamd is ready. Starting application...');
require('./server'); // Start the actual application
}
main().catch(err => { console.error(err.message); process.exit(1); });
Keeping virus definitions current
Stale definitions significantly reduce detection quality. In production, run
freshclam on a schedule inside the clamav container.
The official clamav/clamav image runs freshclam automatically,
but you can also add a cron container for more control.
# Add to docker-compose.yml services section
freshclam:
image: clamav/clamav:stable
command: ['freshclam', '--daemon', '--checks=24', '--foreground=yes']
volumes:
- clamav-db:/var/lib/clamav
depends_on:
- clamav
Production tips
-
Remove
ports: - '3310:3310'from theclamavservice in production. clamd should only be reachable on the internal Docker network. -
Set
CLAMD_CONF_MaxFileSizeandCLAMD_CONF_StreamMaxLengthto match your application's upload size limits. Files larger than these values will return a scan error, not a clean verdict. -
The
upload-tmpshared volume is transient by default. In a multi-replica setup, all replicas ofappmust write to the same volume, and clamd must be able to read from it. Consider a network filesystem (NFS, EFS) or a streaming INSTREAM mode if you need horizontal scaling. -
Monitor
clamd's memory usage. Loading the full virus database consumes roughly 1 GB of RAM. Allocate accordingly in your container resource limits.